From b0f6aca132c558b2e4c584849e08095477e4dda7 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 04:14:46 -0400 Subject: [PATCH 001/128] docs: lock Rust render-plan architecture --- docs/log.md | 15 + docs/planning/decision-register.md | 3 +- docs/planning/rust-layout-engine.md | 1109 ++++++++++++++++++++++----- 3 files changed, 951 insertions(+), 176 deletions(-) diff --git a/docs/log.md b/docs/log.md index 9c0398e3..fa014c56 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,21 @@ ## 2026-08-08 +- **Locked the Rust text-engine and retained render-plan architecture** — Expanded the narrower layout-boundary proposal + into one `no_std + alloc` Rust semantic pipeline for Unicode analysis, bidi, fallback, shaping, per-line editorial + composition, typography geometry, and policy-directed incremental render-plan compilation. The steady-state host + transaction is one revisioned Wasm update with A/B synchronous publication, worker-owned transferable buffers for + retained/asynchronous consumers, explicit return-to-worker retirement, invalidation-directed patches, and + scalar-versus-SIMD admission on the target 25,515-glyph workload. The plan makes sequential regions and declarative + exclusions one-call inputs, cuts unbounded publishing solvers and second authored text channels, and puts the + Wasm/policy/display-list proof before added typography. Review of the base font-fallback implementation also exposed + that its same-technique restriction came from the old one-program/one-schema API rather than shaping or measured + performance. D-161 now makes technique and resource binding properties of each loaded font, permits heterogeneous + same-runtime stacks, removes technique from user-facing `Text` and `TextGroup`, requires every first-party engine policy + to support Bitmap, MSDF, and Slug, and lets third-party policies declare a runtime-validated subset. The render plan + partitions resolved glyphs by technique/resource/program and publishes all participating resources atomically instead + of requiring synthetic composite techniques. + - **Boundary reshaping was redundant by construction** — The largest single cost left in a warm update turned out to be work that could not change its own output. Each reshape range supplied `contextStart: run.start, contextEnd: run.end`, which is exactly the context the retained paragraph shape was produced with, so the shaper returned the glyphs it had already returned — on roughly every line, on every layout. The buffer's beginning- and end-of-text flags did not rescue it either: they describe the buffer edge, and the surrounding text shipped as context overrides them. Three independent lines of evidence agree. The mechanism above; a measurement over 640 ranges and 20,280 glyphs across Latin word wrap, Arabic word wrap, and Arabic character wrap narrow enough to force breaks inside joined words, where every reshaped glyph matched the retained shape; and the pinned natural, wide, and narrow layout hashes plus the entire alignment, clipping, max-lines, ellipsis, and justification contract, all unchanged with it removed. An early attempt to measure this by disabling the range emission alone was wrong and briefly looked like proof that reshaping mattered: clearing the ranges while leaving the fragments flagged made positioning look for a result that no longer existed. `ReshapeRange` stays, because a narrowed context is a real future need — a truncated line whose last letter should take its final form, or a line composed as an isolated unit for per-line widths — and the contract tests now assert zero crossings so reintroducing one is deliberate rather than silent. Against the pre-optimization commit on an identical workload at 25,515 glyphs, a reflow now lays out in 8.09ms against 110.40ms, inside the 8.33ms budget at 120Hz; a resize in 10.59ms against 103.54ms; a text edit in 33.62ms against 109.66ms. - **Distance-field subpaths renamed, and the line that made it safe** — `./raster/mtsdf` and `./three/mtsdf` sat beside `./bakers/msdf`, so a consumer wrote one spelling to bake and another to render. The export paths and the symbols reachable through them now read msdf. The rule that made the change tractable is that identifiers move and string literals do not: a first attempt swept 370 occurrences across the monorepo and broke four separate things, every one of them a literal. The worst was msdfgen's own `mtsdf` CLI mode, which is a different algorithm from its `msdf` mode and would have silently changed what the native quality oracle generates without failing loudly. The others were the baked artifact kind, the packaged schema enum, and fixture filenames. Nothing persisted moved in the landed change: the glTF extension encoding value, the schema enum, the validator's diagnostic codes, the Rust crate and bin target names, the generated ABI module, the baker Wasm filenames, and every fixture filename keep their spelling. The benchmark application keeps `mtsdf` throughout, because its conformance scenario identifiers and `?technique=mtsdf` URL vocabulary appear in checked-in GPU performance evidence and moving them would mean regenerating hardware results for a spelling change; it consumes the renamed package symbols by aliasing them at its ten import sites instead. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 50dc66b4..e9544858 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -203,7 +203,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | | D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | | D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | -| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Accepted | +| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | | D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | | D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | | D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | @@ -225,6 +225,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | | D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | | D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | +| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 30881adb..2bc805aa 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1,217 +1,976 @@ --- type: Design Proposal -title: Rust layout engine and the atomic frame ABI -description: Moves retained paragraph layout into the existing Rust shaper crate behind one frame-scoped entry point, so the logic that must be correct is written once and reused natively, and instance data reaches the GPU without leaving Wasm memory. +title: Rust text engine and retained render-plan ABI +description: Defines a Rust-owned shaping, layout, typography, and render-plan pipeline with one steady-state Wasm update transaction and renderer-directed incremental output. status: draft tags: - layout + - shaping + - typography + - rendering - wasm - performance - abi generated: - by: anthropic-claude/opus-5 - at: '2026-08-08T05:10:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-08T07:00:00Z' sources: - id: layout-benchmark resource: ../../packages/text/scripts/benchmark-paragraph-layout.mts title: Paragraph layout benchmark, workflow text:layout-benchmark - - id: shaper-crate - resource: ../../packages/text/rust/shaper/src/lib.rs - title: HarfRust Wasm shaper crate - id: paragraph resource: ../../packages/text/src/paragraph.ts title: TypeScript paragraph preparation and layout - - id: bitmap - resource: ../../packages/text/src/raster/bitmap-technique.ts - title: Portable Bitmap technique, canonical instance storage - - id: pbo - resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgl-fallback/nodes/GLSLNodeBuilder.js - title: Three.js WebGL fallback node builder, setupPBO + - id: paragraph-batch + resource: ../../packages/text/src/paragraph-batch.ts + title: Current canonical packing and dirty-range implementation + - id: shaper-crate + resource: ../../packages/text/rust/shaper/src/lib.rs + title: HarfRust Wasm shaper crate + - id: vertical-writing + resource: vertical-writing.md + title: Vertical-writing research + - id: editorial-flow + resource: editorial-flow-layout.md + title: Editorial-flow research + - id: writing-modes + resource: https://www.w3.org/TR/css-writing-modes-4/ + title: CSS Writing Modes Level 4 + - id: css-text + resource: https://www.w3.org/TR/css-text-4/ + title: CSS Text Level 4 + - id: text-decoration + resource: https://www.w3.org/TR/css-text-decor-4/ + title: CSS Text Decoration Level 4 + - id: inline-layout + resource: https://www.w3.org/TR/css-inline-3/ + title: CSS Inline Layout Level 3 + - id: multicol + resource: https://www.w3.org/TR/css-multicol-2/ + title: CSS Multi-column Layout Level 2 + - id: jlreq + resource: https://www.w3.org/TR/jlreq/ + title: Requirements for Japanese Text Layout + - id: ruby + resource: https://www.w3.org/TR/css-ruby-1/ + title: CSS Ruby Annotation Layout Module Level 1 + - id: harfbuzz + resource: https://harfbuzz.github.io/harfbuzz-hb-buffer.html + title: HarfBuzz buffer and safe-concatenation contract + - id: icu4x + resource: https://docs.rs/icu_segmenter/latest/icu_segmenter/ + title: ICU4X segmenter Unicode-version documentation + - id: parley + resource: https://docs.rs/parley/latest/parley/layout/ + title: Parley retained rich-text layout - id: pretext resource: https://github.com/chenglou/pretext - title: Pretext, incremental per-line text layout + title: Pretext incremental per-line text layout + - id: webrender + resource: https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html + title: Firefox rendering overview and display lists + - id: staging + resource: https://docs.rs/wgpu/latest/wgpu/util/struct.StagingBelt.html + title: wgpu staging-belt upload model + - id: wasm-js-api + resource: https://webassembly.github.io/spec/js-api/ + title: WebAssembly JavaScript API + - id: wasm-simd + resource: https://webassembly.github.io/spec/core/ + title: WebAssembly core SIMD and relaxed-operation semantics + - id: rust-simd + resource: https://doc.rust-lang.org/core/arch/wasm32/index.html + title: Rust wasm32 SIMD intrinsics and target-feature model + - id: safari-simd + resource: https://webkit.org/blog/13966/webkit-features-in-safari-16-4/ + title: WebAssembly 128-bit SIMD in Safari 16.4 + - id: chrome-simd + resource: https://blog.chromium.org/2021/04/chrome-91-handwriting-recognition-webxr.html + title: WebAssembly SIMD enabled by default in Chrome 91 + - id: firefox-simd + resource: https://bugzilla.mozilla.org/show_bug.cgi?id=1625130 + title: Firefox WebAssembly SIMD shipping record + - id: worker-transfer + resource: https://html.spec.whatwg.org/multipage/workers.html + title: HTML Worker transfer semantics + - id: renderer-capabilities + resource: renderer-capabilities.md + title: Renderer capability matrix + - id: payload-budget + resource: payload-budget.md + title: Font and raster payload budget + - id: mlreq + resource: https://www.w3.org/TR/mlreq/ + title: Mongolian Layout Requirements + - id: unicode-emoji + resource: https://www.unicode.org/reports/tr51/ + title: Unicode Emoji 17 + - id: opentype-colr + resource: https://learn.microsoft.com/en-us/typography/opentype/spec/colr + title: OpenType COLR color table + - id: opentype-cbdt + resource: https://learn.microsoft.com/en-us/typography/opentype/spec/cbdt + title: OpenType CBDT color bitmap table + - id: opentype-sbix + resource: https://learn.microsoft.com/en-us/typography/opentype/spec/sbix + title: OpenType sbix color bitmap table --- -# Rust layout engine and the atomic frame ABI - -This proposal moves retained paragraph layout from TypeScript into the existing Rust shaper crate, behind a single -frame-scoped entry point, and makes canonical instance storage the engine's output rather than something JavaScript -assembles afterwards. - -It is written after a measured TypeScript optimization pass, so it inherits that pass's evidence rather than -speculating. Read [the decision register](decision-register.md) entries D-159 and D-160 for the tiering and the -measurement discipline the numbers below depend on. - -## Why this is not only a performance change - -Three requirements decide the shape, and only one of them is speed. - -**The logic must be written once.** Bidi resolution, cluster boundaries, break opportunities, shaping integration, and -line composition are the parts most expensive to get right and most expensive to get wrong. A native consumer that -reimplements them inherits a second set of bugs and a second conformance obligation. Rust compiles to both targets from -one source, so the correctness-critical core stops being a per-target liability. - -**Layout must be per line, with a caller-supplied width.** An editorial page flows text around images and objects, so -each line has its own available width, computed by subtracting blocked intervals. A paragraph-scoped pass cannot express -that: there is no single width to pass it. Pretext demonstrates the working shape — prepare once, then -`layoutNextLine(prepared, cursor, maxWidth)` with a segment/grapheme cursor carried between calls, each call free to -take a different width.[^pretext] Per-line layout is therefore a feature requirement that happens to also be the -performance answer. - -**The frame must cross the boundary once.** Not once per operation — once. Today a text edit crosses three times -(`analyzeBidi`, `shapeBatch`, `reshapeRanges`) and a resize once. Three exports mean three opportunities for the host to -observe or mutate intermediate state, and they are the reason layout logic accreted on the JavaScript side: whatever -sits between two crossings has to live somewhere, and it lived in TypeScript. - -## What per-line layout does and does not buy - -Layout cascades. If a break position moves on line three, every line after it moves. Per-line composition does not -remove that and no model does; it is inherent to flow. What changes is the cost of each cascaded line and the number of -lines that must be composed at all. - -| Property | Paragraph-scoped pass | Per-line with a cursor | -| --- | --- | --- | -| Cascade length after a break moves | lines after the change | lines after the change | -| Reshape cost per cascaded line | whole line | glyphs straddling the boundary | -| Per-line available width | one width for the paragraph | one width per line | -| Stop when the viewport is full | not expressible | stop at the last visible line | -| Resume after an edit | recompose the paragraph | resume from the last unaffected line | - -The last two are the asymptotic ones. Four columns of six thousand glyphs with forty visible lines each compose forty -lines per column, not the whole column. That is the case the roadmap's editorial showcase actually renders, and a -paragraph-scoped pass cannot reach it by any constant-factor tuning. - -## What the TypeScript pass already established - -Do not re-derive these; they are measured and they bound what remains. - -Cost per invalidation class at 25,515 glyphs, identical workload, pre-optimization commit against the current tree: - -| Case | Before | After | -| --- | --- | --- | -| Resize | 103.54 ms | 21.54 ms | -| Reflow | 110.40 ms | 17.37 ms | -| Text edit | 109.66 ms | 39.53 ms | - -Three findings from that pass constrain this design. - -**Marshalling is not the cost.** Copying the shaped result out of Wasm across eleven arrays costs 0.08 ms per update for -604 KiB; copying canonical storage into the Three attribute costs 0.043 ms for 1.22 MB. Bulk copies are fast. Retaining -data in Wasm is worth doing because it decides *where the compute happens*, not because the copies are expensive. Any -proposal that justifies itself by saved memcpy time is justifying itself wrongly. - -**Boundary reshaping was redundant as built.** It requested the whole run as shaping context, which is the context the -retained shape already used, so it returned the glyphs it already had. Measured byte-identical over 640 ranges and -20,280 glyphs across Latin word wrap, Arabic word wrap, and Arabic character wrap narrow enough to break inside joined -words. The capability matters only under a *narrowed* context, which is exactly what per-line composition and ellipsis -truncation introduce — so the Rust engine must reintroduce it deliberately, with a test that fails when it is absent. - -**Segmentation and line breaking are already absent from the warm paths.** The text-analysis tier is retained across any -change that alters neither text nor base direction, so a resize and a reflow do not touch them. Moving them to Rust -cannot improve those two classes by any amount. They move for the write-once requirement, not for frame budget. - -## The ABI - -One export per frame. - +# Rust text engine and retained render-plan ABI + +This proposal supersedes the narrower “move paragraph layout into Rust” draft. The unit moving into Rust is the complete +text engine: Unicode analysis, bidi, style itemization, shaping, cluster construction, line breaking and composition, +positioning, typography-derived geometry, and renderer-neutral render-plan compilation. TypeScript retains public API +lifecycle and renderer integration; it does not retain a second implementation of typography. + +The engine publishes two related products: + +1. a semantic layout snapshot for editing, accessibility, hit testing, native reuse, and diagnostics; and +2. a revisioned render plan describing resources, physical buffer views, minimal patches, ordered primitives, and draw + packets that the initial Three/TSL dual-backend adapter or a native adapter lowers into its own commands. + +The render plan is deliberately closer to a display list or submission transaction than a shaped-glyph array. It is +not a literal WebGPU command buffer: backend-specific pipelines, staging, fences, and command encoding remain with the +renderer. + +## Decisions this proposal makes + +- Correctness-critical shaping and layout logic has one Rust implementation shared by native and Wasm consumers. +- A dirty text session performs one Wasm update transaction in steady state. Unchanged animation frames perform none. + Font registration, session creation, cold reservation, and transfer-buffer return are lifecycle operations, not + hidden typography crossings. +- Per-line widths are computed inside Rust from declarative flow regions, columns, exclusions, and inline objects. An + arbitrary host callback per line is incompatible with both a single crossing and native reuse. +- The engine output contains semantic layout state and a portable render plan. Canonical GPU-ready records are requested + views within that plan, not the only representation of the result. +- Render implementations register a versioned render-plan policy describing formats, batching compatibility, + capabilities, patch preferences, and permitted augmentations. Stable policies are referenced by ID on updates rather + than serialized every frame. +- A loaded font owns its raster technique and resource binding. `FontStack`, `Text`, and `TextGroup` do not ask the user + to repeat a technique: an ordered stack may contain fonts from different techniques in the same runtime, and the + render policy declares which of those techniques its engine can lower. +- Result publication uses A/B Wasm buffers for synchronous reads only. A retained or asynchronous result is copied into + a worker-owned transferable `ArrayBuffer`; root returns ownership of that same buffer to the worker on retirement so + pooling or garbage collection occurs on the worker rather than root. +- Incremental output is revision-relative. If a consumer cannot apply the advertised base revision, the engine emits a + checkpoint rather than allowing a partially updated buffer. +- Scalar Rust remains the correctness oracle, but the engine's chunking, storage, alignment, flags, summaries, scratch, + and render-policy execution are designed around 128-bit Wasm lanes before the port. SIMD viability is an entry gate, + not a cleanup experiment after an allocation-heavy scalar architecture has hardened. + +## What the current code establishes + +The current ownership split does not match the target architecture: + +- `paragraph.ts` owns Unicode segmentation, style and bidi orchestration, shaping-run preparation, cluster measurement, + UAX line-break consumption, greedy composition, ellipsis, visual reordering, positioning, alignment, and + justification. +- the Rust shaper owns HarfRust calls, bidi analysis, font registration, and its wire ABI, but no paragraph composition; +- `paragraph-batch.ts` packs every live glyph from slot zero and then byte-compares the full live storage to discover + dirty ranges; and +- renderer adapters repack or copy those canonical arrays again according to backend constraints. + +That evidence changes several claims in the earlier draft: + +- A current text edit crosses the Wasm boundary at most for bidi analysis and broad shaping. The redundant boundary + reshape crossing has already been removed and contract tests assert zero reshape calls. +- “Atomic entry point, no logic moved” is not a valid stage. TypeScript needs bidi output to construct shaping runs, and + future line composition determines the narrowed context used for boundary reshaping. Those operations cannot become + private internal calls while TypeScript still orchestrates the data dependency between them. +- Small memcpy measurements show that copying is not the dominant cost in the measured workload. They do not establish + that full-buffer rewriting, scanning, repeated upload calls, or suffix movement are free. +- Double buffering does not eliminate CPU-to-GPU transfer and does not make a detached JavaScript view valid after + `memory.grow()`. It protects immutable publication generations; renderer-owned staging protects GPU submission. +- Returning only instance data would discard the information native consumers, editing surfaces, selection, + accessibility, decorations, and alternative renderers need. + +On the current branch, the pinned `text:layout-benchmark -- --glyphs 22000` workload renders 25,515 glyphs and measured: + +| Invalidation | Median | p95 | Relative to 8.33 ms | +| --- | ---: | ---: | ---: | +| cold | 53.78 ms | 73.83 ms | 6.5× median | +| font size | 12.50 ms | 17.75 ms | 1.5× median | +| layout width | 9.38 ms | 13.69 ms | 1.1× median | +| text edit | 39.11 ms | 41.72 ms | 4.7× median | + +The warm lanes showed 12.7–15.4% relative standard deviation in that run. These are a local baseline, not a universal +forecast, but they establish that the present full-paragraph warm path does not synchronously meet 120 Hz. + +## Architectural boundary + +### Rust core + +Create a renderer-neutral `no_std + alloc` Rust text-engine core, separated from the Wasm transport. The Wasm artifact +uses the repository-pinned Talc 5.0.4 dynamic allocator, aborting panics, LTO, one codegen unit, stripping, and the +`wasm-opt -Oz` pass, matching the existing portable Wasm crates. Host-only measurement, fixture, compression, and oracle binaries may +use `std`; production semantic code may not acquire a `std`-only dependency. + +The core owns: + +- retained document and paragraph state, revisions, cache dependencies, and invalidation; +- Unicode 17 grapheme, word, script, bidi, and line-break analysis; +- the existing immutable ordered `FontStack` and `.notdef`-driven fallback semantics, generalized so each selected font + carries its own raster technique and resource binding, plus style itemization, OpenType features, and horizontal and + vertical shaping of the existing static-font contract; +- cluster advances, safe boundary reshaping, line composition, justification, and positioning; +- horizontal and vertical writing-mode geometry; +- decorations, inserted-glyph provenance, inline-object placement, and interaction geometry; +- resource identities, stable instance identities, physical record compilation, dirty patches, and draw packets; and +- scalar and optional SIMD implementations behind identical semantic contracts. + +The same crate is called directly by native consumers. A thin Wasm crate validates the binary request, invokes the +core, and publishes a versioned binary result. Browser-specific typed-array pinning must not leak into the core. + +### TypeScript host + +TypeScript owns: + +- the ergonomic public API and conversion into explicit engine mutations; +- font and raster-resource lifecycle; +- registration of render-plan policies and backend capability sets; +- pinning and re-pinning Wasm memory views; +- transferable-buffer retirement: root transfers a retired buffer back to its originating worker rather than dropping + it on the root thread; +- lowering the renderer-neutral plan through the one Three/TSL policy used by both WebGPU and forced WebGL2; and +- renderer-owned upload staging, command encoding, fences, and transfer-buffer retirement. + +TypeGPU product integration is outside this stack. The Three/TSL adapter and a minimal native plan consumer prove the +display-list and policy boundaries without adding another renderer dependency or product surface. + +It does not decide bidi runs, break lines, position glyphs, synthesize decorations, or rebuild dirty ranges. + +## Retained update ABI + +The hot operation is one mutation transaction: + +```text +text_update(session_id: u32, request_offset: u32, request_len: u32) -> u32 ``` -text_update(request_offset: u32, request_len: u32) -> u32 // returns result offset -``` - -Bidi, shaping, segmentation, line composition, boundary reshaping, and instance packing all become internal Rust calls. -`analyzeBidi`, `shapeBatch`, and `reshapeRanges` stop existing at the boundary. There is no partial-state seam for -layout logic to accrete on. - -**The request is written in place.** JavaScript holds a pinned view over a retained staging region and writes the -frame's mutations into it: text deltas, style and span changes, constraints, and the per-line width intervals that carry -hole punching. Then it calls `text_update` once. - -**Growth never costs a second crossing in steady state.** The result header carries the capacity the next frame will -need. Growth happens at the top of the next single call. A frame that grows toward the watermark may need a second call; -growth doubles and then backs off to fit, so this settles and does not recur. - -**Detachment has one discipline, because `memory.grow()` detaches every view regardless of a declared maximum** -— verified in the pinned Node runtime: with `maximum` set, `buffer.resizable` is `false` and an existing view is -detached by a grow. Double buffering inside Wasm does not prevent this; detachment is a property of the memory, not of -a buffer slot. The rule is therefore: -1. `text_update` is the only export that may grow memory. -2. After it returns, compare `memory.buffer` identity against the retained one. -3. Re-pin every view if it changed. -4. Upload. No Wasm call may occur between re-pin and upload. +“One crossing” means one call for a dirty session update. It does not mean one call on every `requestAnimationFrame`, +and it does not forbid cold lifecycle exports whose outputs are retained. -Double buffering remains in the design for its actual purpose: the engine writes the next frame's instance data into the -free buffer while the active one is still being read by the renderer, and swaps. That is what makes the retained result -safe to alias, once the detachment discipline above holds. +### Request -**The result is instance data, not shaped glyphs.** The engine writes canonical technique storage directly — for Bitmap -that is `origins`, `sizes`, `uvOrigins`, `uvSizes` as tightly packed pairs and `colors` as quads, which is already the -GPU attribute layout[^bitmap] — plus the coalesced dirty ranges describing what changed. +The versioned request contains offsets to packed sections for: -## What the GPU can and cannot alias +- ABI version, session ID, expected engine revision, and last consumed render-plan revision; +- ordered text mutations and stable style/span mutations; +- paragraph constraints and writing-mode changes; +- complete flow geometry for this call: regions and their shapes, exclusions, inline objects, viewport, and explicit + page/region constraints, all in region-local coordinates before entry; +- deterministic composition limits: maximum regions, lines, clusters, and output bytes for this update; +- a registered render-policy ID and capability-set ID; +- requested semantic views such as hit-test, caret, selection, accessibility, and diagnostics; and +- optional policy parameters whose schema was validated at registration. -Verified against the pinned Three.js, not assumed. +Stable fonts, policies, and capabilities are referenced by IDs. Repeating a large descriptor every frame would merely +move host work into serialization. -**WebGPU aliases correctly**, for `itemSize` 2 and 4. `itemSize` 3 makes Three reallocate every update. Bitmap's layout -qualifies as authored; MTSDF and Slug repack into `vec4` and need their canonical layouts checked against the same -constraint before they can alias. +All offsets and lengths are range-checked before use. Enum tags, alignment, multiplication, and revision relationships +are validated at the Wasm boundary. Failure returns a typed result without exposing partially mutated state. -**WebGL2 cannot alias under the PBO path.** `GLSLNodeBuilder.setupPBO` assigns `attribute.array = newArray` with -power-of-two padding and hands that array to a `DataTexture` it retains by reference.[^pbo] A view over Wasm memory is -copied once and dropped, and later re-pinning is silently ineffective. +### Result -This is not a blocker. One copy on WebGL2 replaces the four the current pipeline performs, and WebGPU — the flagship -backend and the native path — gets the direct alias. The design should not contort to make WebGL2 zero-copy. +The result header contains: -## Staging +- ABI version, status, engine revision, render-plan revision, and required base revision; +- active A/B output slot and publication generation; +- request and output capacity watermarks for a later update; +- offsets and lengths for semantic layout tables; +- offsets and lengths for resource, buffer, patch, primitive, and draw-packet tables; and +- diagnostics, feature fallbacks, and performance counters requested for development builds. -Each stage lands independently, proves byte-identical layout against the pinned goldens, and reports -`text:layout-benchmark` before and after. A stage that does not move its measured number is reported as such and kept or -dropped on its merits, not on expectation. +The engine commits a revision only after every section is valid. A failed update leaves the previously published +revision consumable. -**Stage 1 — the atomic entry point, no logic moved.** Introduce `text_update` and the retained staging region. Bidi, -shaping, and reshaping become internal calls behind it; the three current exports are removed. Layout stays in -TypeScript, driven through the new single crossing. This isolates the ABI change from every logic change, so a -regression here is unambiguously the ABI. +### Capacity and memory growth -**Stage 2 — instance packing in Rust.** Canonical storage becomes engine output. This is the phase with the most -favourable ratio: a fixed-width transform, no data dependence between glyphs, and it is where SIMD pays first. Gate on -the packed-consumer hash staying byte-identical. +JavaScript cannot write an oversized request before calling the function that would grow its staging region. The ABI +therefore has an explicit cold lifecycle operation: -**Stage 3 — per-line composition with a cursor.** `layout_next_line(cursor, max_width)` with the caller supplying width -per line. Line breaking initially consumes host-supplied break opportunities so the Unicode conformance gate is -untouched. Boundary reshaping returns here, under a narrowed context, with a test that fails if it is absent — this is -where hole punching and correct ellipsis truncation become expressible. - -**Stage 4 — text analysis in Rust.** Grapheme segmentation moves first; `unicode-segmentation` is Unicode 17 and passes -the official grapheme vectors. Line breaking waits: no published Rust crate passes Unicode 17 `LineBreakTest`, and this -repository gates on that file passing unchanged. Until ICU4X publishes a conforming UAX #14 segmenter, break -opportunities stay host-supplied through the Stage 3 ABI, which costs nothing because the tier is already retained -across the warm paths. +```text +text_reserve(session_id: u32, request_capacity: u32, result_capacity: u32) -> u32 +``` -## Where SIMD pays +The host computes the exact encoded request length before pinning. It calls `text_reserve` only when that length exceeds +the retained request arena or when a previous result watermark requires more result capacity. Reservation grows by the +declared settling policy, re-pins all views, and performs no typography. The normal sequence is: + +1. reserve request and result capacity at session creation, or call `text_reserve` before pinning when a later mutation + exceeds either watermark; +2. write the next mutation request into the retained staging arena; +3. call `text_update` once; +4. compare `memory.buffer` identity, re-pin all views if it changed, and validate the result header; +5. synchronously consume the published slot, or copy retained/asynchronous bytes into a worker-owned transfer buffer; +6. transfer that buffer to root with the plan revision and ownership token; and +7. when the renderer retires it, transfer the same buffer back to the worker for pooling or worker-side collection. + +In the pinned runtime, `memory.grow()` detaches fixed-length `ArrayBuffer` views even when the memory declares a maximum. +The invariant is therefore not “only one export may ever grow.” It is: no memory-growing call may occur between pinning +a published result and the consumer's synchronous read, and the update operation is the only hot-path grow point. Future +resizable Wasm buffers can be feature-detected without weakening this fallback. + +The Wasm A/B pair is never retained by root. The worker may reuse a Wasm slot as soon as its bytes have been consumed or +copied. Transfer buffers have an explicit ownership state machine—`worker-owned -> transferred-to-root -> retired -> +transferred-to-worker`—and are never accessed while detached. A bounded worker-side pool reuses returned capacities; +excess buffers become unreachable and are collected on the worker. Failure to return a buffer is observable backpressure, +not permission to grow an unbounded pool. GPU staging belts and submission fences remain backend responsibilities. +[^staging][^worker-transfer] + +## Rust layout pipeline + +Each update follows one dependency graph inside Rust: + +```text +mutations + -> retained Unicode analysis and style itemization + -> bidi runs and font fallback + -> shaping and clusters + -> flow-band and inline-slot construction + -> line breaking, narrowed boundary reshaping, and composition + -> axis-neutral positioning, baselines, justification, and overflow + -> decoration, inline-object, hit-test, caret, and selection geometry + -> semantic snapshot + -> policy-directed render-plan compilation +``` -Named so the claim can be checked rather than assumed. In descending expected value: instance packing, a fixed-width -transform over independent glyphs; cluster advance prefix sums; per-line width accumulation; break-candidate scanning -over packed flags; and bidi level run detection. Each should be measured against a scalar Rust baseline before it is -described as a win — a Rust rewrite that is not vectorized is the honest comparison point, not the TypeScript one. +### Analysis and line breaking + +The final engine cannot leave Unicode line breaking in TypeScript. The current JavaScript implementation is Unicode 17 +and passes the repository's unmodified `LineBreakTest` gate. Published Rust segmenters do not yet provide the same +Unicode-version guarantee; for example, the current ICU4X line segmenter documents Unicode 15.1 data while its other +segmenters have advanced.[^icu4x] + +The implementation stage should therefore port the current generated Unicode 17 tables and rule evaluation into Rust, +preserving attribution and licensing, and prove it against the same official vector file. Host-supplied break +opportunities may exist only as a short-lived differential-oracle mechanism before cutover, not as the architecture. + +### Per-line composition and editorial flow + +Pretext and Parley both support a retained prepared layout with line-by-line progress, but the public hot path here must +not call back to JavaScript for each width.[^pretext][^parley] Rust builds line bands from declarative regions and +subtracts exclusions to produce one or more available inline intervals. A line cursor carries the logical cluster, +fragment, region, column, and block-axis position. Public resume tokens allow pagination and viewport-limited layout +without exposing a mutable internal pointer. + +Columns are strictly sequential. Each region has caller-supplied fixed geometry; the engine fills it once in logical +order and advances the cursor to the next region when its block extent is exhausted. There is no target-height search, +retry, redistribution, or implicit balancing solver. A shorter final column is valid output. + +Sequential overflow through at least two supplied regions is required. Every region and exclusion is supplied before +the one `text_update` call, which completes shaping, band construction, exclusion subtraction, breaking, boundary +reshaping, positioning, and plan compilation without a measurement callback or host round trip. The measured envelope +may cap how many regions, vertices, exclusions, lines, and clusters one realtime transaction accepts; it may not remove +multi-region continuation. Missing the 4 ms gate blocks the milestone until the implementation or supported numeric +envelope changes. The engine never uses a wall-clock timer to stop mid-update. Partial/resume is an explicit overflow +result for requests outside that envelope, not the normal layout path. + +### Portable flow model and Three integration + +“Column” is not a core type. The core type is an ordered flow thread containing stable regions. Equal adjacent +rectangles render as columns; pages, panels, irregular shapes, and several text planes use the same model. + +```text +FlowThread { + regions: [FlowRegionId...] + limits: { max_regions, max_lines, max_clusters, max_output_bytes } +} + +FlowRegion { + id + writing_mode + local_shape: rectangle | bounded_simple_polygon + exclusions: [rectangle | bounded_simple_polygon...] + clip_bounds + geometry_revision +} +``` -## Risks +The exact polygon vertex, exclusion, and slot limits are selected by the Stage 0 performance packet. Shapes are in the +region's local 2D text plane. A region descriptor contains no Three object, world matrix, material, or GPU handle. + +The current `ParagraphContentBox` becomes shorthand for a flow thread containing one rectangular region. The portable +paragraph API gains a mutually exclusive flow descriptor for explicit multi-region composition. Its layout output +identifies the region for every line and fragment and reports region-local inline/block coordinates plus overflow and +resume state. + +The Three integration exposes `TextRegion`, an identity-bearing `THREE.Object3D` that owns one region's local shape, +exclusions, writing mode, clip bounds, and geometry revision. `Text` owns the ordered `FlowThread` story and references +its `TextRegion` objects explicitly. There is no semantic `TextFlow` scene object. `TextGroup` remains only a +rendering/batching owner; flow is never inferred from group child order. + +- changing a region object's world transform updates rendering only; +- changing local region shape, writing mode, or exclusion geometry sends one flow-geometry mutation to Rust; +- render plans carry a region index/ID, and the Three/TSL policy reads a small `vec4`-record region-transform buffer on + both WebGPU and the WebGL PBO fallback; and +- disposing or reordering a region changes the flow-thread revision without changing text or broad shaping state. + +The canonical geometry inputs are rectangles and bounded simple polygons. Public helpers construct rectangles and +polygons and conservatively tessellate circles and curves when their geometry revision changes. For a 3D obstacle, the +Three layer projects a bounded silhouette into the target region's 2D plane and tessellates it before constructing the +update request. All resulting shapes are submitted with revisions in that same call. Rust owns line-band intersection, +exclusion subtraction, break choice, boundary reshape, and glyph placement. Projection and curve tessellation are scene +geometry integration, not a second typography implementation. Their cost is measured separately and vertex/exclusion +caps prevent arbitrary meshes from entering the realtime text transaction. Region or exclusion geometry may not depend +on the text measurement produced by that call; such a dependency would create the forbidden measurement-feedback loop. + +An edit resumes from the earliest invalidated safe boundary. Recomposition stops when new line state converges with a +retained later line, or at the requested viewport/page boundary. A changed break still cascades when its flow actually +changes; the architecture makes that cascade incremental rather than pretending it does not exist. + +Boundary reshaping uses HarfBuzz's safe-concatenation flags: walk backward to a safe cluster, shape only the narrowed +suffix through the proposed line end, retry farther back if the result begins unsafe, and splice the replacement. Tests +must contain a script and width for which the result is wrong when this step is removed.[^harfbuzz] + +Position accumulation, alignment, justification, and vertical block progression use `f64` internally and narrow once +when writing an explicitly `f32` render view. + +### Axis-neutral geometry and vertical text + +Vertical text is not horizontal text with a rotated transform. The layout model uses logical inline and block axes from +the beginning, then maps them to physical coordinates at output. It supports at least: + +- `horizontal-tb`, `vertical-rl`, and `vertical-lr` writing modes; +- `mixed`, `upright`, and `sideways` text orientation; +- vertical advances and origins from `vhea`, `vmtx`, and `VORG` where present; +- `vert`/`vrt2` OpenType behavior and Unicode vertical-orientation data; +- vertical baselines, column progression, punctuation placement, decoration orientation, and interaction geometry; and +- per-instance orientation so mixed Latin and CJK do not force separate semantic layouts. + +This follows the distinction in Writing Modes and JLREQ: glyph orientation, line direction, punctuation, ruby, and +column order are related but not interchangeable operations.[^writing-modes][^jlreq] + +## Publishing typography contract + +The engine API should expose explicit typed values rather than cloning CSS strings, but its common-feature semantics +need a published reference. The initial contract is organized as capabilities so unsupported combinations fail or +report a fallback instead of silently approximating them. + +| Area | Required engine behavior | +| --- | --- | +| Fonts and runs | language and script, fallback, OpenType features, horizontal and vertical metrics, baselines; static fonts only | +| Span positioning | explicit baseline shift, superscript/subscript positioning, and OpenType `sups`/`subs` features without a second text stream | +| Spacing | letter spacing, word spacing, line height, paragraph space before/after, first-line and hanging indents, spacing in logical axes | +| Tabs | authored left/right/center/decimal stops and bounded leader glyphs; the decimal alignment character is explicit rather than supplied by a locale database | +| Breaking | word/character/no-wrap, whitespace handling, explicit soft hyphen and inserted-hyphen provenance | +| Alignment | logical start/end/center, script-aware justification opportunities, hanging punctuation | +| Writing modes | horizontal and both vertical progressions, mixed/upright/sideways orientation, vertical substitutions and origins | +| Decorations | underline, overline, and line-through; color, thickness, offset, solid/double/dotted/dashed/wavy style, skip spaces, and bounded ink-box skipping | +| Editorial flow | multiple regions and sequential columns, exclusions with multiple slots per band, inline objects, drop caps, forced breaks | +| Pagination | explicit page/column breaks and resume tokens; no balancing or widow/orphan/keep solver | +| CJK emphasis | emphasis marks and short horizontal runs in vertical text; ruby and warichu are out of scope | +| Emoji | Unicode 17 grapheme, variation-selector, modifier, flag, tag, and ZWJ behavior through the ordinary font-fallback and shaping path; optional color art remains a raster resource | +| Interaction | logical/visual ranges, cluster maps, caret stops, hit testing, selection geometry, accessibility reading order | + +Word spacing applies to identified word separators, while letter spacing operates between typographic character units +after shaping and bidi ordering; nonzero letter spacing also interacts with optional ligatures. Justification cannot be +implemented as uniform extra space between glyph records.[^css-text] + +Decorations are layout-derived primitives, not renderer decoration flags. The core determines line fragments, +continuity, metrics, vertical orientation, skip-space behavior, and optional pre-baked ink-box intersections; the render +plan carries the resulting segments or paths alongside glyph primitives. Per-frame outline-curve intersection is not a +realtime feature.[^text-decoration] + +The font baker must expose the data this contract consumes. Its prerequisites include underline and strike metrics, +vertical advances and origins, baseline data where available, glyph extents for skip-ink decisions, and retained feature +data required by vertical shaping. A declaration that these metrics “should be baked” is not implementation evidence. + +### Per-font shaping-payload cost + +Most retained features add engine behavior but no font bytes. The current baker already retains `GSUB`, `GPOS`, `GDEF`, +horizontal metrics, dense glyph extents, and optional `BASE`, `VORG`, `vhea`, and `vmtx` tables when present. + +Measured source-table sizes for the repository fixtures establish the relevant bound: + +| Capability | Per-font shaping-data effect | +| --- | --- | +| Word/letter spacing, breaking, regions, exclusions, and sequential flow | zero bytes | +| Tate-chu-yoko | zero bytes; uses existing glyphs and existing GSUB width features when available | +| Emphasis marks | zero bytes; uses an ordinary shaped/cached mark glyph | +| Underline and strike | at most four extracted `i16` metrics, eight raw numeric bytes before container overhead | +| Bounded ink-box skipping | zero bytes; reuses the existing eight-byte dense extent record per glyph | +| Vertical shaping, Inter and Amiri fixtures | zero bytes; source fonts have no vertical tables and use the declared fallback | +| Vertical shaping, Noto Sans CJK JP | `vmtx` 261,386 + `vhea` 36 + `VORG` 920 = 262,342 raw bytes, about 36.8 KiB when the three source tables are Brotli-compressed independently; already retained today | +| `BASE` in Noto Sans CJK JP | 240 raw bytes; already retained and not exclusively vertical | + +Vertical layout therefore adds no new per-font bytes to the current artifact contract; it begins consuming data already +preserved. Underline metrics must be extracted from `post`, not retained by adding the whole table: Inter's source +`post` table is 32,773 bytes because it includes glyph names, while the needed underline position and thickness are four +raw bytes. Strike metrics already live in the required `OS/2` table. + +The target Mac's installed Noto Sans Mongolian fixture has 1,598 glyphs. The exact tables retained by the current closed +shaping profile occupy 59,352 padded SFNT table bytes; with its SFNT directory, 8-byte dense extents, and availability +bits, the derived shaping payload is 72,524 raw bytes. This cost belongs only to that selected font. HarfRust 0.12 already +contains Mongolian script selection, Free Variation Selector handling, and shaping behavior, so accepting the font adds +no second shaper or language dictionary to the core. The vertical contract therefore includes Mongolian top-to-bottom, +left-to-right flow and conformance fixtures alongside CJK top-to-bottom, right-to-left flow.[^mlreq] + +The engine has no EFIGS/Cyrillic/CJK-only language whitelist. It accepts any static font whose shaping is expressible by +the retained OpenType tables and the pinned HarfRust engine, and applications may compose per-language or already +subsetted font assets in one fallback list. Conformance priority covers Latin/EFIGS, Cyrillic, Arabic, Indic, CJK, +Mongolian, and Unicode emoji. A script requiring another global shaper such as AAT or Graphite is rejected rather than +pulling that system into every Wasm artifact. + +### Emoji and color-font boundary + +Emoji text remains ordinary Unicode prose. Unicode 17 grapheme and line-break data keep RGI modifier, flag, tag, keycap, +variation-selector, and ZWJ sequences intact; HarfRust maps a supported sequence to the font's glyphs, and the normal +fallback list may select an emoji-only font.[^unicode-emoji] No emoji sequence table is copied into each font. + +Color artwork never enters the shared shaping SFNT, which continues to exclude `COLR`, `CPAL`, `SVG `, `CBDT`, `CBLC`, +and `sbix`. A separately imported color-bitmap baker consumes the source font and emits an optional `rgba8unorm`, sRGB +bitmap companion with its own selected glyph coverage, strikes, pages, records, hashes, and byte report. The first color +slice accepts OpenType-layout fonts with CBDT/CBLC or `sbix` bitmap glyphs and COLR/CPAL glyphs flattened at bake time; +it does not add AAT `morx` shaping or an SVG runtime. CBDT embeds PNG-backed strikes, `sbix` stores standard bitmap +graphics, and COLR describes layered or paint-graph vector compositions.[^opentype-cbdt] [^opentype-sbix] +[^opentype-colr] + +The base branch already implements immutable ordered `FontStack` values and resolves `.notdef` clusters through later +fonts before layout. This stack ports that behavior into Rust and preserves its fixtures; it does not build a second +fallback mechanism. It removes the old raster-homogeneity restriction: every member must belong to the same text runtime, +but each loaded font carries its own technique and resource binding. Fallback remains a shaping decision about glyph +availability, not a renderer eligibility decision. + +RGBA8 costs four GPU bytes per texel versus one for the grayscale R8 bitmap path; selected coverage and independently +resident pages are therefore mandatory, and the payload report keeps color pages separate.[^renderer-capabilities] +[^payload-budget] Slug color-paint compilation is not required to ship emoji in this stack; Bitmap is the required color +path. An MSDF or Slug prose font may therefore fall back to an emoji-only Bitmap font without reshaping or changing line +breaks; the render plan partitions the resolved glyphs by their actual technique and resource. No synthetic composite +technique or cross-technique artifact is required. + +Exact outline skip-ink is cut because it would undermine the reduced shaping payload—for example, the Noto CJK source +`CFF ` table is 15,458,582 bytes. Static V0 also continues to reject variable-font axis/delta tables (`fvar`, `avar`, +`gvar`, `cvar`, `HVAR`, `VVAR`, and `MVAR`). Variable-font support is not part of this stack; adding it would require a +separate format, size, and runtime admission decision. + +### Cut publishing features and cost envelope + +“Annotations” does not mean arbitrary comments. It refers to specialized inline typography, principally: + +- **ruby:** a second, usually smaller text stream associated with base characters or words—for example Japanese + furigana, Chinese pronunciation, or an explanatory gloss—placed above/below horizontal text or beside vertical text; +- **emphasis marks:** dots, sesame marks, or another glyph placed beside individual CJK graphemes as typographic + emphasis; and +- **tate-chu-yoko:** a short horizontal run, commonly two to four date or page-number digits, fitted upright into one + vertical inline cell. + +Ruby would be the large feature. It needs base/annotation pairing, independent shaping, mono/group distribution, overhang and +collision rules, line-break coupling, vertical placement, and potentially multiple annotation levels.[^ruby] A heavily +annotated educational document can approach one annotation glyph per base glyph, so shaping work, semantic glyph state, +and render records can approach 2× before pairing and overhang work. In the current physical schemas, another rendered +glyph represents 48 bytes for Bitmap, 108 bytes for MSDF, or 92 bytes for Slug before backend repacking; incremental +plans pay only for changed records, but an initial retained snapshot pays the live total. + +Emphasis marks do not require a nested line breaker, but a fully emphasized range can add one mark primitive per +grapheme and therefore approach 2× primitive packing for that range. Tate-chu-yoko normally adds no characters: it +reshapes and fits a tagged short run as one vertical inline atom. Both belong in the first credible vertical release. + +The product scope is therefore: + +| Capability | When it is used | Initial scope | Cost control | +| --- | --- | --- | --- | +| Japanese line-start/end restrictions and tailoring | ordinary Japanese horizontal and vertical prose | include | compact rule/table delta; same line-break vectors plus tailored cases | +| Tate-chu-yoko | short dates, counters, page numbers, and occasional Latin inside vertical text | include | bounded tagged runs; no general nested layout | +| Emphasis marks | CJK emphasis where italics are inappropriate | include with decorations | cached mark shape; one extra primitive only where applied | +| Ruby | educational text, names, pronunciation guides, translations, manga, and specialist CJK publishing | cut | no base/annotation model or nested shaping stream | +| Warichu | compact Japanese parenthetical notes set as two small lines inside one line | cut | no nested inline line-layout model | +| Automatic language hyphenation | narrow justified columns in language-aware publishing | cut | no dictionaries, data-pack ABI, or language hyphenation algorithm; explicit soft hyphens remain | +| Balanced columns | final newspaper, magazine, and page composition | cut | columns fill sequentially; applications may choose region geometry externally | +| Widow/orphan and keep constraints | page/column finalization | cut | explicit page/column breaks and resume tokens remain | +| Automatic footnotes and sidenotes | page-coupled notes and scholarly annotations | cut | applications manually compose note text in independent regions; no second text channel or coupled page solver enters the engine | +| OpenType math layout | formulas, stretchy operators, fractions, scripts, and equation structure | cut | the `MATH` table is not a complete math-layout specification and would require a separate recursive box engine | +| Text on a path | labels following arbitrary curves | cut | no arc-length mapping, tangent placement, curve-aware interaction, or path-decoration system in the core | +| OpenType-SVG glyph paint | SVG-authored color glyphs and icons | cut | no XML/SVG parser, DOM, scripting, animation, filter, or external-resource runtime; color emoji uses the bounded bitmap companion | + +The cut features reserve no runtime tables, optional dictionaries, policy opcodes, semantic records, or implementation +stages. More generally, the core never automatically lays out a second authored text channel beside the selected prose. +Their documented cost explains the boundary; it is not a promise of later delivery. Reintroducing one requires a new +design decision and an independent size/performance admission proposal. + +## Render-plan policy + +A renderer implementer registers a policy once. The policy is data, not a hot JavaScript callback, so the same request +can be validated, executed in Wasm, and reused natively. Every first-party engine policy declares support for the shipped +Bitmap, MSDF, and Slug techniques; the initial Three policy is the first implementation of that invariant. A third-party +engine policy may declare only the techniques it implements and grow that set independently. Binding a `FontStack` +validates all of its technique IDs against that set, and a resolved glyph or requested paint capability that the selected +program cannot implement fails preparation before publication. + +The descriptor includes: + +- named physical buffer schemas: scalar type, vector width, alignment, stride, interleaving/SoA layout, capacity class, + and usage intent; +- a technique capability table mapping stable technique IDs to program IDs, accepted resource kinds, supported paint and + compositing features, and physical schemas; +- required semantic inputs and requested derived views; +- a batch-compatibility key assembled from declared resource, technique, material, clipping, depth, and ordering fields; +- backend capabilities such as storage-buffer support, indirect draws, aliasable vector widths, maximum binding sizes, + and update alignment; +- an upload cost model: preferred coalescing gap, range/call penalty, whole-buffer threshold, and fragmentation budget; +- an allocation strategy chosen from ordered direct storage or stable pooled records with a chunked order/indirection + buffer; and +- validated augmentations that derive extra fields from semantic records without reimplementing layout. + +Augmentation is one versioned, typed, straight-line bytecode. It has semantic-field and constant loads, explicit +resource lookups, deterministic arithmetic and conversion, predicated selection, and physical-field stores. It has no +callback, backward branch, data-dependent loop, memory allocation, arbitrary address, or layout-mutating opcode. The +engine iterates the validated program over four-record SIMD lanes and executes scalar tails. Built-in Bitmap, MSDF, and +Slug policies use the same bytecode and verifier as external policies; a native builder emits that bytecode rather than +bypassing it with a second Rust policy trait. + +Augmentation examples include packing `origin + size` into `vec4`, adding atlas/material indices, emitting selection or +object IDs, quantizing fields, or requesting per-glyph bounds. It may not choose line breaks, mutate cluster order, or +change semantic positions. + +## Render-plan IR + +The render plan is a revisioned display-list and resource transaction, following the separation used by retained +renderers such as WebRender: rendering intent and resource changes are portable; backend command encoding is not. +[^webrender] + +It contains: + +- **identity:** ABI, engine revision, plan revision, required base revision, policy/capability hashes, and output + generation; +- **semantic tables:** optional line, fragment, run, cluster, logical/visual, caret, selection, and inserted-glyph tables; +- **resources:** stable IDs, generations, bounds, creation/update/retirement intent, and technique-specific references; +- **buffers:** stable buffer IDs, schemas, live lengths, capacities, and allocation generations; +- **patches:** allocate/resize, write range, fill, copy/relocate, and retire operations referencing exact payload spans in + the published Wasm generation; +- **primitives:** ordered glyph, decoration, inline-object, clip, and custom-policy primitive records carrying the + selected technique, resource, and program identities where applicable; +- **draw packets:** compatible primitive ranges, resource/buffer bindings, ordering tokens, and optional indirect + argument records; and +- **retirement:** the earliest generation after which resources, slots, and output bytes may be reused. + +The initial adapter lowers this IR to Three attributes, TSL storage nodes, and draws. The same graph runs through +Three's WebGPU backend and forced WebGL2 backend. In Three 0.185.1, WebGL PBO setup replaces the supplied typed array with +a power-of-two-padded retained array and a `DataTexture`; the adapter therefore performs one explicit copy into that +retained array and applies later patches to it. WebGPU may consume re-pinned Wasm views where Three preserves them. +Bitmap uses `vec2`/`vec4` records and MSDF and Slug use `vec4`/`uvec4` records, all valid in the fallback. A minimal native +consumer proves schema, patch, revision, and retirement semantics without claiming another renderer integration. + +### Minimal updates + +“Minimal” is policy-relative and measured. The objective includes bytes scanned, bytes rewritten, upload bytes, upload +calls, draw packets, memory overhead, fragmentation, and CPU/GPU time. Minimizing only dirty-byte count can lose when it +creates hundreds of tiny `queue.writeBuffer` calls. + +The policy chooses one of two initial allocation strategies: + +1. **Ordered direct records:** lowest shader and draw complexity; insertion can move the ordered suffix. +2. **Stable record pool plus chunked order/indirection:** local record patches and bounded order-chunk updates at the + cost of one indexed lookup. The Three/TSL policy implements the lookup over storage records on both WebGPU and the + WebGL PBO fallback. + +There is no third segmented-record mode in this stack. Chunking belongs to the stable-indirect order representation, +and its draw-packet boundaries are part of that one strategy rather than another allocator and policy surface. + +The engine assigns stable instance identities where shaped semantics remain equivalent across revisions. Invalidation +starts from edited text/style/flow dependencies, not from rewriting every live record and diffing all bytes afterward. +Patches are aligned and coalesced according to the registered backend cost model. Tests cover insertion, deletion, +replacement, style edits, and flow changes at the start, middle, and end of large retained paragraphs. + +If `required_base_revision` does not match the consumer, the engine returns a checkpoint containing complete live state. +Skipped render revisions can never be repaired by applying an adjacent delta blindly. + +## Performance contract + +Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a +validated plan ready for renderer lowering, including any required Wasm-to-transfer-buffer copy. The design target is +p95 ≤ 1.0 ms for localized edits and retained constraint changes that converge within the requested viewport. Renderer +lowering and CPU-side upload submission are reported separately and must also fit the application's total 4 ms UI +budget; neither target is justified by a best-case median. + +The benchmark reports phases and retained-output costs separately: + +- Unicode/style invalidation, shaping, composition, positioning, semantic geometry, plan compilation; +- bytes scanned, rewritten, published, copied by the adapter, and submitted to the GPU; +- patch count, upload call count, draw-packet count, allocations, memory growth, and output-generation pressure; and +- scalar-oracle versus SIMD-kernel time on the same data and output contract; and +- root/worker ownership transitions, transfer-buffer pool hits, copies, transferred bytes, returns, and backpressure. + +At minimum, benchmark these deterministic workloads: + +- the existing 25,515-glyph fully visible cold, font-size, width, and text-edit cases; +- localized insert/delete/replace/style edits at paragraph start, middle, and end; +- 100,000 retained glyphs with a bounded visible window and convergence after a local edit; +- mixed-direction Arabic/Latin, Indic shaping, CJK horizontal and vertical layout; +- decorations and word/letter spacing; +- multi-column flow with exclusions and an inline object; and +- renderer policies for ordered, indirect, and segmented storage on supported backends. + +No-op frames do no Wasm work. After warm capacity is established, primary warm cases perform zero allocator calls and +zero memory growth. The fully visible 25,515-glyph font-size, width, and text-edit lanes and the retained/windowed +100,000-glyph localized-edit lane must all remain below the 4 ms p95 ceiling. The 1 ms target is reported for every lane +and is a required design objective, but it does not become an asserted result until measured. Cold initialization and +genuinely document-wide structural changes are reported separately with allocation, growth, and tail-latency evidence. + +### Realtime feature admission + +No finite implementation can guarantee a frame time for unbounded text or geometry. The product guarantee is therefore +defined over explicit input and work bounds selected from the Stage 0 measurements. Each request caps regions, lines, +clusters, exclusions per region, slots per band, policy operations, semantic-output bytes, patch bytes, and total output +bytes. Exceeding a cap produces a valid partial plan, an overflow reason, and a resume cursor; it never starts an +unbounded recovery or silently changes typography. The previous complete revision remains renderable until its +replacement is complete. + +A realtime feature must satisfy all of these: + +- its work is linear or better in invalidated/visible chunks, emitted fragments, or another explicitly capped input; +- it performs no nested layout, global optimization, unbounded backtracking, runtime dictionary loading, outline-curve + intersection, arbitrary host callback, or convergence loop without a deterministic iteration cap; +- its warm path performs zero allocations and memory growth at the admitted capacity; +- expensive semantic views are absent unless requested; and +- its isolated and combined worst-case corpora remain under p95 4 ms, with p95 1 ms as the design target. + +The admitted feature candidates are horizontal and vertical shaping, Unicode breaking, word/letter spacing, +underline/overline/line-through, bounded ink-box skipping, tate-chu-yoko with a short-run cap, emphasis marks, bounded +exclusions and inline objects, and bounded sequential-region flow. A candidate that misses the budget is narrowed or +cut; architectural generality is not a reason to ship it. + +## SIMD-shaped engine design + +SIMD is part of the storage and algorithm design, not a loop replacement at the end. The scalar implementation remains +an exact oracle and tail path, but production data structures must make contiguous lanes available from the first Rust +milestone. + +### Data layout + +- Store hot glyph and cluster fields as 16-byte-aligned SoA arrays: glyph IDs, clusters, design-unit advances and + offsets, style/font slots, bidi levels, break flags, and stable identities. Physical render records remain + policy-directed AoS/SoA outputs. +- Partition retained text into fixed-capacity chunks whose live capacity is a multiple of 16 clusters. The Stage 0 + packet compares 32-, 64-, and 128-cluster chunks; the chosen size is then an ABI-private invariant. +- Give each chunk summaries needed to skip it without visiting every cluster: total advance by uniform scale run, + required/safe/allowed break masks, first/last candidate positions, bidi transition mask, text range, and revision. +- Retain HarfRust's `i32` design-unit advances and offsets until scaling is required. Do not eagerly convert the whole + paragraph to `f64` arrays. Cluster grouping is performed once while HarfRust output is already monotone within a run. +- Reuse HarfRust `GlyphBuffer::clear()` allocations, UTF decoding buffers, feature records, run tables, line cursors, + patch builders, policy VM registers, and both Wasm output arenas. Clearing live lengths must not drop capacities. +- Use slabs or generational arenas for variable-count lines, fragments, decorations, and inline objects. No object or + hash-map entry is allocated per glyph or cluster in a warm update. + +### Kernel map + +| Kernel | Lane shape | Planned treatment | +| --- | --- | --- | +| Built-in Bitmap/MSDF/Slug packing | four independent `f32`/`u32` records | explicit four-lane load/transform/store; strongest first admission candidate | +| Declarative policy transforms | four independent semantic records | vector bytecode/graph execution so dispatch is amortized across four records | +| Bidi-level transitions | sixteen `u8` levels | compare shifted contiguous levels and extract a bitmask | +| Break and cluster flags | sixteen `u8` flags | vector masks plus `bitmask`/trailing-zero candidate selection | +| Patch verification/coalescing | sixteen bytes or four words | `v128` compare on already invalidated spans; never scan the whole live buffer as the primary algorithm | +| Glyph-to-cluster aggregation | repeated cluster IDs and scatter writes | reshape once into cluster-contiguous runs; core Wasm SIMD has no gather/scatter instruction | +| Line-width search | chunk summaries plus ordered boundary scan | skip summary blocks; preserve scalar addition order at the exact width boundary | +| Decoration bounds/packing | four independent segments | vectorize bounds and physical packing after line fragmentation is fixed | + +Line-breaking correctness forbids changing floating-point association. Core WebAssembly SIMD provides deterministic +`v128` integer and float operations, but relaxed-SIMD operations permit implementation-dependent results and are not +used.[^wasm-simd] Exact integer scans are preferred; ordered `f64` accumulation remains scalar where changing order +could move a break. Both oracle and production paths narrow only when writing a declared lower-precision render field. + +Rust 1.97's `core::simd` remains nightly-only. Wasm kernels therefore use stable `core::arch::wasm32` intrinsics with +`simd128`; native kernels use target-specific intrinsics behind the same chunk/kernel interface.[^rust-simd] The kernel +interface has compile-time SIMD and scalar backends, selected by one checked build feature; exactly one backend is +linked into an artifact. The standard Web artifact enables SIMD. The scalar backend is the differential oracle and +tail implementation and also keeps a same-ABI, no-SIMD compatibility artifact buildable without maintaining a second +semantic engine. That artifact is not published or selected at runtime until concrete consumer demand justifies it. + +### Up-front viability packet + +Before the semantic port chooses its retained structs, build a test-only kernel lab over captured arrays from the real +25,515- and 100,000-glyph workloads. It compares the same data layout under scalar, compiler-generated, and explicit +SIMD kernels for packing, flag scanning, chunk summaries, boundary search, and policy execution in Node, Chromium, and +representative native targets. + +The packet must: + +- prove exact semantic and byte output, including mixed direction, vertical data, partial chunks, and unaligned request + offsets; +- inspect the optimized Wasm to prove the intended vector instructions survive Binaryen; +- record p50/p95/p99, instructions or phase time where available, warm allocations, memory growth, raw/Brotli bytes, + and end-to-end contribution; +- demonstrate zero warm allocation and growth for the tested kernels; and +- select chunk size, alignment, summary layout, policy execution shape, and browser capability baseline before those + types become production API. + +A kernel is admitted when it improves its phase p95 by at least 20% and either improves the complete hot update by at +least 5% or removes enough latency/allocations to meet the 1/4 ms budgets, without regressing another primary workload +by more than 2%. Its production Brotli delta is capped at the smaller of 12 KiB or 5% of the engine module unless a +separate decision records stronger end-to-end evidence. These thresholds intentionally reject the repository's prior +MTSDF pattern of double-digit code growth for low-single-digit bounded gains. + +## Implementation stack + +Each stage is a small Conventional Commit series with unchanged fixtures and an independently reviewable invariant. +The first stack proves the Wasm boundary, policy, display-list/render-plan contract, and complete current Rust semantic +pipeline before adding new publishing features. The single ABI is its final cutover, not its first commit. + +### Foundation stack — Wasm, policy, render plan, and complete current semantics + +### Stage 0 — contracts and measurement + +- record the accepted architecture in the decision register and update the affected package concepts; +- define the binary schema, revisions, A/B publication and transferable-buffer ownership state machines, semantic + tables, and render-plan IR; +- add phase, patch, upload, allocation, and generation-pressure instrumentation; +- add edit-locality, vertical, decoration, spacing, exclusion, and bounded-region benchmark fixtures without changing + goldens; +- execute the up-front SIMD viability packet and select chunk, alignment, summary, and policy-execution layouts; +- add reproducible `simd` and `scalar` artifact builds with identical schemas and ABI, make SIMD the standard package, + and test that an artifact contains only its selected kernel backend; and +- set the measured hard caps for clusters, lines, exclusions, slots, regions, policy operations, and output bytes. + +### Stage 1 — engine shell, frame ABI, and retained ownership + +- separate renderer-neutral Rust core from the Wasm transport; +- implement validated mutation transactions, stable revisions, request reservation, A/B Wasm publication, worker-owned + transferable buffers, root retirement, and deterministic partial/resume results behind a test-only entry point; +- add aligned retained chunks, packed flags, chunk summaries, generational arenas, and zero-allocation warm scratch; +- test growth, detachment, malformed requests, failed updates, skipped revisions, detached-buffer misuse, missing returns, + bounded-pool backpressure, and worker-side collection; and +- keep the shipping hot path unchanged while the Rust dependency chain is incomplete. + +### Stage 2 — policy and retained render-plan proof + +- land the typed straight-line policy bytecode and semantic/resource/buffer/patch/primitive/draw IR; +- compile captured current semantic fixtures into stable instance identities, ordered-direct or stable-indirect storage, + invalidation-directed dirty patches, and capability-shaped draw packets; +- prove built-in Bitmap, MSDF, and Slug policies through scalar kernels before adding SIMD; +- lower the same plan through the one Three/TSL adapter on WebGPU and forced WebGL2 plus a minimal native consumer; and +- prove ordered MSDF-to-Slug and MSDF/Slug-to-Bitmap fallback, per-technique buffers and patches, atomic multi-program + publication, the first-party policy accepting all three built-ins, and a restricted third-party policy rejecting an + unsupported stack before rendering. + +### Stage 3 — complete current shaping and layout in Rust + +- port Unicode 17 grapheme and line-break analysis, bidi orchestration, style itemization, the existing ordered + `FontStack` fallback semantics while removing its raster-homogeneity constraint, shaping-run construction, cluster + measurement, horizontal line composition, reordering, positioning, alignment, justification, and existing overflow + behavior; +- write semantic results directly into the retained plan compiler rather than returning shaped glyph arrays to + TypeScript; +- retain the TypeScript engine temporarily only as a differential test oracle on deterministic fixtures; +- use the unchanged official Unicode vectors, fallback fixtures, mixed-direction goldens, and packed-consumer bytes; and +- implement only SIMD kernels admitted by the Stage 0 scalar-versus-SIMD evidence, with scalar paths and tails kept + test-visible. + +### Stage 4 — atomic cutover and foundation performance gate + +- cut the public hot path to one `text_update` call after byte and semantic parity is established; +- remove TypeScript shaping and layout orchestration and the old analysis/shape/reshape exports together; +- apply retained patches through both Three/TSL backends, including WebGL2's required retained PBO copy; and +- pass the complete 25,515-glyph target-hardware gate before any additional publishing feature enters the stack. + +### Following stacks — publishing features on the proven foundation + +### Stage 5 — spacing, decorations, and interaction + +- implement word- and letter-spacing semantics, tabs/indents, inserted hyphens, script-aware justification, and hanging + punctuation; +- bake and consume underline/strike/vertical/baseline metrics; +- emit underline, overline, line-through, hit-test, caret, selection, and accessibility geometry; and +- verify direction changes, ligature boundaries, fallback fonts, line fragmentation, and vertical decoration orientation. + +### Stage 6 — editorial and vertical layout + +- implement axis-neutral horizontal/vertical composition and text orientation; +- add Japanese line tailoring, bounded tate-chu-yoko, emphasis marks, regions, exclusion subtraction, multiple inline + slots, inline objects, drop caps, explicit breaks, and sequential fill; +- add retained cursor/resume/convergence behavior and safe narrowed reshaping at line boundaries; and +- resolve every declared region and exclusion in the same update call, flowing sequentially into at least a second + fixed region without balancing or a host measurement round trip. + +### Stage 7 — emoji color raster and integration cleanup + +- add the optional Bitmap color companion and selected-page accounting without changing shaping-data size; +- verify emoji-only Bitmap fallback from MSDF and Slug prose through the heterogeneous `FontStack` contract; +- remove the differential TypeScript implementation after all gates pass; +- update roadmap, package concepts, decision register, and API reference; and +- finish with package checks, repository checks, benchmarks, browser conformance, and a clean worktree. + +## Hard gates for every implementation stage + +- never regenerate a golden or official Unicode fixture to accept a behavior change; +- `mise exec -- pnpm --filter @pmndrs/text test` remains 190 passing, 0 failing; +- `mise exec -- pnpm --filter @pmndrs/text check` passes lint, format, types, and tests; +- the benchmark application's 117 tests and 20 headless conformance cases pass; +- the mixed-direction Amiri golden and packed-consumer contract remain exact until an explicitly versioned render-plan + contract replaces the latter; +- `text:layout-benchmark -- --glyphs 22000` reports both baseline and candidate tables at every stage; +- Unicode segmentation and line breaking pass the repository's unchanged official vectors; +- scalar and SIMD paths produce identical declared output bytes; and +- each adapter proves patch application from the stated base revision and checkpoint recovery after a skipped revision. + +## Resolved product direction + +- Render-plan policies are validated declarative bytecode built through typed host/native builders; no arbitrary + JavaScript packing callback or second native policy implementation executes in the hot path. +- Loaded fonts own technique and resource binding. User-facing `FontStack`, Three `Text`, and `TextGroup` carry no + separately authored technique; every first-party engine policy supports Bitmap, MSDF, and Slug, while third-party + policies may declare and safely enforce a subset. +- Ruby, warichu, automatic language hyphenation and dictionaries, balanced columns, and widow/orphan/keep solvers are + cut from the product scope. They reserve no base-runtime code or data. +- The hard warm-update ceiling is p95 < 4 ms on both the fully visible 25,515-glyph lanes and the 100,000-glyph + retained/windowed localized-edit lane. The design target is p95 ≤ 1 ms. +- Sequential multi-region overflow is required, resolves all declared regions and exclusions in one update, and does + not balance columns. Partial/resume remains only the explicit response to a declared out-of-envelope request. +- The standard Web artifact requires `simd128`. This excludes Safari before 16.4, Chromium before 91, Firefox before + 89 on x86/x64 or 90 on arm64, and Firefox on arm32/mips64.[^safari-simd] [^chrome-simd] [^firefox-simd] Initialization + reports a typed unsupported-capability error when module validation fails; it does not user-agent sniff or silently + load another module. +- A checked build-time feature can produce a schema- and ABI-identical scalar artifact from the same engine and kernel + interface. It is retained as a release valve and published only when an actual consumer requires the older browser + tail; SIMD remains the default build and package. + +[^css-text]: [CSS Text Level 4](https://www.w3.org/TR/css-text-4/) defines the relevant spacing, hanging-punctuation, + and justification concepts. The engine API need not duplicate CSS syntax. + +[^harfbuzz]: [HarfBuzz buffer flags](https://harfbuzz.github.io/harfbuzz-hb-buffer.html) define safe-to-insert and + unsafe-to-concatenate boundaries used for narrowed reshaping. + +[^safari-simd]: [WebKit's Safari 16.4 release notes](https://webkit.org/blog/13966/webkit-features-in-safari-16-4/) + record the addition of WebAssembly 128-bit SIMD. + +[^chrome-simd]: [The Chromium 91 release announcement](https://blog.chromium.org/2021/04/chrome-91-handwriting-recognition-webxr.html) + records WebAssembly SIMD becoming enabled by default. + +[^firefox-simd]: [Mozilla's WebAssembly SIMD shipping record](https://bugzilla.mozilla.org/show_bug.cgi?id=1625130) + records Firefox 89 for x86/x64, Firefox 90 for arm64, and no planned arm32 or mips64 implementation. + +[^icu4x]: [`icu_segmenter` documentation](https://docs.rs/icu_segmenter/latest/icu_segmenter/) currently documents + Unicode 15.1 line-break data, so it cannot replace this repository's Unicode 17 gate without new evidence. + +[^jlreq]: [JLREQ](https://www.w3.org/TR/jlreq/) documents Japanese vertical composition, punctuation, ruby, emphasis, + and line-start/end restrictions. + +[^parley]: [Parley layout](https://docs.rs/parley/latest/parley/layout/) demonstrates retained rich-text layout, + decorations, cursor/selection data, and line-breaking iteration in a current Rust implementation. + +[^pretext]: [Pretext](https://github.com/chenglou/pretext) demonstrates a prepared text object advanced by a cursor and + a different width for each line; its Canvas measurement model is not adopted here. + +[^ruby]: [CSS Ruby Annotation Layout Level 1](https://www.w3.org/TR/css-ruby-1/) defines base/annotation pairing, + levels, positioning, merging, and distribution that make ruby a coupled second layout stream. -The engine that must not regress is 1,800 lines of subtle bidi, cluster, and line-composition logic behind goldens that -have already caught one real precision regression and one crash during this work. The staging exists so that each -landing has a small blast radius. +[^rust-simd]: [Rust's stable `wasm32` architecture documentation](https://doc.rust-lang.org/core/arch/wasm32/index.html) + documents `simd128` intrinsics, compilation requirements, and the lack of in-module runtime feature detection. -Two facts specifically limit the achievable win and should be restated whenever this plan is quoted: text analysis is -already absent from resize and reflow, and boundary reshaping as previously built was redundant. Both were removed in -TypeScript. The Rust engine inherits an already-tightened baseline, so its case rests on write-once correctness, -per-line composition, and vectorized packing — not on the original numbers. +[^staging]: [`wgpu::util::StagingBelt`](https://docs.rs/wgpu/latest/wgpu/util/struct.StagingBelt.html) is an example of + renderer-owned reuse for many buffer writes; it is separate from Wasm result publication. -[^pretext]: [Pretext](https://github.com/chenglou/pretext) exposes `layoutNextLine(prepared, cursor, maxWidth)` and a - range-returning variant, carrying a segment/grapheme cursor between calls and keeping prepared segment widths valid - across them. +[^text-decoration]: [CSS Text Decoration Level 4](https://www.w3.org/TR/css-text-decor-4/) defines the decoration + dimensions and skip behavior used as the semantic reference. -[^bitmap]: `createStorage` returns `Float32Array(capacity * 2)` for origins, sizes, and both UV pairs, and - `Float32Array(capacity * 4)` for colors. +[^webrender]: [Firefox's rendering overview](https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html) + separates retained display-list intent from renderer-specific scene building and submission. + +[^wasm-simd]: [The WebAssembly core specification](https://webassembly.github.io/spec/core/) defines fixed-width + `v128` operations and records that relaxed operations may have implementation-dependent results. + +[^worker-transfer]: [The HTML Worker model](https://html.spec.whatwg.org/multipage/workers.html) defines worker + `postMessage` transfer lists used to move `ArrayBuffer` ownership between worker and root. -[^pbo]: `setupPBO` replaces the attribute array with a padded copy and constructs the `DataTexture` over that copy. +[^writing-modes]: [CSS Writing Modes Level 4](https://www.w3.org/TR/css-writing-modes-4/) defines logical inline/block + directions and distinguishes writing mode from glyph orientation. From 4cccea79b160f5b62818b00741e0746afa5b3a4d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 04:27:52 -0400 Subject: [PATCH 002/128] feat(text): validate render plan policies --- docs/log.md | 10 + docs/packages/text.md | 8 +- packages/text/rust/shaper/src/engine/mod.rs | 6 + .../text/rust/shaper/src/engine/policy.rs | 572 ++++++++++++++++++ packages/text/rust/shaper/src/lib.rs | 1 + 5 files changed, 596 insertions(+), 1 deletion(-) create mode 100644 packages/text/rust/shaper/src/engine/mod.rs create mode 100644 packages/text/rust/shaper/src/engine/policy.rs diff --git a/docs/log.md b/docs/log.md index fa014c56..a4ed2e83 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-08 +- **Began the Rust render-policy foundation without growing shipping Wasm** — Added the first renderer-neutral engine + module to the existing shaper `rlib`, preserving the single `no_std + alloc` Rust/Wasm codebase rather than creating a + second module. A bounded straight-line policy representation and total verifier now reject invalid identities, + duplicate technique variants/programs/buffers/stores, invalid vector lanes, unknown buffers, out-of-range semantic + fields, uninitialized or mistyped registers, and incomplete physical records before execution. Technique variants use + the same data and functions; no technique-specific class or packer entered the core. Sixteen focused Rust tests and the + two complete Unicode 17 bidi conformance suites pass. Because no Wasm export reaches the new module yet, the optimized + shaper remains exactly 680,312 raw bytes; the compiler-derived direct-memory ABI remains unchanged until the policy + records join it in the next commit. + - **Locked the Rust text-engine and retained render-plan architecture** — Expanded the narrower layout-boundary proposal into one `no_std + alloc` Rust semantic pipeline for Unicode analysis, bidi, fallback, shaping, per-line editorial composition, typography geometry, and policy-directed incremental render-plan compilation. The steady-state host diff --git a/docs/packages/text.md b/docs/packages/text.md index e740a3ea..c4f79cd1 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6ae55db41be216c0ccf2a79b55167c964770e5fbc6c490940d6b68b4b3bca512' +source_digest: 'sha256:ac4d619314a7e2d2e02840013b6b4ef50ca74971f7ff0df87120d85a7213c74e' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -366,6 +366,12 @@ The retained SIMD decision evidence compares an equivalent four-texel scalar til The shaper, Bitmap baker, and MTSDF generator/artifact boundary define every direct-memory header and table as a fixed-width `#[repr(C)]` Rust type. Build-only Rust generators derive each JSON size, alignment, and field offset from `size_of`, `align_of`, and `offset_of!`, then emit exact typed `as const` TypeScript modules from that JSON. Rust readers/writers and production TypeScript therefore consume one compiler-led truth; CI compares regenerated output byte-for-byte and fails on stale checked-in modules. Production Wasm embeds no duplicate JSON and exports no ABI pointer/length bootstrap. This keeps QuickType, JSON Schema, runtime JSON parsing, and binding-generator code out of the shipping graph. Wasm linear memory is guaranteed little-endian; serialized GLB, KTX2, SFNT, and extension records continue to follow their own portable format contracts. +The first Rust text-engine foundation keeps that direct-memory rule and adds a renderer-neutral `no_std + alloc` policy +model to the existing shaper `rlib`, not a second Wasm module. Plain data and total validation functions bound program, +buffer, operation, register, field, vector-width, and store coverage before execution; technique variants share that one +verifier instead of owning parallel packers. The initial module is deliberately unreachable from the Wasm exports while +its fixed-width wire contract is built, and optimized dead-code evidence keeps the shipping shaper at 680,312 raw bytes. + Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. The runtime repeats no parallel wire-format implementation. Bitmap and MTSDF renderers plus both standalone validators consume the same dependency-light KTX2 and dense-record rules; only the standalone layer imports Khronos/Ajv. The renderers also share the lossless-atlas adapter, unit quad, parallel-array checks, and resolved-paint lookup. The MTSDF resource uploads only its authenticated base levels into one padded texture array, samples them bilinearly, sizes reconstruction with screen derivatives, and owns one material per logical array; disposal releases materials and textures transactionally. diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs new file mode 100644 index 00000000..2c122c5c --- /dev/null +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -0,0 +1,6 @@ +//! Renderer-neutral retained text-engine state. +//! +//! The public types in this module are available to native consumers. Wasm memory ownership and +//! pointer validation stay in the target-gated transport module. + +pub mod policy; diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs new file mode 100644 index 00000000..042cd1ca --- /dev/null +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -0,0 +1,572 @@ +//! Validated renderer policy data and its scalar correctness executor. +//! +//! Policies are intentionally straight-line. The engine owns record iteration, so a policy cannot +//! allocate, loop, branch backward, address arbitrary memory, or mutate semantic layout. + +use alloc::vec::Vec; + +pub const MAX_PROGRAMS: usize = 32; +pub const MAX_BUFFERS_PER_PROGRAM: usize = 16; +pub const MAX_OPERATIONS_PER_PROGRAM: usize = 128; +pub const MAX_REGISTERS: usize = 32; +pub const MAX_VECTOR_WIDTH: u8 = 4; + +const UNINITIALIZED: u8 = 0; +const F32_REGISTER: u8 = 1; +const U32_REGISTER: u8 = 2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct TechniqueId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct ProgramId(pub u32); + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct BufferId(pub u16); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum ScalarType { + F32 = 1, + U32 = 2, + U16 = 3, +} + +impl ScalarType { + const fn byte_width(self) -> usize { + match self { + Self::F32 | Self::U32 => 4, + Self::U16 => 2, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BufferSchema { + pub id: BufferId, + pub scalar: ScalarType, + pub vector_width: u8, +} + +impl BufferSchema { + pub fn stride(self) -> usize { + self.scalar.byte_width() * usize::from(self.vector_width) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ProgramCapabilities { + pub paint: u32, + pub compositing: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Operation { + LoadF32 { + target: u8, + field: u8, + }, + LoadU32 { + target: u8, + field: u8, + }, + ConstantF32 { + target: u8, + bits: u32, + }, + ConstantU32 { + target: u8, + value: u32, + }, + AddF32 { + target: u8, + left: u8, + right: u8, + }, + SubtractF32 { + target: u8, + left: u8, + right: u8, + }, + MultiplyF32 { + target: u8, + left: u8, + right: u8, + }, + LessThanF32 { + target: u8, + left: u8, + right: u8, + }, + SelectF32 { + target: u8, + condition: u8, + when_true: u8, + when_false: u8, + }, + ConvertU32ToF32 { + target: u8, + source: u8, + }, + StoreF32 { + source: u8, + buffer: BufferId, + lane: u8, + }, + StoreU32 { + source: u8, + buffer: BufferId, + lane: u8, + }, + StoreU16 { + source: u8, + buffer: BufferId, + lane: u8, + }, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ProgramDescriptor { + pub technique: TechniqueId, + pub variant: u16, + pub id: ProgramId, + pub f32_input_count: u8, + pub u32_input_count: u8, + pub capabilities: ProgramCapabilities, + pub buffers: Vec, + pub operations: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PolicyDescriptor { + pub programs: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ValidatedPolicy { + programs: Vec, +} + +impl ValidatedPolicy { + pub fn new(descriptor: PolicyDescriptor) -> Result { + validate_policy(&descriptor)?; + Ok(Self { + programs: descriptor.programs, + }) + } + + pub fn programs(&self) -> &[ProgramDescriptor] { + &self.programs + } + + pub fn program(&self, technique: TechniqueId, variant: u16) -> Option<&ProgramDescriptor> { + self.programs + .iter() + .find(|program| program.technique == technique && program.variant == variant) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PolicyError { + EmptyPolicy, + TooManyPrograms, + InvalidTechniqueId, + InvalidProgramId, + DuplicateTechniqueVariant, + DuplicateProgramId, + EmptyBuffers, + TooManyBuffers, + InvalidBufferId, + DuplicateBufferId, + InvalidVectorWidth, + EmptyOperations, + TooManyOperations, + InvalidRegister, + UninitializedRegister, + RegisterTypeMismatch, + InvalidInputField, + UnknownBuffer, + StoreTypeMismatch, + InvalidStoreLane, + DuplicateStore, + IncompleteBuffer, +} + +fn validate_policy(descriptor: &PolicyDescriptor) -> Result<(), PolicyError> { + if descriptor.programs.is_empty() { + return Err(PolicyError::EmptyPolicy); + } + if descriptor.programs.len() > MAX_PROGRAMS { + return Err(PolicyError::TooManyPrograms); + } + for (index, program) in descriptor.programs.iter().enumerate() { + if program.technique.0 == 0 { + return Err(PolicyError::InvalidTechniqueId); + } + if program.id.0 == 0 { + return Err(PolicyError::InvalidProgramId); + } + for previous in &descriptor.programs[..index] { + if previous.technique == program.technique && previous.variant == program.variant { + return Err(PolicyError::DuplicateTechniqueVariant); + } + if previous.id == program.id { + return Err(PolicyError::DuplicateProgramId); + } + } + validate_program(program)?; + } + Ok(()) +} + +fn validate_program(program: &ProgramDescriptor) -> Result<(), PolicyError> { + if program.buffers.is_empty() { + return Err(PolicyError::EmptyBuffers); + } + if program.buffers.len() > MAX_BUFFERS_PER_PROGRAM { + return Err(PolicyError::TooManyBuffers); + } + for (index, buffer) in program.buffers.iter().enumerate() { + if buffer.id.0 == 0 { + return Err(PolicyError::InvalidBufferId); + } + if buffer.vector_width == 0 || buffer.vector_width > MAX_VECTOR_WIDTH { + return Err(PolicyError::InvalidVectorWidth); + } + if program.buffers[..index] + .iter() + .any(|previous| previous.id == buffer.id) + { + return Err(PolicyError::DuplicateBufferId); + } + } + if program.operations.is_empty() { + return Err(PolicyError::EmptyOperations); + } + if program.operations.len() > MAX_OPERATIONS_PER_PROGRAM { + return Err(PolicyError::TooManyOperations); + } + + let mut registers = [UNINITIALIZED; MAX_REGISTERS]; + let mut stored_lanes = [0_u8; MAX_BUFFERS_PER_PROGRAM]; + for operation in &program.operations { + validate_operation(program, operation, &mut registers, &mut stored_lanes)?; + } + for (index, buffer) in program.buffers.iter().enumerate() { + let required = (1_u8 << buffer.vector_width) - 1; + if stored_lanes[index] != required { + return Err(PolicyError::IncompleteBuffer); + } + } + Ok(()) +} + +fn validate_operation( + program: &ProgramDescriptor, + operation: &Operation, + registers: &mut [u8; MAX_REGISTERS], + stored_lanes: &mut [u8; MAX_BUFFERS_PER_PROGRAM], +) -> Result<(), PolicyError> { + match *operation { + Operation::LoadF32 { target, field } => { + if field >= program.f32_input_count { + return Err(PolicyError::InvalidInputField); + } + initialize(registers, target, F32_REGISTER) + } + Operation::LoadU32 { target, field } => { + if field >= program.u32_input_count { + return Err(PolicyError::InvalidInputField); + } + initialize(registers, target, U32_REGISTER) + } + Operation::ConstantF32 { target, .. } => initialize(registers, target, F32_REGISTER), + Operation::ConstantU32 { target, .. } => initialize(registers, target, U32_REGISTER), + Operation::AddF32 { + target, + left, + right, + } + | Operation::SubtractF32 { + target, + left, + right, + } + | Operation::MultiplyF32 { + target, + left, + right, + } => { + require(registers, left, F32_REGISTER)?; + require(registers, right, F32_REGISTER)?; + initialize(registers, target, F32_REGISTER) + } + Operation::LessThanF32 { + target, + left, + right, + } => { + require(registers, left, F32_REGISTER)?; + require(registers, right, F32_REGISTER)?; + initialize(registers, target, U32_REGISTER) + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + require(registers, condition, U32_REGISTER)?; + require(registers, when_true, F32_REGISTER)?; + require(registers, when_false, F32_REGISTER)?; + initialize(registers, target, F32_REGISTER) + } + Operation::ConvertU32ToF32 { target, source } => { + require(registers, source, U32_REGISTER)?; + initialize(registers, target, F32_REGISTER) + } + Operation::StoreF32 { + source, + buffer, + lane, + } => { + require(registers, source, F32_REGISTER)?; + validate_store(program, buffer, lane, ScalarType::F32, stored_lanes) + } + Operation::StoreU32 { + source, + buffer, + lane, + } => { + require(registers, source, U32_REGISTER)?; + validate_store(program, buffer, lane, ScalarType::U32, stored_lanes) + } + Operation::StoreU16 { + source, + buffer, + lane, + } => { + require(registers, source, U32_REGISTER)?; + validate_store(program, buffer, lane, ScalarType::U16, stored_lanes) + } + } +} + +fn initialize( + registers: &mut [u8; MAX_REGISTERS], + target: u8, + register_type: u8, +) -> Result<(), PolicyError> { + let slot = registers + .get_mut(usize::from(target)) + .ok_or(PolicyError::InvalidRegister)?; + *slot = register_type; + Ok(()) +} + +fn require( + registers: &[u8; MAX_REGISTERS], + register: u8, + register_type: u8, +) -> Result<(), PolicyError> { + let actual = *registers + .get(usize::from(register)) + .ok_or(PolicyError::InvalidRegister)?; + if actual == UNINITIALIZED { + return Err(PolicyError::UninitializedRegister); + } + if actual != register_type { + return Err(PolicyError::RegisterTypeMismatch); + } + Ok(()) +} + +fn validate_store( + program: &ProgramDescriptor, + buffer: BufferId, + lane: u8, + scalar: ScalarType, + stored_lanes: &mut [u8; MAX_BUFFERS_PER_PROGRAM], +) -> Result<(), PolicyError> { + let index = program + .buffers + .iter() + .position(|candidate| candidate.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + let schema = program.buffers[index]; + if schema.scalar != scalar { + return Err(PolicyError::StoreTypeMismatch); + } + if lane >= schema.vector_width { + return Err(PolicyError::InvalidStoreLane); + } + let mask = 1_u8 << lane; + if stored_lanes[index] & mask != 0 { + return Err(PolicyError::DuplicateStore); + } + stored_lanes[index] |= mask; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + const BITMAP: TechniqueId = TechniqueId(1); + const PROGRAM: ProgramId = ProgramId(1); + const ORIGINS: BufferId = BufferId(1); + + fn valid_program() -> ProgramDescriptor { + ProgramDescriptor { + technique: BITMAP, + variant: 0, + id: PROGRAM, + f32_input_count: 2, + u32_input_count: 0, + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema { + id: ORIGINS, + scalar: ScalarType::F32, + vector_width: 2, + }], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::LoadF32 { + target: 1, + field: 1, + }, + Operation::StoreF32 { + source: 0, + buffer: ORIGINS, + lane: 0, + }, + Operation::StoreF32 { + source: 1, + buffer: ORIGINS, + lane: 1, + }, + ], + } + } + + #[test] + fn accepts_complete_straight_line_program() { + let policy = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![valid_program()], + }) + .unwrap(); + assert_eq!( + policy.program(BITMAP, 0).map(|value| value.id), + Some(PROGRAM) + ); + assert_eq!(policy.program(BITMAP, 1), None); + } + + #[test] + fn rejects_uninitialized_and_wrong_type_registers() { + let mut uninitialized = valid_program(); + uninitialized.operations[2] = Operation::StoreF32 { + source: 4, + buffer: ORIGINS, + lane: 0, + }; + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![uninitialized], + }), + Err(PolicyError::UninitializedRegister) + ); + + let mut wrong_type = valid_program(); + wrong_type.operations[0] = Operation::ConstantU32 { + target: 0, + value: 1, + }; + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![wrong_type], + }), + Err(PolicyError::RegisterTypeMismatch) + ); + } + + #[test] + fn rejects_partial_duplicate_and_out_of_range_stores() { + let mut partial = valid_program(); + partial.operations.pop(); + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![partial], + }), + Err(PolicyError::IncompleteBuffer) + ); + + let mut duplicate = valid_program(); + duplicate.operations[3] = Operation::StoreF32 { + source: 1, + buffer: ORIGINS, + lane: 0, + }; + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![duplicate], + }), + Err(PolicyError::DuplicateStore) + ); + + let mut out_of_range = valid_program(); + out_of_range.operations[3] = Operation::StoreF32 { + source: 1, + buffer: ORIGINS, + lane: 2, + }; + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![out_of_range], + }), + Err(PolicyError::InvalidStoreLane) + ); + } + + #[test] + fn rejects_duplicate_technique_variants_and_program_ids() { + let first = valid_program(); + let mut same_variant = valid_program(); + same_variant.id = ProgramId(2); + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![first.clone(), same_variant], + }), + Err(PolicyError::DuplicateTechniqueVariant) + ); + + let mut duplicate_id = valid_program(); + duplicate_id.technique = TechniqueId(2); + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![first, duplicate_id], + }), + Err(PolicyError::DuplicateProgramId) + ); + } + + #[test] + fn accepts_same_technique_with_distinct_variants() { + let first = valid_program(); + let mut second = valid_program(); + second.variant = 1; + second.id = ProgramId(2); + let policy = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![first, second], + }) + .unwrap(); + assert_eq!(policy.programs().len(), 2); + } +} diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index ee3662fd..a4ed92fe 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -4,6 +4,7 @@ extern crate alloc; mod abi_contract; pub mod bidi; +pub mod engine; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] mod wire; From 808e5cf1a0e41a79e5672b58a7591a3e785d9acf Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 04:47:28 -0400 Subject: [PATCH 003/128] feat(text): register fixed-layout render policies --- docs/log.md | 13 + docs/packages/text.md | 14 +- packages/text/rust/shaper/src/abi_contract.rs | 229 ++++++++- packages/text/rust/shaper/src/engine/mod.rs | 6 + .../text/rust/shaper/src/engine/policy.rs | 14 + packages/text/rust/shaper/src/engine/state.rs | 123 +++++ packages/text/rust/shaper/src/engine/wire.rs | 446 ++++++++++++++++++ packages/text/rust/shaper/src/lib.rs | 2 + packages/text/rust/shaper/src/wasm.rs | 53 ++- packages/text/rust/shaper/src/wire.rs | 12 +- .../text/src/generated/text-shaper-abi.ts | 73 +++ .../render-policy-registration.test.mjs | 93 ++++ 12 files changed, 1072 insertions(+), 6 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/state.rs create mode 100644 packages/text/rust/shaper/src/engine/wire.rs create mode 100644 packages/text/tests/integration/render-policy-registration.test.mjs diff --git a/docs/log.md b/docs/log.md index a4ed2e83..f10defb3 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,19 @@ ## 2026-08-08 +- **Registered fixed-layout render policies at the Rust/Wasm boundary** — Added compiler-derived `#[repr(C)]` policy + headers and fixed-width program, buffer, and operation records to the existing generated shaper ABI. Registration + performs one bounded direct-memory decode, rejects overlapping tables, forged lengths, nonzero reserved fields, + noncanonical op encodings, and semantically incomplete programs, then retains typed Rust policy state independent of + the caller's allocation. Identical handle registration is idempotent, conflicting registration is observable, and + disposal is exact. No JSON, string dispatch, runtime reflection, or frame-path schema decode entered the Wasm module. + Twenty-one Rust unit tests, both complete Unicode 17 bidi conformance suites, and a real optimized-Wasm lifecycle test + pass. Making validation and lifecycle reachable grows the optimized shaper from 680,312 to 698,238 raw bytes, from + 253,568 to 260,228 gzip bytes, and from 199,365 to 203,760 Brotli bytes; this is registration-time infrastructure, not + evidence of frame-path performance. As expected for unreachable hot-path code, the 25,515-glyph comparison remains + within run variance: cold/font-size/layout-width/text medians move from 55.28/12.02/8.42/38.66 milliseconds to + 52.48/11.92/8.23/38.73 milliseconds, with corresponding p95 values of 69.57/14.87/11.07/41.06 milliseconds. + - **Began the Rust render-policy foundation without growing shipping Wasm** — Added the first renderer-neutral engine module to the existing shaper `rlib`, preserving the single `no_std + alloc` Rust/Wasm codebase rather than creating a second module. A bounded straight-line policy representation and total verifier now reject invalid identities, diff --git a/docs/packages/text.md b/docs/packages/text.md index c4f79cd1..b450714e 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:ac4d619314a7e2d2e02840013b6b4ef50ca74971f7ff0df87120d85a7213c74e' +source_digest: 'sha256:cca140771c55745ae3b27ad5056c26301a87f5371a23cd99dd3ea0c12956d743' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -372,6 +372,18 @@ buffer, operation, register, field, vector-width, and store coverage before exec verifier instead of owning parallel packers. The initial module is deliberately unreachable from the Wasm exports while its fixed-width wire contract is built, and optimized dead-code evidence keeps the shipping shaper at 680,312 raw bytes. +The next boundary slice exposes policy registration through compiler-derived `#[repr(C)]` request, program, buffer, and +operation records. TypeScript consumes the generated offsets directly; Rust performs one bounded registration-time +decode and retains typed policy state, so frame updates do not parse policy records. The decoder rejects forged lengths, +overlapping tables, nonzero reserved fields, noncanonical operation records, invalid register flow, and incomplete +physical outputs. Registration is idempotent only for an identical handle and policy, conflicting reuse and missing +disposal are distinct statuses, and retained state survives release of the request allocation. The optimized reachable +module measures 698,238 raw / 260,228 gzip / 203,760 Brotli bytes on the same Darwin arm64 toolchain, a delta of +17,926 / 6,660 / 4,395 bytes over the preceding shaper. This is cold registration infrastructure; frame-path admission +and performance remain unclaimed until the retained update and executor land. The existing 25,515-glyph TypeScript path +remains within measurement variance: baseline-to-current cold/font-size/layout-width/text medians are +55.28→52.48 / 12.02→11.92 / 8.42→8.23 / 38.66→38.73 milliseconds. + Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. The runtime repeats no parallel wire-format implementation. Bitmap and MTSDF renderers plus both standalone validators consume the same dependency-light KTX2 and dense-record rules; only the standalone layer imports Khronos/Ajv. The renderers also share the lossless-atlas adapter, unit quad, parallel-array checks, and resolved-paint lookup. The MTSDF resource uploads only its authenticated base levels into one padded texture array, samples them bilinearly, sizes reconstruction with screen derivatives, and owns one material per logical array; disposal releases materials and textures transactionally. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index c1113dcd..69d0cfd3 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -2,6 +2,12 @@ use alloc::string::{String, ToString}; use core::mem::{align_of, offset_of, size_of}; use serde_json::json; +use crate::engine::policy::{ + OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, + OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, + OP_STORE_U32, OP_SUBTRACT_F32, ScalarType, +}; + pub const ABI_VERSION: u32 = 0; pub const SHAPER_VERSION: &str = env!("CARGO_PKG_VERSION"); pub const HARFRUST_VERSION: &str = "0.12.0"; @@ -35,6 +41,52 @@ struct BidiRequestHeader { reserved: [u8; 3], } +#[repr(C)] +struct PolicyRequestHeader { + byte_length: u32, + programs_offset: u32, + program_count: u32, + buffers_offset: u32, + buffer_count: u32, + operations_offset: u32, + operation_count: u32, +} + +#[repr(C)] +struct PolicyProgramRecord { + technique_id: u32, + program_id: u32, + variant: u16, + f32_input_count: u8, + u32_input_count: u8, + paint_capabilities: u32, + compositing_capabilities: u32, + buffer_start: u32, + buffer_count: u16, + reserved0: u16, + operation_start: u32, + operation_count: u16, + reserved1: u16, +} + +#[repr(C)] +struct PolicyBufferRecord { + id: u16, + scalar: u8, + vector_width: u8, +} + +#[repr(C)] +struct PolicyOperationRecord { + opcode: u8, + target: u8, + operand0: u8, + operand1: u8, + immediate0: u32, + immediate1: u32, + immediate2: u32, +} + #[repr(C)] struct FeatureRecord { tag: u32, @@ -120,6 +172,26 @@ layout!( BIDI_REQUEST_HEADER_ALIGNMENT, BidiRequestHeader ); +layout!( + POLICY_REQUEST_HEADER_SIZE, + POLICY_REQUEST_HEADER_ALIGNMENT, + PolicyRequestHeader +); +layout!( + POLICY_PROGRAM_RECORD_SIZE, + POLICY_PROGRAM_RECORD_ALIGNMENT, + PolicyProgramRecord +); +layout!( + POLICY_BUFFER_RECORD_SIZE, + POLICY_BUFFER_RECORD_ALIGNMENT, + PolicyBufferRecord +); +layout!( + POLICY_OPERATION_RECORD_SIZE, + POLICY_OPERATION_RECORD_ALIGNMENT, + PolicyOperationRecord +); layout!(FEATURE_RECORD_SIZE, FEATURE_RECORD_ALIGNMENT, FeatureRecord); layout!(RUN_RECORD_SIZE, RUN_RECORD_ALIGNMENT, RunRecord); layout!( @@ -153,6 +225,88 @@ field_offset!(RESHAPE_RANGE_COUNT, ReshapeRequestHeader, range_count); field_offset!(BIDI_TEXT_OFFSET, BidiRequestHeader, text_offset); field_offset!(BIDI_TEXT_LENGTH, BidiRequestHeader, text_length); field_offset!(BIDI_DIRECTION, BidiRequestHeader, direction); +field_offset!(POLICY_BYTE_LENGTH, PolicyRequestHeader, byte_length); +field_offset!(POLICY_PROGRAMS_OFFSET, PolicyRequestHeader, programs_offset); +field_offset!(POLICY_PROGRAM_COUNT, PolicyRequestHeader, program_count); +field_offset!(POLICY_BUFFERS_OFFSET, PolicyRequestHeader, buffers_offset); +field_offset!(POLICY_BUFFER_COUNT, PolicyRequestHeader, buffer_count); +field_offset!( + POLICY_OPERATIONS_OFFSET, + PolicyRequestHeader, + operations_offset +); +field_offset!(POLICY_OPERATION_COUNT, PolicyRequestHeader, operation_count); +field_offset!( + POLICY_PROGRAM_TECHNIQUE_ID, + PolicyProgramRecord, + technique_id +); +field_offset!(POLICY_PROGRAM_ID, PolicyProgramRecord, program_id); +field_offset!(POLICY_PROGRAM_VARIANT, PolicyProgramRecord, variant); +field_offset!( + POLICY_PROGRAM_F32_INPUT_COUNT, + PolicyProgramRecord, + f32_input_count +); +field_offset!( + POLICY_PROGRAM_U32_INPUT_COUNT, + PolicyProgramRecord, + u32_input_count +); +field_offset!( + POLICY_PROGRAM_PAINT_CAPABILITIES, + PolicyProgramRecord, + paint_capabilities +); +field_offset!( + POLICY_PROGRAM_COMPOSITING_CAPABILITIES, + PolicyProgramRecord, + compositing_capabilities +); +field_offset!( + POLICY_PROGRAM_BUFFER_START, + PolicyProgramRecord, + buffer_start +); +field_offset!( + POLICY_PROGRAM_BUFFER_COUNT, + PolicyProgramRecord, + buffer_count +); +field_offset!(POLICY_PROGRAM_RESERVED0, PolicyProgramRecord, reserved0); +field_offset!( + POLICY_PROGRAM_OPERATION_START, + PolicyProgramRecord, + operation_start +); +field_offset!( + POLICY_PROGRAM_OPERATION_COUNT, + PolicyProgramRecord, + operation_count +); +field_offset!(POLICY_PROGRAM_RESERVED1, PolicyProgramRecord, reserved1); +field_offset!(POLICY_BUFFER_ID, PolicyBufferRecord, id); +field_offset!(POLICY_BUFFER_SCALAR, PolicyBufferRecord, scalar); +field_offset!(POLICY_BUFFER_VECTOR_WIDTH, PolicyBufferRecord, vector_width); +field_offset!(POLICY_OPERATION_OPCODE, PolicyOperationRecord, opcode); +field_offset!(POLICY_OPERATION_TARGET, PolicyOperationRecord, target); +field_offset!(POLICY_OPERATION_OPERAND0, PolicyOperationRecord, operand0); +field_offset!(POLICY_OPERATION_OPERAND1, PolicyOperationRecord, operand1); +field_offset!( + POLICY_OPERATION_IMMEDIATE0, + PolicyOperationRecord, + immediate0 +); +field_offset!( + POLICY_OPERATION_IMMEDIATE1, + PolicyOperationRecord, + immediate1 +); +field_offset!( + POLICY_OPERATION_IMMEDIATE2, + PolicyOperationRecord, + immediate2 +); field_offset!(FEATURE_TAG, FeatureRecord, tag); field_offset!(FEATURE_VALUE, FeatureRecord, value); field_offset!(FEATURE_START, FeatureRecord, start); @@ -251,6 +405,9 @@ pub fn json() -> String { "fontCount": "pmndrs_text_shaper_font_count", "retainedFontBytes": "pmndrs_text_shaper_retained_font_bytes", "planCount": "pmndrs_text_shaper_plan_count", + "registerPolicy": "pmndrs_text_engine_register_policy", + "disposePolicy": "pmndrs_text_engine_dispose_policy", + "policyCount": "pmndrs_text_engine_policy_count", "shapeBatch": "pmndrs_text_shaper_shape_batch", "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", @@ -283,6 +440,52 @@ pub fn json() -> String { "textLength": BIDI_TEXT_LENGTH, "direction": BIDI_DIRECTION }, + "policyRequest": { + "size": POLICY_REQUEST_HEADER_SIZE, + "alignment": POLICY_REQUEST_HEADER_ALIGNMENT, + "byteLength": POLICY_BYTE_LENGTH, + "programsOffset": POLICY_PROGRAMS_OFFSET, + "programCount": POLICY_PROGRAM_COUNT, + "buffersOffset": POLICY_BUFFERS_OFFSET, + "bufferCount": POLICY_BUFFER_COUNT, + "operationsOffset": POLICY_OPERATIONS_OFFSET, + "operationCount": POLICY_OPERATION_COUNT + }, + "policyProgram": { + "size": POLICY_PROGRAM_RECORD_SIZE, + "alignment": POLICY_PROGRAM_RECORD_ALIGNMENT, + "techniqueId": POLICY_PROGRAM_TECHNIQUE_ID, + "programId": POLICY_PROGRAM_ID, + "variant": POLICY_PROGRAM_VARIANT, + "f32InputCount": POLICY_PROGRAM_F32_INPUT_COUNT, + "u32InputCount": POLICY_PROGRAM_U32_INPUT_COUNT, + "paintCapabilities": POLICY_PROGRAM_PAINT_CAPABILITIES, + "compositingCapabilities": POLICY_PROGRAM_COMPOSITING_CAPABILITIES, + "bufferStart": POLICY_PROGRAM_BUFFER_START, + "bufferCount": POLICY_PROGRAM_BUFFER_COUNT, + "reserved0": POLICY_PROGRAM_RESERVED0, + "operationStart": POLICY_PROGRAM_OPERATION_START, + "operationCount": POLICY_PROGRAM_OPERATION_COUNT, + "reserved1": POLICY_PROGRAM_RESERVED1 + }, + "policyBuffer": { + "size": POLICY_BUFFER_RECORD_SIZE, + "alignment": POLICY_BUFFER_RECORD_ALIGNMENT, + "id": POLICY_BUFFER_ID, + "scalar": POLICY_BUFFER_SCALAR, + "vectorWidth": POLICY_BUFFER_VECTOR_WIDTH + }, + "policyOperation": { + "size": POLICY_OPERATION_RECORD_SIZE, + "alignment": POLICY_OPERATION_RECORD_ALIGNMENT, + "opcode": POLICY_OPERATION_OPCODE, + "target": POLICY_OPERATION_TARGET, + "operand0": POLICY_OPERATION_OPERAND0, + "operand1": POLICY_OPERATION_OPERAND1, + "immediate0": POLICY_OPERATION_IMMEDIATE0, + "immediate1": POLICY_OPERATION_IMMEDIATE1, + "immediate2": POLICY_OPERATION_IMMEDIATE2 + }, "feature": { "size": FEATURE_RECORD_SIZE, "alignment": FEATURE_RECORD_ALIGNMENT, @@ -357,6 +560,28 @@ pub fn json() -> String { "PDI": 22 } }, + "policy": { + "scalarTypes": { + "f32": ScalarType::F32 as u8, + "u32": ScalarType::U32 as u8, + "u16": ScalarType::U16 as u8 + }, + "opcodes": { + "loadF32": OP_LOAD_F32, + "loadU32": OP_LOAD_U32, + "constantF32": OP_CONSTANT_F32, + "constantU32": OP_CONSTANT_U32, + "addF32": OP_ADD_F32, + "subtractF32": OP_SUBTRACT_F32, + "multiplyF32": OP_MULTIPLY_F32, + "lessThanF32": OP_LESS_THAN_F32, + "selectF32": OP_SELECT_F32, + "convertU32ToF32": OP_CONVERT_U32_TO_F32, + "storeF32": OP_STORE_F32, + "storeU32": OP_STORE_U32, + "storeU16": OP_STORE_U16 + } + }, "status": { "ok": 0, "invalidHandle": 1, @@ -365,7 +590,9 @@ pub fn json() -> String { "handleConflict": 4, "fontMissing": 5, "invalidRequest": 6, - "resultTooLarge": 7 + "resultTooLarge": 7, + "policyConflict": 8, + "policyMissing": 9 } }) .to_string() diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 2c122c5c..f451eeda 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -3,4 +3,10 @@ //! The public types in this module are available to native consumers. Wasm memory ownership and //! pointer validation stay in the target-gated transport module. +mod state; + pub mod policy; +#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] +pub(crate) mod wire; + +pub use state::{EngineError, TextEngine}; diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 042cd1ca..b46c89a1 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -11,6 +11,20 @@ pub const MAX_OPERATIONS_PER_PROGRAM: usize = 128; pub const MAX_REGISTERS: usize = 32; pub const MAX_VECTOR_WIDTH: u8 = 4; +pub const OP_LOAD_F32: u8 = 1; +pub const OP_LOAD_U32: u8 = 2; +pub const OP_CONSTANT_F32: u8 = 3; +pub const OP_CONSTANT_U32: u8 = 4; +pub const OP_ADD_F32: u8 = 5; +pub const OP_SUBTRACT_F32: u8 = 6; +pub const OP_MULTIPLY_F32: u8 = 7; +pub const OP_LESS_THAN_F32: u8 = 8; +pub const OP_SELECT_F32: u8 = 9; +pub const OP_CONVERT_U32_TO_F32: u8 = 10; +pub const OP_STORE_F32: u8 = 11; +pub const OP_STORE_U32: u8 = 12; +pub const OP_STORE_U16: u8 = 13; + const UNINITIALIZED: u8 = 0; const F32_REGISTER: u8 = 1; const U32_REGISTER: u8 = 2; diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs new file mode 100644 index 00000000..efca6c60 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -0,0 +1,123 @@ +use alloc::collections::BTreeMap; + +use super::policy::ValidatedPolicy; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EngineError { + InvalidHandle, + HandleConflict, + PolicyMissing, +} + +#[derive(Default)] +pub struct TextEngine { + policies: BTreeMap, +} + +impl TextEngine { + pub fn register_policy( + &mut self, + handle: u32, + policy: ValidatedPolicy, + ) -> Result<(), EngineError> { + if handle == 0 { + return Err(EngineError::InvalidHandle); + } + if let Some(existing) = self.policies.get(&handle) { + return if existing == &policy { + Ok(()) + } else { + Err(EngineError::HandleConflict) + }; + } + self.policies.insert(handle, policy); + Ok(()) + } + + pub fn dispose_policy(&mut self, handle: u32) -> Result<(), EngineError> { + self.policies + .remove(&handle) + .map(|_| ()) + .ok_or(EngineError::PolicyMissing) + } + + pub fn policy(&self, handle: u32) -> Result<&ValidatedPolicy, EngineError> { + self.policies.get(&handle).ok_or(EngineError::PolicyMissing) + } + + pub fn policy_count(&self) -> u32 { + self.policies.len().try_into().unwrap_or(u32::MAX) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::policy::{ + BufferId, BufferSchema, Operation, PolicyDescriptor, ProgramCapabilities, + ProgramDescriptor, ProgramId, ScalarType, TechniqueId, + }; + use alloc::vec; + + #[test] + fn registration_is_idempotent_but_rejects_handle_conflicts() { + let first = validated_policy(TechniqueId(1)); + let mut engine = TextEngine::default(); + assert_eq!(engine.register_policy(1, first.clone()), Ok(())); + assert_eq!(engine.register_policy(1, first), Ok(())); + assert_eq!(engine.policy_count(), 1); + assert_eq!( + engine.register_policy(1, validated_policy(TechniqueId(2))), + Err(EngineError::HandleConflict) + ); + assert_eq!( + engine.policy(1).unwrap().programs()[0].technique, + TechniqueId(1) + ); + } + + #[test] + fn disposal_is_exact_and_missing_handles_are_observable() { + let mut engine = TextEngine::default(); + assert_eq!( + engine.register_policy(0, validated_policy(TechniqueId(1))), + Err(EngineError::InvalidHandle) + ); + assert_eq!(engine.dispose_policy(1), Err(EngineError::PolicyMissing)); + engine + .register_policy(1, validated_policy(TechniqueId(1))) + .unwrap(); + assert_eq!(engine.dispose_policy(1), Ok(())); + assert_eq!(engine.dispose_policy(1), Err(EngineError::PolicyMissing)); + } + + fn validated_policy(technique: TechniqueId) -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![ProgramDescriptor { + technique, + variant: 0, + id: ProgramId(1), + f32_input_count: 1, + u32_input_count: 0, + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema { + id: BufferId(1), + scalar: ScalarType::F32, + vector_width: 1, + }], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } +} diff --git a/packages/text/rust/shaper/src/engine/wire.rs b/packages/text/rust/shaper/src/engine/wire.rs new file mode 100644 index 00000000..1a6eb76b --- /dev/null +++ b/packages/text/rust/shaper/src/engine/wire.rs @@ -0,0 +1,446 @@ +//! Fixed-width policy wire decoding. +//! +//! Rust compiler-derived offsets in `abi_contract` are the only layout authority. This decoder +//! performs one bounded registration-time pass over direct Wasm memory and retains typed policy +//! data; frame updates never revisit these records. + +use alloc::vec::Vec; + +use crate::{ + STATUS_INVALID_REQUEST, + abi_contract::{ + POLICY_BUFFER_COUNT, POLICY_BUFFER_ID, POLICY_BUFFER_RECORD_ALIGNMENT, + POLICY_BUFFER_RECORD_SIZE, POLICY_BUFFER_SCALAR, POLICY_BUFFER_VECTOR_WIDTH, + POLICY_BUFFERS_OFFSET, POLICY_BYTE_LENGTH, POLICY_OPERATION_COUNT, + POLICY_OPERATION_IMMEDIATE0, POLICY_OPERATION_IMMEDIATE1, POLICY_OPERATION_IMMEDIATE2, + POLICY_OPERATION_OPCODE, POLICY_OPERATION_OPERAND0, POLICY_OPERATION_OPERAND1, + POLICY_OPERATION_RECORD_ALIGNMENT, POLICY_OPERATION_RECORD_SIZE, POLICY_OPERATION_TARGET, + POLICY_OPERATIONS_OFFSET, POLICY_PROGRAM_BUFFER_COUNT, POLICY_PROGRAM_BUFFER_START, + POLICY_PROGRAM_COMPOSITING_CAPABILITIES, POLICY_PROGRAM_COUNT, + POLICY_PROGRAM_F32_INPUT_COUNT, POLICY_PROGRAM_ID, POLICY_PROGRAM_OPERATION_COUNT, + POLICY_PROGRAM_OPERATION_START, POLICY_PROGRAM_PAINT_CAPABILITIES, + POLICY_PROGRAM_RECORD_ALIGNMENT, POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, + POLICY_PROGRAM_RESERVED1, POLICY_PROGRAM_TECHNIQUE_ID, POLICY_PROGRAM_U32_INPUT_COUNT, + POLICY_PROGRAM_VARIANT, POLICY_PROGRAMS_OFFSET, POLICY_REQUEST_HEADER_SIZE, + }, + engine::policy::{ + BufferId, BufferSchema, MAX_BUFFERS_PER_PROGRAM, MAX_OPERATIONS_PER_PROGRAM, MAX_PROGRAMS, + OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, + OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, + OP_STORE_U32, OP_SUBTRACT_F32, Operation, PolicyDescriptor, ProgramCapabilities, + ProgramDescriptor, ProgramId, ScalarType, TechniqueId, ValidatedPolicy, + }, + wire::{array, read_u16, read_u32}, +}; + +pub(crate) fn parse_policy(bytes: &[u8]) -> Result { + if bytes.len() < POLICY_REQUEST_HEADER_SIZE as usize + || read_u32(bytes, POLICY_BYTE_LENGTH)? + != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? + { + return Err(STATUS_INVALID_REQUEST); + } + + let program_count = read_u32(bytes, POLICY_PROGRAM_COUNT)?; + let buffer_count = read_u32(bytes, POLICY_BUFFER_COUNT)?; + let operation_count = read_u32(bytes, POLICY_OPERATION_COUNT)?; + if usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_PROGRAMS + || usize::try_from(buffer_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_PROGRAMS * MAX_BUFFERS_PER_PROGRAM + || usize::try_from(operation_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_PROGRAMS * MAX_OPERATIONS_PER_PROGRAM + { + return Err(STATUS_INVALID_REQUEST); + } + + let programs = table( + bytes, + read_u32(bytes, POLICY_PROGRAMS_OFFSET)?, + program_count, + POLICY_PROGRAM_RECORD_SIZE, + POLICY_PROGRAM_RECORD_ALIGNMENT, + )?; + let buffers_offset = read_u32(bytes, POLICY_BUFFERS_OFFSET)?; + let buffers = table( + bytes, + buffers_offset, + buffer_count, + POLICY_BUFFER_RECORD_SIZE, + POLICY_BUFFER_RECORD_ALIGNMENT, + )?; + let operations_offset = read_u32(bytes, POLICY_OPERATIONS_OFFSET)?; + let operations = table( + bytes, + operations_offset, + operation_count, + POLICY_OPERATION_RECORD_SIZE, + POLICY_OPERATION_RECORD_ALIGNMENT, + )?; + reject_overlaps(bytes, programs, buffers, operations)?; + + let mut decoded = Vec::new(); + decoded + .try_reserve_exact(usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)?) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in programs.chunks_exact(POLICY_PROGRAM_RECORD_SIZE as usize) { + if read_u16(record, POLICY_PROGRAM_RESERVED0)? != 0 + || read_u16(record, POLICY_PROGRAM_RESERVED1)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let selected_buffers = indexed_records( + buffers, + read_u32(record, POLICY_PROGRAM_BUFFER_START)?, + read_u16(record, POLICY_PROGRAM_BUFFER_COUNT)?, + POLICY_BUFFER_RECORD_SIZE, + )?; + let selected_operations = indexed_records( + operations, + read_u32(record, POLICY_PROGRAM_OPERATION_START)?, + read_u16(record, POLICY_PROGRAM_OPERATION_COUNT)?, + POLICY_OPERATION_RECORD_SIZE, + )?; + decoded.push(ProgramDescriptor { + technique: TechniqueId(read_u32(record, POLICY_PROGRAM_TECHNIQUE_ID)?), + variant: read_u16(record, POLICY_PROGRAM_VARIANT)?, + id: ProgramId(read_u32(record, POLICY_PROGRAM_ID)?), + f32_input_count: byte(record, POLICY_PROGRAM_F32_INPUT_COUNT)?, + u32_input_count: byte(record, POLICY_PROGRAM_U32_INPUT_COUNT)?, + capabilities: ProgramCapabilities { + paint: read_u32(record, POLICY_PROGRAM_PAINT_CAPABILITIES)?, + compositing: read_u32(record, POLICY_PROGRAM_COMPOSITING_CAPABILITIES)?, + }, + buffers: decode_buffers(selected_buffers)?, + operations: decode_operations(selected_operations)?, + }); + } + ValidatedPolicy::new(PolicyDescriptor { programs: decoded }).map_err(|_| STATUS_INVALID_REQUEST) +} + +fn table(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { + if offset < POLICY_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + array(bytes, offset, count, stride, alignment) +} + +fn reject_overlaps( + bytes: &[u8], + programs: &[u8], + buffers: &[u8], + operations: &[u8], +) -> Result<(), u32> { + let ranges = [ + relative_range(bytes, programs)?, + relative_range(bytes, buffers)?, + relative_range(bytes, operations)?, + ]; + for index in 0..ranges.len() { + for previous in &ranges[..index] { + let current = ranges[index]; + if current.start < previous.end && previous.start < current.end { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +#[derive(Clone, Copy)] +struct ByteRange { + start: usize, + end: usize, +} + +fn relative_range(container: &[u8], selected: &[u8]) -> Result { + let start = (selected.as_ptr() as usize) + .checked_sub(container.as_ptr() as usize) + .ok_or(STATUS_INVALID_REQUEST)?; + Ok(ByteRange { + start, + end: start + .checked_add(selected.len()) + .ok_or(STATUS_INVALID_REQUEST)?, + }) +} + +fn indexed_records(records: &[u8], start: u32, count: u16, stride: u32) -> Result<&[u8], u32> { + let offset = start.checked_mul(stride).ok_or(STATUS_INVALID_REQUEST)?; + array(records, offset, u32::from(count), stride, 1) +} + +fn decode_buffers(records: &[u8]) -> Result, u32> { + let mut buffers = Vec::new(); + buffers + .try_reserve_exact(records.len() / POLICY_BUFFER_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_BUFFER_RECORD_SIZE as usize) { + let scalar = match byte(record, POLICY_BUFFER_SCALAR)? { + value if value == ScalarType::F32 as u8 => ScalarType::F32, + value if value == ScalarType::U32 as u8 => ScalarType::U32, + value if value == ScalarType::U16 as u8 => ScalarType::U16, + _ => return Err(STATUS_INVALID_REQUEST), + }; + buffers.push(BufferSchema { + id: BufferId(read_u16(record, POLICY_BUFFER_ID)?), + scalar, + vector_width: byte(record, POLICY_BUFFER_VECTOR_WIDTH)?, + }); + } + Ok(buffers) +} + +fn decode_operations(records: &[u8]) -> Result, u32> { + let mut operations = Vec::new(); + operations + .try_reserve_exact(records.len() / POLICY_OPERATION_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_OPERATION_RECORD_SIZE as usize) { + operations.push(decode_operation(record)?); + } + Ok(operations) +} + +fn decode_operation(record: &[u8]) -> Result { + let opcode = byte(record, POLICY_OPERATION_OPCODE)?; + let target = byte(record, POLICY_OPERATION_TARGET)?; + let operand0 = byte(record, POLICY_OPERATION_OPERAND0)?; + let operand1 = byte(record, POLICY_OPERATION_OPERAND1)?; + let immediate0 = read_u32(record, POLICY_OPERATION_IMMEDIATE0)?; + let immediate1 = read_u32(record, POLICY_OPERATION_IMMEDIATE1)?; + let immediate2 = read_u32(record, POLICY_OPERATION_IMMEDIATE2)?; + let no_immediates = || { + (immediate0 == 0 && immediate1 == 0 && immediate2 == 0) + .then_some(()) + .ok_or(STATUS_INVALID_REQUEST) + }; + match opcode { + OP_LOAD_F32 | OP_LOAD_U32 => { + if operand1 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + no_immediates()?; + Ok(if opcode == OP_LOAD_F32 { + Operation::LoadF32 { + target, + field: operand0, + } + } else { + Operation::LoadU32 { + target, + field: operand0, + } + }) + } + OP_CONSTANT_F32 | OP_CONSTANT_U32 => { + if operand0 != 0 || operand1 != 0 || immediate1 != 0 || immediate2 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + Ok(if opcode == OP_CONSTANT_F32 { + Operation::ConstantF32 { + target, + bits: immediate0, + } + } else { + Operation::ConstantU32 { + target, + value: immediate0, + } + }) + } + OP_ADD_F32 | OP_SUBTRACT_F32 | OP_MULTIPLY_F32 | OP_LESS_THAN_F32 => { + no_immediates()?; + Ok(match opcode { + OP_ADD_F32 => Operation::AddF32 { + target, + left: operand0, + right: operand1, + }, + OP_SUBTRACT_F32 => Operation::SubtractF32 { + target, + left: operand0, + right: operand1, + }, + OP_MULTIPLY_F32 => Operation::MultiplyF32 { + target, + left: operand0, + right: operand1, + }, + _ => Operation::LessThanF32 { + target, + left: operand0, + right: operand1, + }, + }) + } + OP_SELECT_F32 => { + if immediate0 > u8::MAX.into() || immediate1 != 0 || immediate2 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + Ok(Operation::SelectF32 { + target, + condition: operand0, + when_true: operand1, + when_false: immediate0 as u8, + }) + } + OP_CONVERT_U32_TO_F32 => { + if operand1 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + no_immediates()?; + Ok(Operation::ConvertU32ToF32 { + target, + source: operand0, + }) + } + OP_STORE_F32 | OP_STORE_U32 | OP_STORE_U16 => { + if target != 0 || immediate0 > u16::MAX.into() || immediate1 != 0 || immediate2 != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let buffer = BufferId(immediate0 as u16); + Ok(match opcode { + OP_STORE_F32 => Operation::StoreF32 { + source: operand0, + buffer, + lane: operand1, + }, + OP_STORE_U32 => Operation::StoreU32 { + source: operand0, + buffer, + lane: operand1, + }, + _ => Operation::StoreU16 { + source: operand0, + buffer, + lane: operand1, + }, + }) + } + _ => Err(STATUS_INVALID_REQUEST), + } +} + +fn byte(bytes: &[u8], offset: usize) -> Result { + bytes.get(offset).copied().ok_or(STATUS_INVALID_REQUEST) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + const PROGRAMS_OFFSET: usize = POLICY_REQUEST_HEADER_SIZE as usize; + const BUFFERS_OFFSET: usize = PROGRAMS_OFFSET + POLICY_PROGRAM_RECORD_SIZE as usize; + const OPERATIONS_OFFSET: usize = BUFFERS_OFFSET + POLICY_BUFFER_RECORD_SIZE as usize; + const OPERATION_COUNT: usize = 4; + const BYTE_LENGTH: usize = + OPERATIONS_OFFSET + OPERATION_COUNT * POLICY_OPERATION_RECORD_SIZE as usize; + + #[test] + fn decodes_compiler_mapped_policy_records() { + let bytes = valid_policy_bytes(); + let policy = parse_policy(&bytes).unwrap(); + let program = policy.program(TechniqueId(1), 0).unwrap(); + assert_eq!(program.id, ProgramId(2)); + assert_eq!(program.buffers[0].stride(), 8); + assert_eq!(program.operations.len(), OPERATION_COUNT); + } + + #[test] + fn rejects_forged_lengths_overlaps_and_reserved_data() { + let mut forged_length = valid_policy_bytes(); + put_u32(&mut forged_length, POLICY_BYTE_LENGTH, u32::MAX); + assert_eq!(parse_policy(&forged_length), Err(STATUS_INVALID_REQUEST)); + + let mut overlap = valid_policy_bytes(); + put_u32( + &mut overlap, + POLICY_BUFFERS_OFFSET, + u32::try_from(PROGRAMS_OFFSET).unwrap(), + ); + assert_eq!(parse_policy(&overlap), Err(STATUS_INVALID_REQUEST)); + + let mut reserved = valid_policy_bytes(); + put_u16( + &mut reserved[PROGRAMS_OFFSET..], + POLICY_PROGRAM_RESERVED0, + 1, + ); + assert_eq!(parse_policy(&reserved), Err(STATUS_INVALID_REQUEST)); + } + + #[test] + fn rejects_unknown_and_noncanonical_operations() { + let mut unknown = valid_policy_bytes(); + unknown[OPERATIONS_OFFSET + POLICY_OPERATION_OPCODE] = u8::MAX; + assert_eq!(parse_policy(&unknown), Err(STATUS_INVALID_REQUEST)); + + let mut noncanonical = valid_policy_bytes(); + noncanonical[OPERATIONS_OFFSET + POLICY_OPERATION_OPERAND1] = 1; + assert_eq!(parse_policy(&noncanonical), Err(STATUS_INVALID_REQUEST)); + } + + fn valid_policy_bytes() -> Vec { + let mut bytes = vec![0; BYTE_LENGTH]; + put_u32(&mut bytes, POLICY_BYTE_LENGTH, BYTE_LENGTH as u32); + put_u32(&mut bytes, POLICY_PROGRAMS_OFFSET, PROGRAMS_OFFSET as u32); + put_u32(&mut bytes, POLICY_PROGRAM_COUNT, 1); + put_u32(&mut bytes, POLICY_BUFFERS_OFFSET, BUFFERS_OFFSET as u32); + put_u32(&mut bytes, POLICY_BUFFER_COUNT, 1); + put_u32( + &mut bytes, + POLICY_OPERATIONS_OFFSET, + OPERATIONS_OFFSET as u32, + ); + put_u32(&mut bytes, POLICY_OPERATION_COUNT, OPERATION_COUNT as u32); + + let program = &mut bytes[PROGRAMS_OFFSET..BUFFERS_OFFSET]; + put_u32(program, POLICY_PROGRAM_TECHNIQUE_ID, 1); + put_u32(program, POLICY_PROGRAM_ID, 2); + program[POLICY_PROGRAM_F32_INPUT_COUNT] = 2; + put_u16(program, POLICY_PROGRAM_BUFFER_COUNT, 1); + put_u16( + program, + POLICY_PROGRAM_OPERATION_COUNT, + OPERATION_COUNT as u16, + ); + + let buffer = &mut bytes[BUFFERS_OFFSET..OPERATIONS_OFFSET]; + put_u16(buffer, POLICY_BUFFER_ID, 1); + buffer[POLICY_BUFFER_SCALAR] = ScalarType::F32 as u8; + buffer[POLICY_BUFFER_VECTOR_WIDTH] = 2; + + write_operation(&mut bytes, 0, OP_LOAD_F32, 0, 0, 0, 0); + write_operation(&mut bytes, 1, OP_LOAD_F32, 1, 1, 0, 0); + write_operation(&mut bytes, 2, OP_STORE_F32, 0, 0, 0, 1); + write_operation(&mut bytes, 3, OP_STORE_F32, 0, 1, 1, 1); + bytes + } + + fn write_operation( + bytes: &mut [u8], + index: usize, + opcode: u8, + target: u8, + operand0: u8, + operand1: u8, + immediate0: u32, + ) { + let start = OPERATIONS_OFFSET + index * POLICY_OPERATION_RECORD_SIZE as usize; + let record = &mut bytes[start..start + POLICY_OPERATION_RECORD_SIZE as usize]; + record[POLICY_OPERATION_OPCODE] = opcode; + record[POLICY_OPERATION_TARGET] = target; + record[POLICY_OPERATION_OPERAND0] = operand0; + record[POLICY_OPERATION_OPERAND1] = operand1; + put_u32(record, POLICY_OPERATION_IMMEDIATE0, immediate0); + } + + fn put_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } +} diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index a4ed92fe..171ba86b 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -27,6 +27,8 @@ pub const STATUS_HANDLE_CONFLICT: u32 = 4; pub const STATUS_FONT_MISSING: u32 = 5; pub const STATUS_INVALID_REQUEST: u32 = 6; pub const STATUS_RESULT_TOO_LARGE: u32 = 7; +pub const STATUS_POLICY_CONFLICT: u32 = 8; +pub const STATUS_POLICY_MISSING: u32 = 9; const BUFFER_FLAGS_MASK: u32 = 0xff; const MAX_CACHED_PLANS_PER_FONT: usize = 64; diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 30fc449e..52b18864 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -2,7 +2,9 @@ use alloc::{boxed::Box, vec::Vec}; use core::sync::atomic::{AtomicUsize, Ordering}; use crate::{ - STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, ShaperRegistry, bidi, + STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, STATUS_POLICY_CONFLICT, STATUS_POLICY_MISSING, + STATUS_RESULT_TOO_LARGE, ShaperRegistry, bidi, + engine::{EngineError, TextEngine, wire::parse_policy}, wire::{ pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, parse_shape_request, @@ -46,6 +48,7 @@ pub unsafe extern "C" fn pmndrs_text_shaper_register_font( let WasmState { registry, allocations, + .. } = state; let Some(sfnt) = owned_bytes(allocations, sfnt_pointer, sfnt_length) else { return 2; @@ -82,6 +85,45 @@ pub extern "C" fn pmndrs_text_shaper_plan_count() -> u32 { with_state(|state| state.registry.plan_count()) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_engine_register_policy( + handle: u32, + pointer: u32, + length: u32, +) -> u32 { + with_state(|state| { + let WasmState { + engine, + allocations, + .. + } = state; + let Some(bytes) = owned_bytes(allocations, pointer, length) else { + return STATUS_INVALID_REQUEST; + }; + let policy = match parse_policy(bytes) { + Ok(policy) => policy, + Err(status) => return status, + }; + match engine.register_policy(handle, policy) { + Ok(()) => 0, + Err(error) => engine_status(error), + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_dispose_policy(handle: u32) -> u32 { + with_state(|state| match state.engine.dispose_policy(handle) { + Ok(()) => 0, + Err(error) => engine_status(error), + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_policy_count() -> u32 { + with_state(|state| state.engine.policy_count()) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn pmndrs_text_shaper_shape_batch(pointer: u32, length: u32) -> u32 { with_state(|state| { @@ -162,6 +204,7 @@ pub extern "C" fn pmndrs_text_shaper_result_len() -> u32 { #[derive(Default)] struct WasmState { registry: ShaperRegistry, + engine: TextEngine, allocations: Vec, } @@ -234,6 +277,14 @@ fn store_result(registry: &mut ShaperRegistry, result: Vec) -> u32 { } } +fn engine_status(error: EngineError) -> u32 { + match error { + EngineError::InvalidHandle => STATUS_INVALID_HANDLE, + EngineError::HandleConflict => STATUS_POLICY_CONFLICT, + EngineError::PolicyMissing => STATUS_POLICY_MISSING, + } +} + fn with_state(operation: impl FnOnce(&mut WasmState) -> Result) -> Result { let mut pointer = STATE.load(Ordering::Acquire); if pointer == 0 { diff --git a/packages/text/rust/shaper/src/wire.rs b/packages/text/rust/shaper/src/wire.rs index 898ae4e9..94eb2fc6 100644 --- a/packages/text/rust/shaper/src/wire.rs +++ b/packages/text/rust/shaper/src/wire.rs @@ -319,7 +319,13 @@ pub fn pack_bidi_result(output: &BidiAnalysis) -> Result, u32> { Ok(bytes) } -fn array(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { +pub(crate) fn array( + bytes: &[u8], + offset: u32, + count: u32, + stride: u32, + alignment: u32, +) -> Result<&[u8], u32> { if !offset.is_multiple_of(alignment) { return Err(STATUS_INVALID_REQUEST); } @@ -330,14 +336,14 @@ fn array(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> bytes.get(offset..end).ok_or(STATUS_INVALID_REQUEST) } -fn read_u16(bytes: &[u8], offset: usize) -> Result { +pub(crate) fn read_u16(bytes: &[u8], offset: usize) -> Result { let value = bytes .get(offset..offset.checked_add(2).ok_or(STATUS_INVALID_REQUEST)?) .ok_or(STATUS_INVALID_REQUEST)?; Ok(u16::from_le_bytes([value[0], value[1]])) } -fn read_u32(bytes: &[u8], offset: usize) -> Result { +pub(crate) fn read_u32(bytes: &[u8], offset: usize) -> Result { let value = bytes .get(offset..offset.checked_add(4).ok_or(STATUS_INVALID_REQUEST)?) .ok_or(STATUS_INVALID_REQUEST)?; diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 5ac42ba7..08bec9a4 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -38,9 +38,12 @@ export const textShaperAbi = { "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", "deallocate": "pmndrs_text_shaper_dealloc", "disposeFont": "pmndrs_text_shaper_dispose_font", + "disposePolicy": "pmndrs_text_engine_dispose_policy", "fontCount": "pmndrs_text_shaper_font_count", "planCount": "pmndrs_text_shaper_plan_count", + "policyCount": "pmndrs_text_engine_policy_count", "registerFont": "pmndrs_text_shaper_register_font", + "registerPolicy": "pmndrs_text_engine_register_policy", "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", "resultLength": "pmndrs_text_shaper_result_len", "resultPointer": "pmndrs_text_shaper_result_ptr", @@ -75,6 +78,52 @@ export const textShaperAbi = { "tag": 0, "value": 4 }, + "policyBuffer": { + "alignment": 2, + "id": 0, + "scalar": 2, + "size": 4, + "vectorWidth": 3 + }, + "policyOperation": { + "alignment": 4, + "immediate0": 4, + "immediate1": 8, + "immediate2": 12, + "opcode": 0, + "operand0": 2, + "operand1": 3, + "size": 16, + "target": 1 + }, + "policyProgram": { + "alignment": 4, + "bufferCount": 24, + "bufferStart": 20, + "compositingCapabilities": 16, + "f32InputCount": 10, + "operationCount": 32, + "operationStart": 28, + "paintCapabilities": 12, + "programId": 4, + "reserved0": 26, + "reserved1": 34, + "size": 36, + "techniqueId": 0, + "u32InputCount": 11, + "variant": 8 + }, + "policyRequest": { + "alignment": 4, + "bufferCount": 16, + "buffersOffset": 12, + "byteLength": 0, + "operationCount": 24, + "operationsOffset": 20, + "programCount": 8, + "programsOffset": 4, + "size": 28 + }, "reshapeRange": { "alignment": 4, "contextEnd": 16, @@ -140,6 +189,28 @@ export const textShaperAbi = { "memory": "memory", "name": "pmndrs-text-shaper", "pointerWidth": 32, + "policy": { + "opcodes": { + "addF32": 5, + "constantF32": 3, + "constantU32": 4, + "convertU32ToF32": 10, + "lessThanF32": 8, + "loadF32": 1, + "loadU32": 2, + "multiplyF32": 7, + "selectF32": 9, + "storeF32": 11, + "storeU16": 13, + "storeU32": 12, + "subtractF32": 6 + }, + "scalarTypes": { + "f32": 1, + "u16": 3, + "u32": 2 + } + }, "status": { "fontMissing": 5, "handleConflict": 4, @@ -148,6 +219,8 @@ export const textShaperAbi = { "invalidHandle": 1, "invalidRequest": 6, "ok": 0, + "policyConflict": 8, + "policyMissing": 9, "resultTooLarge": 7 }, "version": 0, diff --git a/packages/text/tests/integration/render-policy-registration.test.mjs b/packages/text/tests/integration/render-policy-registration.test.mjs new file mode 100644 index 00000000..1090af7a --- /dev/null +++ b/packages/text/tests/integration/render-policy-registration.test.mjs @@ -0,0 +1,93 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('registers compiler-mapped render policies as retained typed Wasm state', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const module = await WebAssembly.compile(wasm); + const instance = await WebAssembly.instantiate(module, {}); + const memory = instance.exports[abi.memory]; + const allocate = instance.exports[abi.functions.allocate]; + const deallocate = instance.exports[abi.functions.deallocate]; + const register = instance.exports[abi.functions.registerPolicy]; + const dispose = instance.exports[abi.functions.disposePolicy]; + const count = instance.exports[abi.functions.policyCount]; + assert.ok(memory instanceof WebAssembly.Memory); + assert.equal(typeof allocate, 'function'); + assert.equal(typeof deallocate, 'function'); + assert.equal(typeof register, 'function'); + assert.equal(typeof dispose, 'function'); + assert.equal(typeof count, 'function'); + + const bytes = renderPolicyBytes(abi); + const pointer = allocate(bytes.byteLength); + assert.notEqual(pointer, 0); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + + assert.equal(count(), 0); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.ok); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.ok, 'identical registration is idempotent'); + assert.equal(count(), 1); + + const request = abi.layouts.policyRequest; + const program = abi.layouts.policyProgram; + const programsOffset = new DataView(bytes.buffer).getUint32(request.programsOffset, true); + new DataView(memory.buffer).setUint32(pointer + programsOffset + program.techniqueId, 2, true); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.policyConflict); + assert.equal(count(), 1); + + deallocate(pointer, bytes.byteLength); + assert.equal(count(), 1, 'validated policy state must not borrow the registration allocation'); + assert.equal(dispose(7), abi.status.ok); + assert.equal(dispose(7), abi.status.policyMissing); + assert.equal(count(), 0); + assert.equal(register(8, pointer, bytes.byteLength), abi.status.invalidRequest); +}); + +function renderPolicyBytes(abi) { + const request = abi.layouts.policyRequest; + const program = abi.layouts.policyProgram; + const buffer = abi.layouts.policyBuffer; + const operation = abi.layouts.policyOperation; + const programsOffset = align(request.size, program.alignment); + const buffersOffset = align(programsOffset + program.size, buffer.alignment); + const operationsOffset = align(buffersOffset + buffer.size, operation.alignment); + const operationCount = 2; + const bytes = new Uint8Array(operationsOffset + operation.size * operationCount); + const view = new DataView(bytes.buffer); + + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.programsOffset, programsOffset, true); + view.setUint32(request.programCount, 1, true); + view.setUint32(request.buffersOffset, buffersOffset, true); + view.setUint32(request.bufferCount, 1, true); + view.setUint32(request.operationsOffset, operationsOffset, true); + view.setUint32(request.operationCount, operationCount, true); + + view.setUint32(programsOffset + program.techniqueId, 1, true); + view.setUint32(programsOffset + program.programId, 1, true); + view.setUint8(programsOffset + program.f32InputCount, 1); + view.setUint16(programsOffset + program.bufferCount, 1, true); + view.setUint16(programsOffset + program.operationCount, operationCount, true); + + view.setUint16(buffersOffset + buffer.id, 1, true); + view.setUint8(buffersOffset + buffer.scalar, abi.policy.scalarTypes.f32); + view.setUint8(buffersOffset + buffer.vectorWidth, 1); + + view.setUint8(operationsOffset + operation.opcode, abi.policy.opcodes.loadF32); + view.setUint8(operationsOffset + operation.target, 0); + view.setUint8(operationsOffset + operation.operand0, 0); + + const storeOffset = operationsOffset + operation.size; + view.setUint8(storeOffset + operation.opcode, abi.policy.opcodes.storeF32); + view.setUint8(storeOffset + operation.operand0, 0); + view.setUint32(storeOffset + operation.immediate0, 1, true); + return bytes; +} + +function align(value, alignment) { + return Math.ceil(value / alignment) * alignment; +} From 1f41e42f8bac31037d4cea525192662eca1ddb72 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 05:19:56 -0400 Subject: [PATCH 004/128] feat(text): publish retained frame transactions --- docs/log.md | 19 + docs/packages/text.md | 22 +- packages/text/rust/shaper/src/abi_contract.rs | 420 +++++++++++++++++- packages/text/rust/shaper/src/engine/frame.rs | 56 +++ .../text/rust/shaper/src/engine/frame_wire.rs | 170 +++++++ packages/text/rust/shaper/src/engine/mod.rs | 7 + packages/text/rust/shaper/src/engine/state.rs | 155 ++++++- .../text/rust/shaper/src/engine/transport.rs | 372 ++++++++++++++++ packages/text/rust/shaper/src/lib.rs | 4 + packages/text/rust/shaper/src/wasm.rs | 187 +++++++- packages/text/rust/shaper/src/wire.rs | 2 +- .../text/src/generated/text-shaper-abi.ts | 87 +++- .../render-plan-frame-abi.test.mjs | 161 +++++++ .../render-policy-registration.test.mjs | 47 +- packages/text/tests/support/engine-abi.mjs | 75 ++++ 15 files changed, 1730 insertions(+), 54 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/frame.rs create mode 100644 packages/text/rust/shaper/src/engine/frame_wire.rs create mode 100644 packages/text/rust/shaper/src/engine/transport.rs create mode 100644 packages/text/tests/integration/render-plan-frame-abi.test.mjs create mode 100644 packages/text/tests/support/engine-abi.mjs diff --git a/docs/log.md b/docs/log.md index f10defb3..657f760c 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,25 @@ ## 2026-08-08 +- **Proved the retained A/B frame transaction in the optimized Wasm** — Added session lifecycle, cold reservation, one + retained 16-byte-aligned request arena, two retained 16-byte-aligned result arenas, explicit engine/plan/publication + revisions, base-revision checkpoints, and a compiler-derived 120-byte request plus 128-byte result header. The result + already reserves fixed semantic, resource, buffer, patch, primitive, draw, retirement, and diagnostic table fields; + unimplemented nonempty sections fail at the Rust boundary instead of falling back to host typography. Updates return + the selected result pointer in their single call. Success alternates slots, while malformed or stale-revision requests + write only the inactive slot and leave the active publication byte-identical. A real Node/Wasm proof observes zero + warm memory growth, then forces an 8 MiB cold reserve, observes the old fixed buffer detach, re-reads the aligned request + pointer, and disposes the session exactly. Twenty-five Rust unit tests, both complete Unicode 17 bidi suites, and the + optimized-Wasm policy/frame proofs pass. The frame shell grows the optimized shaper from 698,238 to 725,302 raw bytes, + from 260,228 to 269,438 gzip bytes, and from 203,760 to 210,867 Brotli bytes. The tables are still empty, so this proves + ownership and transaction semantics rather than shaping/layout performance. The still-unwired 25,515-glyph host path + remains within run variance at 54.42/12.15/8.31/38.98 millisecond cold/font-size/layout-width/text medians and + 70.38/14.48/11.31/40.89 millisecond p95 values. The package's 186 integration tests, six fuzz targets, 117 benchmark + application tests, 20/20 warmed headless conformance scenarios, and the 172,156-byte packed-consumer proof + (`af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b`) also pass. The aggregate benchmark script + still stops at its deliberately stale checked package-size snapshot; this stage records the new measured size instead + of rewriting unrelated historical evidence. + - **Registered fixed-layout render policies at the Rust/Wasm boundary** — Added compiler-derived `#[repr(C)]` policy headers and fixed-width program, buffer, and operation records to the existing generated shaper ABI. Registration performs one bounded direct-memory decode, rejects overlapping tables, forged lengths, nonzero reserved fields, diff --git a/docs/packages/text.md b/docs/packages/text.md index b450714e..9909c7da 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:cca140771c55745ae3b27ad5056c26301a87f5371a23cd99dd3ea0c12956d743' +source_digest: 'sha256:72c1c1f5b300b47900fba2ed6843e91e68f64fb401aa472b0c16ca02764e34de' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -384,6 +384,26 @@ and performance remain unclaimed until the retained update and executor land. Th remains within measurement variance: baseline-to-current cold/font-size/layout-width/text medians are 55.28→52.48 / 12.02→11.92 / 8.42→8.23 / 38.66→38.73 milliseconds. +The retained frame shell gives each engine session one 16-byte-aligned request arena and two 16-byte-aligned result +arenas. Cold creation and reservation may resize them; a warm update reads the already-pinned request and returns the +selected result pointer in that single call. The compiler-derived request header is 120 bytes. Its section offsets cover +text/style mutations, constraints, regions, exclusions, inline objects, and policy parameters; Stage 1 accepts only the +canonical empty transaction and rejects a nonempty section until its Rust consumer exists. The 128-byte aligned result +header already fixes revisions, base requirements, capacity watermarks, output slot and generation, plus semantic, +resource, physical-buffer, patch, primitive, draw, retirement, and diagnostic table locations. Successful publication +alternates A/B slots; a failed parse or revision check writes the inactive slot without advancing or modifying the active +publication. The real optimized-Wasm test proves that a warm update preserves `memory.buffer`, while an 8 MiB cold +reserve detaches the prior fixed buffer and requires re-reading the aligned request pointer. This shell measures 725,302 +raw / 269,438 gzip / 210,867 Brotli bytes, adding 27,064 / 9,210 / 7,107 bytes to the policy-registration checkpoint. +Semantic tables remain empty, so no shaping or layout performance claim is attached to this stage. The current +25,515-glyph TypeScript path remains within run variance at 54.42/12.15/8.31/38.98 millisecond +cold/font-size/layout-width/text medians and 70.38/14.48/11.31/40.89 millisecond p95 values. The package's 186 +integration tests and six fuzz targets pass, as do the benchmark application's 117 tests, 20/20 warmed headless +conformance scenarios, and 172,156-byte packed-consumer proof +(`af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b`). The aggregate benchmark script still stops +at its deliberately stale checked package-size snapshot; this stage records the actual measured size without rewriting +that unrelated historical evidence. + Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. The runtime repeats no parallel wire-format implementation. Bitmap and MTSDF renderers plus both standalone validators consume the same dependency-light KTX2 and dense-record rules; only the standalone layer imports Khronos/Ajv. The renderers also share the lossless-atlas adapter, unit quad, parallel-array checks, and resolved-paint lookup. The MTSDF resource uploads only its authenticated base levels into one padded texture array, samples them bilinearly, sizes reconstruction with screen derivatives, and owns one material per logical array; disposal releases materials and textures transactionally. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 69d0cfd3..44d71248 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -2,6 +2,7 @@ use alloc::string::{String, ToString}; use core::mem::{align_of, offset_of, size_of}; use serde_json::json; +use crate::engine::frame::RESULT_FLAG_CHECKPOINT; use crate::engine::policy::{ OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, @@ -87,6 +88,74 @@ struct PolicyOperationRecord { immediate2: u32, } +#[repr(C)] +struct EngineUpdateRequestHeader { + abi_version: u32, + byte_length: u32, + session_id: u32, + expected_engine_revision: u32, + consumed_plan_revision: u32, + policy_handle: u32, + capability_set: u32, + flags: u32, + semantic_view_mask: u32, + max_clusters: u32, + max_lines: u32, + max_regions: u32, + max_exclusions: u32, + max_inline_objects: u32, + max_slots_per_band: u32, + max_output_bytes: u32, + text_mutations_offset: u32, + text_mutation_count: u32, + style_mutations_offset: u32, + style_mutation_count: u32, + constraints_offset: u32, + constraint_count: u32, + regions_offset: u32, + region_count: u32, + exclusions_offset: u32, + exclusion_count: u32, + inline_objects_offset: u32, + inline_object_count: u32, + policy_parameters_offset: u32, + policy_parameters_length: u32, +} + +#[repr(C, align(16))] +struct EngineResultHeader { + abi_version: u32, + byte_length: u32, + status: u32, + flags: u32, + session_id: u32, + engine_revision: u32, + plan_revision: u32, + required_base_revision: u32, + publication_generation: u32, + output_slot: u32, + request_capacity: u32, + required_request_capacity: u32, + result_capacity: u32, + required_result_capacity: u32, + semantics_offset: u32, + semantics_count: u32, + resources_offset: u32, + resource_count: u32, + buffers_offset: u32, + buffer_count: u32, + patches_offset: u32, + patch_count: u32, + primitives_offset: u32, + primitive_count: u32, + draws_offset: u32, + draw_count: u32, + retirements_offset: u32, + retirement_count: u32, + diagnostics_offset: u32, + diagnostic_count: u32, +} + #[repr(C)] struct FeatureRecord { tag: u32, @@ -192,6 +261,16 @@ layout!( POLICY_OPERATION_RECORD_ALIGNMENT, PolicyOperationRecord ); +layout!( + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, + EngineUpdateRequestHeader +); +layout!( + ENGINE_RESULT_HEADER_SIZE, + ENGINE_RESULT_HEADER_ALIGNMENT, + EngineResultHeader +); layout!(FEATURE_RECORD_SIZE, FEATURE_RECORD_ALIGNMENT, FeatureRecord); layout!(RUN_RECORD_SIZE, RUN_RECORD_ALIGNMENT, RunRecord); layout!( @@ -307,6 +386,262 @@ field_offset!( PolicyOperationRecord, immediate2 ); +field_offset!( + ENGINE_UPDATE_ABI_VERSION, + EngineUpdateRequestHeader, + abi_version +); +field_offset!( + ENGINE_UPDATE_BYTE_LENGTH, + EngineUpdateRequestHeader, + byte_length +); +field_offset!( + ENGINE_UPDATE_SESSION_ID, + EngineUpdateRequestHeader, + session_id +); +field_offset!( + ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, + EngineUpdateRequestHeader, + expected_engine_revision +); +field_offset!( + ENGINE_UPDATE_CONSUMED_PLAN_REVISION, + EngineUpdateRequestHeader, + consumed_plan_revision +); +field_offset!( + ENGINE_UPDATE_POLICY_HANDLE, + EngineUpdateRequestHeader, + policy_handle +); +field_offset!( + ENGINE_UPDATE_CAPABILITY_SET, + EngineUpdateRequestHeader, + capability_set +); +field_offset!(ENGINE_UPDATE_FLAGS, EngineUpdateRequestHeader, flags); +field_offset!( + ENGINE_UPDATE_SEMANTIC_VIEW_MASK, + EngineUpdateRequestHeader, + semantic_view_mask +); +field_offset!( + ENGINE_UPDATE_MAX_CLUSTERS, + EngineUpdateRequestHeader, + max_clusters +); +field_offset!( + ENGINE_UPDATE_MAX_LINES, + EngineUpdateRequestHeader, + max_lines +); +field_offset!( + ENGINE_UPDATE_MAX_REGIONS, + EngineUpdateRequestHeader, + max_regions +); +field_offset!( + ENGINE_UPDATE_MAX_EXCLUSIONS, + EngineUpdateRequestHeader, + max_exclusions +); +field_offset!( + ENGINE_UPDATE_MAX_INLINE_OBJECTS, + EngineUpdateRequestHeader, + max_inline_objects +); +field_offset!( + ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + EngineUpdateRequestHeader, + max_slots_per_band +); +field_offset!( + ENGINE_UPDATE_MAX_OUTPUT_BYTES, + EngineUpdateRequestHeader, + max_output_bytes +); +field_offset!( + ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + EngineUpdateRequestHeader, + text_mutations_offset +); +field_offset!( + ENGINE_UPDATE_TEXT_MUTATION_COUNT, + EngineUpdateRequestHeader, + text_mutation_count +); +field_offset!( + ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, + EngineUpdateRequestHeader, + style_mutations_offset +); +field_offset!( + ENGINE_UPDATE_STYLE_MUTATION_COUNT, + EngineUpdateRequestHeader, + style_mutation_count +); +field_offset!( + ENGINE_UPDATE_CONSTRAINTS_OFFSET, + EngineUpdateRequestHeader, + constraints_offset +); +field_offset!( + ENGINE_UPDATE_CONSTRAINT_COUNT, + EngineUpdateRequestHeader, + constraint_count +); +field_offset!( + ENGINE_UPDATE_REGIONS_OFFSET, + EngineUpdateRequestHeader, + regions_offset +); +field_offset!( + ENGINE_UPDATE_REGION_COUNT, + EngineUpdateRequestHeader, + region_count +); +field_offset!( + ENGINE_UPDATE_EXCLUSIONS_OFFSET, + EngineUpdateRequestHeader, + exclusions_offset +); +field_offset!( + ENGINE_UPDATE_EXCLUSION_COUNT, + EngineUpdateRequestHeader, + exclusion_count +); +field_offset!( + ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, + EngineUpdateRequestHeader, + inline_objects_offset +); +field_offset!( + ENGINE_UPDATE_INLINE_OBJECT_COUNT, + EngineUpdateRequestHeader, + inline_object_count +); +field_offset!( + ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + EngineUpdateRequestHeader, + policy_parameters_offset +); +field_offset!( + ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + EngineUpdateRequestHeader, + policy_parameters_length +); +field_offset!(ENGINE_RESULT_ABI_VERSION, EngineResultHeader, abi_version); +field_offset!(ENGINE_RESULT_BYTE_LENGTH, EngineResultHeader, byte_length); +field_offset!(ENGINE_RESULT_STATUS, EngineResultHeader, status); +field_offset!(ENGINE_RESULT_FLAGS, EngineResultHeader, flags); +field_offset!(ENGINE_RESULT_SESSION_ID, EngineResultHeader, session_id); +field_offset!( + ENGINE_RESULT_ENGINE_REVISION, + EngineResultHeader, + engine_revision +); +field_offset!( + ENGINE_RESULT_PLAN_REVISION, + EngineResultHeader, + plan_revision +); +field_offset!( + ENGINE_RESULT_REQUIRED_BASE_REVISION, + EngineResultHeader, + required_base_revision +); +field_offset!( + ENGINE_RESULT_PUBLICATION_GENERATION, + EngineResultHeader, + publication_generation +); +field_offset!(ENGINE_RESULT_OUTPUT_SLOT, EngineResultHeader, output_slot); +field_offset!( + ENGINE_RESULT_REQUEST_CAPACITY, + EngineResultHeader, + request_capacity +); +field_offset!( + ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, + EngineResultHeader, + required_request_capacity +); +field_offset!( + ENGINE_RESULT_RESULT_CAPACITY, + EngineResultHeader, + result_capacity +); +field_offset!( + ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + EngineResultHeader, + required_result_capacity +); +field_offset!( + ENGINE_RESULT_SEMANTICS_OFFSET, + EngineResultHeader, + semantics_offset +); +field_offset!( + ENGINE_RESULT_SEMANTICS_COUNT, + EngineResultHeader, + semantics_count +); +field_offset!( + ENGINE_RESULT_RESOURCES_OFFSET, + EngineResultHeader, + resources_offset +); +field_offset!( + ENGINE_RESULT_RESOURCE_COUNT, + EngineResultHeader, + resource_count +); +field_offset!( + ENGINE_RESULT_BUFFERS_OFFSET, + EngineResultHeader, + buffers_offset +); +field_offset!(ENGINE_RESULT_BUFFER_COUNT, EngineResultHeader, buffer_count); +field_offset!( + ENGINE_RESULT_PATCHES_OFFSET, + EngineResultHeader, + patches_offset +); +field_offset!(ENGINE_RESULT_PATCH_COUNT, EngineResultHeader, patch_count); +field_offset!( + ENGINE_RESULT_PRIMITIVES_OFFSET, + EngineResultHeader, + primitives_offset +); +field_offset!( + ENGINE_RESULT_PRIMITIVE_COUNT, + EngineResultHeader, + primitive_count +); +field_offset!(ENGINE_RESULT_DRAWS_OFFSET, EngineResultHeader, draws_offset); +field_offset!(ENGINE_RESULT_DRAW_COUNT, EngineResultHeader, draw_count); +field_offset!( + ENGINE_RESULT_RETIREMENTS_OFFSET, + EngineResultHeader, + retirements_offset +); +field_offset!( + ENGINE_RESULT_RETIREMENT_COUNT, + EngineResultHeader, + retirement_count +); +field_offset!( + ENGINE_RESULT_DIAGNOSTICS_OFFSET, + EngineResultHeader, + diagnostics_offset +); +field_offset!( + ENGINE_RESULT_DIAGNOSTIC_COUNT, + EngineResultHeader, + diagnostic_count +); field_offset!(FEATURE_TAG, FeatureRecord, tag); field_offset!(FEATURE_VALUE, FeatureRecord, value); field_offset!(FEATURE_START, FeatureRecord, start); @@ -408,6 +743,13 @@ pub fn json() -> String { "registerPolicy": "pmndrs_text_engine_register_policy", "disposePolicy": "pmndrs_text_engine_dispose_policy", "policyCount": "pmndrs_text_engine_policy_count", + "createSession": "pmndrs_text_engine_create_session", + "reserveSession": "pmndrs_text_engine_reserve_session", + "disposeSession": "pmndrs_text_engine_dispose_session", + "sessionCount": "pmndrs_text_engine_session_count", + "requestPointer": "pmndrs_text_engine_request_ptr", + "requestCapacity": "pmndrs_text_engine_request_capacity", + "textUpdate": "pmndrs_text_engine_update", "shapeBatch": "pmndrs_text_shaper_shape_batch", "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", @@ -486,6 +828,74 @@ pub fn json() -> String { "immediate1": POLICY_OPERATION_IMMEDIATE1, "immediate2": POLICY_OPERATION_IMMEDIATE2 }, + "engineUpdateRequest": { + "size": ENGINE_UPDATE_REQUEST_HEADER_SIZE, + "alignment": ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, + "abiVersion": ENGINE_UPDATE_ABI_VERSION, + "byteLength": ENGINE_UPDATE_BYTE_LENGTH, + "sessionId": ENGINE_UPDATE_SESSION_ID, + "expectedEngineRevision": ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, + "consumedPlanRevision": ENGINE_UPDATE_CONSUMED_PLAN_REVISION, + "policyHandle": ENGINE_UPDATE_POLICY_HANDLE, + "capabilitySet": ENGINE_UPDATE_CAPABILITY_SET, + "flags": ENGINE_UPDATE_FLAGS, + "semanticViewMask": ENGINE_UPDATE_SEMANTIC_VIEW_MASK, + "maxClusters": ENGINE_UPDATE_MAX_CLUSTERS, + "maxLines": ENGINE_UPDATE_MAX_LINES, + "maxRegions": ENGINE_UPDATE_MAX_REGIONS, + "maxExclusions": ENGINE_UPDATE_MAX_EXCLUSIONS, + "maxInlineObjects": ENGINE_UPDATE_MAX_INLINE_OBJECTS, + "maxSlotsPerBand": ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + "maxOutputBytes": ENGINE_UPDATE_MAX_OUTPUT_BYTES, + "textMutationsOffset": ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + "textMutationCount": ENGINE_UPDATE_TEXT_MUTATION_COUNT, + "styleMutationsOffset": ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, + "styleMutationCount": ENGINE_UPDATE_STYLE_MUTATION_COUNT, + "constraintsOffset": ENGINE_UPDATE_CONSTRAINTS_OFFSET, + "constraintCount": ENGINE_UPDATE_CONSTRAINT_COUNT, + "regionsOffset": ENGINE_UPDATE_REGIONS_OFFSET, + "regionCount": ENGINE_UPDATE_REGION_COUNT, + "exclusionsOffset": ENGINE_UPDATE_EXCLUSIONS_OFFSET, + "exclusionCount": ENGINE_UPDATE_EXCLUSION_COUNT, + "inlineObjectsOffset": ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, + "inlineObjectCount": ENGINE_UPDATE_INLINE_OBJECT_COUNT, + "policyParametersOffset": ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + "policyParametersLength": ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH + }, + "engineResult": { + "size": ENGINE_RESULT_HEADER_SIZE, + "alignment": ENGINE_RESULT_HEADER_ALIGNMENT, + "abiVersion": ENGINE_RESULT_ABI_VERSION, + "byteLength": ENGINE_RESULT_BYTE_LENGTH, + "status": ENGINE_RESULT_STATUS, + "flags": ENGINE_RESULT_FLAGS, + "sessionId": ENGINE_RESULT_SESSION_ID, + "engineRevision": ENGINE_RESULT_ENGINE_REVISION, + "planRevision": ENGINE_RESULT_PLAN_REVISION, + "requiredBaseRevision": ENGINE_RESULT_REQUIRED_BASE_REVISION, + "publicationGeneration": ENGINE_RESULT_PUBLICATION_GENERATION, + "outputSlot": ENGINE_RESULT_OUTPUT_SLOT, + "requestCapacity": ENGINE_RESULT_REQUEST_CAPACITY, + "requiredRequestCapacity": ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, + "resultCapacity": ENGINE_RESULT_RESULT_CAPACITY, + "requiredResultCapacity": ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + "semanticsOffset": ENGINE_RESULT_SEMANTICS_OFFSET, + "semanticsCount": ENGINE_RESULT_SEMANTICS_COUNT, + "resourcesOffset": ENGINE_RESULT_RESOURCES_OFFSET, + "resourceCount": ENGINE_RESULT_RESOURCE_COUNT, + "buffersOffset": ENGINE_RESULT_BUFFERS_OFFSET, + "bufferCount": ENGINE_RESULT_BUFFER_COUNT, + "patchesOffset": ENGINE_RESULT_PATCHES_OFFSET, + "patchCount": ENGINE_RESULT_PATCH_COUNT, + "primitivesOffset": ENGINE_RESULT_PRIMITIVES_OFFSET, + "primitiveCount": ENGINE_RESULT_PRIMITIVE_COUNT, + "drawsOffset": ENGINE_RESULT_DRAWS_OFFSET, + "drawCount": ENGINE_RESULT_DRAW_COUNT, + "retirementsOffset": ENGINE_RESULT_RETIREMENTS_OFFSET, + "retirementCount": ENGINE_RESULT_RETIREMENT_COUNT, + "diagnosticsOffset": ENGINE_RESULT_DIAGNOSTICS_OFFSET, + "diagnosticCount": ENGINE_RESULT_DIAGNOSTIC_COUNT + }, "feature": { "size": FEATURE_RECORD_SIZE, "alignment": FEATURE_RECORD_ALIGNMENT, @@ -582,6 +992,11 @@ pub fn json() -> String { "storeU16": OP_STORE_U16 } }, + "engine": { + "resultFlags": { + "checkpoint": RESULT_FLAG_CHECKPOINT + } + }, "status": { "ok": 0, "invalidHandle": 1, @@ -592,7 +1007,10 @@ pub fn json() -> String { "invalidRequest": 6, "resultTooLarge": 7, "policyConflict": 8, - "policyMissing": 9 + "policyMissing": 9, + "sessionConflict": 10, + "sessionMissing": 11, + "revisionConflict": 12 } }) .to_string() diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs new file mode 100644 index 00000000..f7efa68e --- /dev/null +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -0,0 +1,56 @@ +pub(crate) const RESULT_FLAG_CHECKPOINT: u32 = 1; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct UpdateRequest { + pub session_id: u32, + pub expected_engine_revision: u32, + pub consumed_plan_revision: u32, + pub policy_handle: u32, + pub limits: UpdateLimits, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct UpdateLimits { + pub max_clusters: u32, + pub max_lines: u32, + pub max_regions: u32, + pub max_exclusions: u32, + pub max_inline_objects: u32, + pub max_slots_per_band: u32, + pub max_output_bytes: u32, +} + +impl UpdateLimits { + pub fn all_nonzero(self) -> bool { + self.max_clusters != 0 + && self.max_lines != 0 + && self.max_regions != 0 + && self.max_exclusions != 0 + && self.max_inline_objects != 0 + && self.max_slots_per_band != 0 + && self.max_output_bytes != 0 + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct SessionRevision { + pub engine: u32, + pub plan: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct PreparedUpdate { + pub(super) session_id: u32, + pub(super) previous: SessionRevision, + pub(super) next: SessionRevision, + pub(super) required_base_revision: u32, + pub(super) checkpoint: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct CommittedUpdate { + pub session_id: u32, + pub revision: SessionRevision, + pub required_base_revision: u32, + pub checkpoint: bool, +} diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs new file mode 100644 index 00000000..4f7ce80f --- /dev/null +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -0,0 +1,170 @@ +//! Direct-memory decoding for the retained engine update transaction. +//! +//! The compiler-derived offsets in `abi_contract` remain the sole layout authority. Stage 1 +//! accepts the complete fixed header but deliberately rejects nonempty mutation sections until +//! their Rust semantic consumers land; no request can silently fall back to TypeScript logic. + +use crate::{ + STATUS_INVALID_REQUEST, + abi_contract::{ + ABI_VERSION, ENGINE_RESULT_HEADER_SIZE, ENGINE_UPDATE_ABI_VERSION, + ENGINE_UPDATE_BYTE_LENGTH, ENGINE_UPDATE_CAPABILITY_SET, ENGINE_UPDATE_CONSTRAINT_COUNT, + ENGINE_UPDATE_CONSTRAINTS_OFFSET, ENGINE_UPDATE_CONSUMED_PLAN_REVISION, + ENGINE_UPDATE_EXCLUSION_COUNT, ENGINE_UPDATE_EXCLUSIONS_OFFSET, + ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, ENGINE_UPDATE_FLAGS, + ENGINE_UPDATE_INLINE_OBJECT_COUNT, ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, + ENGINE_UPDATE_MAX_CLUSTERS, ENGINE_UPDATE_MAX_EXCLUSIONS, ENGINE_UPDATE_MAX_INLINE_OBJECTS, + ENGINE_UPDATE_MAX_LINES, ENGINE_UPDATE_MAX_OUTPUT_BYTES, ENGINE_UPDATE_MAX_REGIONS, + ENGINE_UPDATE_MAX_SLOTS_PER_BAND, ENGINE_UPDATE_POLICY_HANDLE, + ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + ENGINE_UPDATE_REGION_COUNT, ENGINE_UPDATE_REGIONS_OFFSET, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, ENGINE_UPDATE_SEMANTIC_VIEW_MASK, + ENGINE_UPDATE_SESSION_ID, ENGINE_UPDATE_STYLE_MUTATION_COUNT, + ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, ENGINE_UPDATE_TEXT_MUTATION_COUNT, + ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + }, + engine::frame::{UpdateLimits, UpdateRequest}, + wire::read_u32, +}; + +const MAX_DECLARED_OUTPUT_BYTES: u32 = 64 * 1024 * 1024; + +pub(crate) fn parse_update_request(bytes: &[u8], session_id: u32) -> Result { + if bytes.len() < ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + || read_u32(bytes, ENGINE_UPDATE_ABI_VERSION)? != ABI_VERSION + || read_u32(bytes, ENGINE_UPDATE_SESSION_ID)? != session_id + || read_u32(bytes, ENGINE_UPDATE_BYTE_LENGTH)? + != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? + || read_u32(bytes, ENGINE_UPDATE_CAPABILITY_SET)? != 0 + || read_u32(bytes, ENGINE_UPDATE_FLAGS)? != 0 + || read_u32(bytes, ENGINE_UPDATE_SEMANTIC_VIEW_MASK)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + + for (offset, count) in [ + ( + ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + ENGINE_UPDATE_TEXT_MUTATION_COUNT, + ), + ( + ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, + ENGINE_UPDATE_STYLE_MUTATION_COUNT, + ), + ( + ENGINE_UPDATE_CONSTRAINTS_OFFSET, + ENGINE_UPDATE_CONSTRAINT_COUNT, + ), + (ENGINE_UPDATE_REGIONS_OFFSET, ENGINE_UPDATE_REGION_COUNT), + ( + ENGINE_UPDATE_EXCLUSIONS_OFFSET, + ENGINE_UPDATE_EXCLUSION_COUNT, + ), + ( + ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, + ENGINE_UPDATE_INLINE_OBJECT_COUNT, + ), + ( + ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + ), + ] { + if read_u32(bytes, offset)? != 0 || read_u32(bytes, count)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } + if bytes.len() != ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize { + return Err(STATUS_INVALID_REQUEST); + } + + let limits = UpdateLimits { + max_clusters: positive(bytes, ENGINE_UPDATE_MAX_CLUSTERS)?, + max_lines: positive(bytes, ENGINE_UPDATE_MAX_LINES)?, + max_regions: positive(bytes, ENGINE_UPDATE_MAX_REGIONS)?, + max_exclusions: positive(bytes, ENGINE_UPDATE_MAX_EXCLUSIONS)?, + max_inline_objects: positive(bytes, ENGINE_UPDATE_MAX_INLINE_OBJECTS)?, + max_slots_per_band: positive(bytes, ENGINE_UPDATE_MAX_SLOTS_PER_BAND)?, + max_output_bytes: read_u32(bytes, ENGINE_UPDATE_MAX_OUTPUT_BYTES)?, + }; + if !limits.all_nonzero() + || limits.max_output_bytes < ENGINE_RESULT_HEADER_SIZE + || limits.max_output_bytes > MAX_DECLARED_OUTPUT_BYTES + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(UpdateRequest { + session_id, + expected_engine_revision: read_u32(bytes, ENGINE_UPDATE_EXPECTED_ENGINE_REVISION)?, + consumed_plan_revision: read_u32(bytes, ENGINE_UPDATE_CONSUMED_PLAN_REVISION)?, + policy_handle: read_u32(bytes, ENGINE_UPDATE_POLICY_HANDLE)?, + limits, + }) +} + +fn positive(bytes: &[u8], offset: usize) -> Result { + let value = read_u32(bytes, offset)?; + if value == 0 { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::write_u32; + use alloc::vec; + + #[test] + fn accepts_only_the_canonical_empty_stage_one_transaction() { + let bytes = request(); + let parsed = parse_update_request(&bytes, 4).unwrap(); + assert_eq!(parsed.session_id, 4); + assert_eq!(parsed.policy_handle, 9); + + let mut trailing = bytes.clone(); + trailing.push(0); + let trailing_length = u32::try_from(trailing.len()).unwrap(); + write_u32(&mut trailing, ENGINE_UPDATE_BYTE_LENGTH, trailing_length); + assert_eq!( + parse_update_request(&trailing, 4), + Err(STATUS_INVALID_REQUEST) + ); + + let mut nonempty = bytes; + write_u32(&mut nonempty, ENGINE_UPDATE_REGION_COUNT, 1); + assert_eq!( + parse_update_request(&nonempty, 4), + Err(STATUS_INVALID_REQUEST) + ); + } + + pub(super) fn request() -> Vec { + let mut bytes = vec![0; ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize]; + write_u32(&mut bytes, ENGINE_UPDATE_ABI_VERSION, ABI_VERSION); + write_u32( + &mut bytes, + ENGINE_UPDATE_BYTE_LENGTH, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + ); + write_u32(&mut bytes, ENGINE_UPDATE_SESSION_ID, 4); + write_u32(&mut bytes, ENGINE_UPDATE_POLICY_HANDLE, 9); + for offset in [ + ENGINE_UPDATE_MAX_CLUSTERS, + ENGINE_UPDATE_MAX_LINES, + ENGINE_UPDATE_MAX_REGIONS, + ENGINE_UPDATE_MAX_EXCLUSIONS, + ENGINE_UPDATE_MAX_INLINE_OBJECTS, + ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + ] { + write_u32(&mut bytes, offset, 1); + } + write_u32( + &mut bytes, + ENGINE_UPDATE_MAX_OUTPUT_BYTES, + ENGINE_RESULT_HEADER_SIZE, + ); + bytes + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index f451eeda..b219ceb5 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -3,7 +3,14 @@ //! The public types in this module are available to native consumers. Wasm memory ownership and //! pointer validation stay in the target-gated transport module. +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod frame; +#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] +pub(crate) mod frame_wire; +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] mod state; +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod transport; pub mod policy; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index efca6c60..d27ea710 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1,17 +1,31 @@ use alloc::collections::BTreeMap; -use super::policy::ValidatedPolicy; +use super::{ + frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, + policy::ValidatedPolicy, +}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum EngineError { InvalidHandle, HandleConflict, PolicyMissing, + SessionConflict, + SessionMissing, + RevisionConflict, + RevisionExhausted, + InvalidRequest, } #[derive(Default)] pub struct TextEngine { policies: BTreeMap, + sessions: BTreeMap, +} + +#[derive(Default)] +struct EngineSession { + revision: SessionRevision, } impl TextEngine { @@ -48,6 +62,95 @@ impl TextEngine { pub fn policy_count(&self) -> u32 { self.policies.len().try_into().unwrap_or(u32::MAX) } + + pub fn create_session(&mut self, handle: u32) -> Result<(), EngineError> { + if handle == 0 { + return Err(EngineError::InvalidHandle); + } + if self.sessions.contains_key(&handle) { + return Err(EngineError::SessionConflict); + } + self.sessions.insert(handle, EngineSession::default()); + Ok(()) + } + + pub fn dispose_session(&mut self, handle: u32) -> Result<(), EngineError> { + self.sessions + .remove(&handle) + .map(|_| ()) + .ok_or(EngineError::SessionMissing) + } + + pub(crate) fn session_revision(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .map(|session| session.revision) + .ok_or(EngineError::SessionMissing) + } + + pub fn session_count(&self) -> u32 { + self.sessions.len().try_into().unwrap_or(u32::MAX) + } + + pub(crate) fn prepare_update( + &self, + request: UpdateRequest, + ) -> Result { + if !request.limits.all_nonzero() { + return Err(EngineError::InvalidRequest); + } + let session = self + .sessions + .get(&request.session_id) + .ok_or(EngineError::SessionMissing)?; + self.policy(request.policy_handle)?; + if request.expected_engine_revision != session.revision.engine + || request.consumed_plan_revision > session.revision.plan + { + return Err(EngineError::RevisionConflict); + } + let next = SessionRevision { + engine: session + .revision + .engine + .checked_add(1) + .ok_or(EngineError::RevisionExhausted)?, + plan: session + .revision + .plan + .checked_add(1) + .ok_or(EngineError::RevisionExhausted)?, + }; + let checkpoint = + session.revision.plan == 0 || request.consumed_plan_revision != session.revision.plan; + Ok(PreparedUpdate { + session_id: request.session_id, + previous: session.revision, + next, + required_base_revision: if checkpoint { 0 } else { session.revision.plan }, + checkpoint, + }) + } + + pub(crate) fn commit_update( + &mut self, + prepared: PreparedUpdate, + ) -> Result { + let session = self + .sessions + .get_mut(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + session.revision = prepared.next; + Ok(CommittedUpdate { + session_id: prepared.session_id, + revision: prepared.next, + required_base_revision: prepared.required_base_revision, + checkpoint: prepared.checkpoint, + }) + } } #[cfg(test)] @@ -91,6 +194,38 @@ mod tests { assert_eq!(engine.dispose_policy(1), Err(EngineError::PolicyMissing)); } + #[test] + fn update_preparation_is_revisioned_and_commit_is_explicit() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + + let first = engine.prepare_update(update(0, 0)).unwrap(); + assert_eq!( + engine.session_revision(4).unwrap(), + SessionRevision::default() + ); + let first = engine.commit_update(first).unwrap(); + assert!(first.checkpoint); + assert_eq!(first.required_base_revision, 0); + assert_eq!(first.revision, SessionRevision { engine: 1, plan: 1 }); + + let second = engine.prepare_update(update(1, 1)).unwrap(); + let second = engine.commit_update(second).unwrap(); + assert!(!second.checkpoint); + assert_eq!(second.required_base_revision, 1); + + assert_eq!( + engine.prepare_update(update(1, 2)), + Err(EngineError::RevisionConflict) + ); + assert_eq!(engine.session_count(), 1); + assert_eq!(engine.dispose_session(4), Ok(())); + assert_eq!(engine.dispose_session(4), Err(EngineError::SessionMissing)); + } + fn validated_policy(technique: TechniqueId) -> ValidatedPolicy { ValidatedPolicy::new(PolicyDescriptor { programs: vec![ProgramDescriptor { @@ -120,4 +255,22 @@ mod tests { }) .unwrap() } + + fn update(expected_engine_revision: u32, consumed_plan_revision: u32) -> UpdateRequest { + UpdateRequest { + session_id: 4, + expected_engine_revision, + consumed_plan_revision, + policy_handle: 9, + limits: super::super::frame::UpdateLimits { + max_clusters: 1, + max_lines: 1, + max_regions: 1, + max_exclusions: 1, + max_inline_objects: 1, + max_slots_per_band: 1, + max_output_bytes: 128, + }, + } + } } diff --git a/packages/text/rust/shaper/src/engine/transport.rs b/packages/text/rust/shaper/src/engine/transport.rs new file mode 100644 index 00000000..c2096076 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/transport.rs @@ -0,0 +1,372 @@ +use alloc::vec::Vec; +use core::slice; + +use crate::{ + STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, + abi_contract::{ + ABI_VERSION, ENGINE_RESULT_ABI_VERSION, ENGINE_RESULT_BUFFER_COUNT, + ENGINE_RESULT_BUFFERS_OFFSET, ENGINE_RESULT_BYTE_LENGTH, ENGINE_RESULT_DIAGNOSTIC_COUNT, + ENGINE_RESULT_DIAGNOSTICS_OFFSET, ENGINE_RESULT_DRAW_COUNT, ENGINE_RESULT_DRAWS_OFFSET, + ENGINE_RESULT_ENGINE_REVISION, ENGINE_RESULT_FLAGS, ENGINE_RESULT_HEADER_ALIGNMENT, + ENGINE_RESULT_HEADER_SIZE, ENGINE_RESULT_OUTPUT_SLOT, ENGINE_RESULT_PATCH_COUNT, + ENGINE_RESULT_PATCHES_OFFSET, ENGINE_RESULT_PLAN_REVISION, ENGINE_RESULT_PRIMITIVE_COUNT, + ENGINE_RESULT_PRIMITIVES_OFFSET, ENGINE_RESULT_PUBLICATION_GENERATION, + ENGINE_RESULT_REQUEST_CAPACITY, ENGINE_RESULT_REQUIRED_BASE_REVISION, + ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + ENGINE_RESULT_RESOURCE_COUNT, ENGINE_RESULT_RESOURCES_OFFSET, + ENGINE_RESULT_RESULT_CAPACITY, ENGINE_RESULT_RETIREMENT_COUNT, + ENGINE_RESULT_RETIREMENTS_OFFSET, ENGINE_RESULT_SEMANTICS_COUNT, + ENGINE_RESULT_SEMANTICS_OFFSET, ENGINE_RESULT_SESSION_ID, ENGINE_RESULT_STATUS, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + }, + engine::frame::{CommittedUpdate, RESULT_FLAG_CHECKPOINT, SessionRevision}, + wire::write_u32, +}; + +const ARENA_ALIGNMENT: usize = 16; +const MAX_ARENA_BYTES: u32 = 64 * 1024 * 1024; + +#[repr(C, align(16))] +#[derive(Clone, Copy)] +struct ArenaBlock([u8; ARENA_ALIGNMENT]); + +pub(crate) struct FrameTransport { + request: AlignedArena, + outputs: [AlignedArena; 2], + active_slot: Option, + publication_generation: u32, +} + +impl FrameTransport { + pub fn new(request_capacity: u32, result_capacity: u32) -> Result { + if request_capacity < ENGINE_UPDATE_REQUEST_HEADER_SIZE + || result_capacity < ENGINE_RESULT_HEADER_SIZE + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(Self { + request: AlignedArena::new(request_capacity)?, + outputs: [ + AlignedArena::new(result_capacity)?, + AlignedArena::new(result_capacity)?, + ], + active_slot: None, + publication_generation: 0, + }) + } + + pub fn reserve(&mut self, request_capacity: u32, result_capacity: u32) -> Result<(), u32> { + if request_capacity < ENGINE_UPDATE_REQUEST_HEADER_SIZE + || result_capacity < ENGINE_RESULT_HEADER_SIZE + { + return Err(STATUS_INVALID_REQUEST); + } + self.request.reserve(request_capacity)?; + self.outputs[0].reserve(result_capacity)?; + self.outputs[1].reserve(result_capacity) + } + + pub fn request_pointer(&self) -> usize { + self.request.pointer() + } + + pub fn request_capacity(&self) -> u32 { + self.request.capacity() + } + + pub fn result_capacity(&self) -> u32 { + self.outputs[0].capacity().min(self.outputs[1].capacity()) + } + + pub fn request_at(&self, pointer: usize, length: u32) -> Result<&[u8], u32> { + if pointer != self.request.pointer() || length > self.request.capacity() { + return Err(STATUS_INVALID_REQUEST); + } + self.request + .bytes() + .get(..usize::try_from(length).map_err(|_| STATUS_INVALID_REQUEST)?) + .ok_or(STATUS_INVALID_REQUEST) + } + + pub fn ensure_publish_capacity(&self, byte_length: u32) -> Result<(), u32> { + if byte_length <= self.result_capacity() { + Ok(()) + } else { + Err(STATUS_RESULT_TOO_LARGE) + } + } + + pub fn next_publication_generation(&self) -> Result { + self.publication_generation + .checked_add(1) + .ok_or(STATUS_RESULT_TOO_LARGE) + } + + pub fn publish_success(&mut self, commit: CommittedUpdate) -> usize { + let slot = self.inactive_slot(); + let generation = self.publication_generation + 1; + self.write_header( + slot, + HeaderValues { + status: 0, + flags: if commit.checkpoint { + RESULT_FLAG_CHECKPOINT + } else { + 0 + }, + session_id: commit.session_id, + revision: commit.revision, + required_base_revision: commit.required_base_revision, + publication_generation: generation, + required_request_capacity: 0, + required_result_capacity: 0, + }, + ); + self.active_slot = Some(slot); + self.publication_generation = generation; + self.outputs[slot].pointer() + } + + pub fn publish_failure( + &mut self, + session_id: u32, + revision: SessionRevision, + status: u32, + required_request_capacity: u32, + required_result_capacity: u32, + ) -> usize { + let slot = self.inactive_slot(); + self.write_header( + slot, + HeaderValues { + status, + flags: 0, + session_id, + revision, + required_base_revision: revision.plan, + publication_generation: self.publication_generation, + required_request_capacity, + required_result_capacity, + }, + ); + self.outputs[slot].pointer() + } + + fn inactive_slot(&self) -> usize { + self.active_slot.map_or(0, |slot| slot ^ 1) + } + + fn write_header(&mut self, slot: usize, values: HeaderValues) { + let result_capacity = self.outputs[slot].capacity(); + let request_capacity = self.request.capacity(); + let bytes = self.outputs[slot].bytes_mut(); + bytes[..ENGINE_RESULT_HEADER_SIZE as usize].fill(0); + write_u32(bytes, ENGINE_RESULT_ABI_VERSION, ABI_VERSION); + write_u32(bytes, ENGINE_RESULT_BYTE_LENGTH, ENGINE_RESULT_HEADER_SIZE); + write_u32(bytes, ENGINE_RESULT_STATUS, values.status); + write_u32(bytes, ENGINE_RESULT_FLAGS, values.flags); + write_u32(bytes, ENGINE_RESULT_SESSION_ID, values.session_id); + write_u32(bytes, ENGINE_RESULT_ENGINE_REVISION, values.revision.engine); + write_u32(bytes, ENGINE_RESULT_PLAN_REVISION, values.revision.plan); + write_u32( + bytes, + ENGINE_RESULT_REQUIRED_BASE_REVISION, + values.required_base_revision, + ); + write_u32( + bytes, + ENGINE_RESULT_PUBLICATION_GENERATION, + values.publication_generation, + ); + write_u32(bytes, ENGINE_RESULT_OUTPUT_SLOT, [0, 1][slot]); + write_u32(bytes, ENGINE_RESULT_REQUEST_CAPACITY, request_capacity); + write_u32( + bytes, + ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, + values.required_request_capacity, + ); + write_u32(bytes, ENGINE_RESULT_RESULT_CAPACITY, result_capacity); + write_u32( + bytes, + ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + values.required_result_capacity, + ); + for offset in [ + ENGINE_RESULT_SEMANTICS_OFFSET, + ENGINE_RESULT_SEMANTICS_COUNT, + ENGINE_RESULT_RESOURCES_OFFSET, + ENGINE_RESULT_RESOURCE_COUNT, + ENGINE_RESULT_BUFFERS_OFFSET, + ENGINE_RESULT_BUFFER_COUNT, + ENGINE_RESULT_PATCHES_OFFSET, + ENGINE_RESULT_PATCH_COUNT, + ENGINE_RESULT_PRIMITIVES_OFFSET, + ENGINE_RESULT_PRIMITIVE_COUNT, + ENGINE_RESULT_DRAWS_OFFSET, + ENGINE_RESULT_DRAW_COUNT, + ENGINE_RESULT_RETIREMENTS_OFFSET, + ENGINE_RESULT_RETIREMENT_COUNT, + ENGINE_RESULT_DIAGNOSTICS_OFFSET, + ENGINE_RESULT_DIAGNOSTIC_COUNT, + ] { + write_u32(bytes, offset, 0); + } + } +} + +struct HeaderValues { + status: u32, + flags: u32, + session_id: u32, + revision: SessionRevision, + required_base_revision: u32, + publication_generation: u32, + required_request_capacity: u32, + required_result_capacity: u32, +} + +struct AlignedArena { + blocks: Vec, +} + +impl AlignedArena { + fn new(required: u32) -> Result { + let mut arena = Self { blocks: Vec::new() }; + arena.reserve(required)?; + Ok(arena) + } + + fn reserve(&mut self, required: u32) -> Result<(), u32> { + let required = aligned_capacity(required)?; + let current = self.capacity(); + if required <= current { + return Ok(()); + } + let doubled = current.checked_mul(2).unwrap_or(MAX_ARENA_BYTES); + let target = required.max(doubled).min(MAX_ARENA_BYTES); + if target < required { + return Err(STATUS_RESULT_TOO_LARGE); + } + let target_blocks = + usize::try_from(target).map_err(|_| STATUS_RESULT_TOO_LARGE)? / ARENA_ALIGNMENT; + self.blocks + .try_reserve_exact(target_blocks - self.blocks.len()) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + self.blocks + .resize(target_blocks, ArenaBlock([0; ARENA_ALIGNMENT])); + Ok(()) + } + + fn capacity(&self) -> u32 { + u32::try_from(self.blocks.len() * ARENA_ALIGNMENT).unwrap_or(MAX_ARENA_BYTES) + } + + fn pointer(&self) -> usize { + self.blocks.as_ptr() as usize + } + + fn bytes(&self) -> &[u8] { + // SAFETY: `ArenaBlock` is exactly 16 initialized bytes with no padding, and the returned + // slice shares the lifetime and immutability of the source block slice. + unsafe { + slice::from_raw_parts( + self.blocks.as_ptr().cast::(), + self.blocks.len() * ARENA_ALIGNMENT, + ) + } + } + + fn bytes_mut(&mut self) -> &mut [u8] { + // SAFETY: `ArenaBlock` is exactly 16 initialized bytes with no padding, and the returned + // slice is the sole mutable borrow of the source block slice. + unsafe { + slice::from_raw_parts_mut( + self.blocks.as_mut_ptr().cast::(), + self.blocks.len() * ARENA_ALIGNMENT, + ) + } + } +} + +fn aligned_capacity(required: u32) -> Result { + if required > MAX_ARENA_BYTES { + return Err(STATUS_RESULT_TOO_LARGE); + } + required + .checked_add((ARENA_ALIGNMENT - 1) as u32) + .map(|value| value & !((ARENA_ALIGNMENT - 1) as u32)) + .filter(|value| *value <= MAX_ARENA_BYTES) + .ok_or(STATUS_RESULT_TOO_LARGE) +} + +const _: () = assert!(core::mem::size_of::() == ARENA_ALIGNMENT); +const _: () = assert!(core::mem::align_of::() == ARENA_ALIGNMENT); +const _: () = assert!(ENGINE_RESULT_HEADER_ALIGNMENT as usize == ARENA_ALIGNMENT); + +#[cfg(test)] +mod tests { + use super::*; + use crate::wire::read_u32; + + #[test] + fn arenas_are_aligned_and_double_without_losing_request_bytes() { + let mut transport = + FrameTransport::new(ENGINE_UPDATE_REQUEST_HEADER_SIZE, ENGINE_RESULT_HEADER_SIZE) + .unwrap(); + assert_eq!(transport.request_pointer() % ARENA_ALIGNMENT, 0); + transport.request.bytes_mut()[0] = 7; + let initial = transport.request_capacity(); + transport + .reserve(initial + 1, ENGINE_RESULT_HEADER_SIZE) + .unwrap(); + assert_eq!(transport.request_capacity(), initial * 2); + assert_eq!(transport.request.bytes()[0], 7); + } + + #[test] + fn successful_publications_alternate_and_failures_preserve_the_active_slot() { + let mut transport = FrameTransport::new(256, 256).unwrap(); + let first = transport.publish_success(commit(1)); + let first_bytes = transport.outputs[0].bytes(); + assert_eq!(read_u32(first_bytes, ENGINE_RESULT_OUTPUT_SLOT).unwrap(), 0); + assert_eq!( + read_u32(first_bytes, ENGINE_RESULT_PUBLICATION_GENERATION).unwrap(), + 1 + ); + + let failure = transport.publish_failure( + 3, + SessionRevision { engine: 1, plan: 1 }, + STATUS_INVALID_REQUEST, + 512, + 0, + ); + assert_ne!(failure, first); + assert_eq!(transport.active_slot, Some(0)); + assert_eq!(transport.publication_generation, 1); + + let second = transport.publish_success(commit(2)); + assert_eq!(second, failure); + let second_bytes = transport.outputs[1].bytes(); + assert_eq!( + read_u32(second_bytes, ENGINE_RESULT_OUTPUT_SLOT).unwrap(), + 1 + ); + assert_eq!( + read_u32(second_bytes, ENGINE_RESULT_PUBLICATION_GENERATION).unwrap(), + 2 + ); + } + + fn commit(revision: u32) -> CommittedUpdate { + CommittedUpdate { + session_id: 3, + revision: SessionRevision { + engine: revision, + plan: revision, + }, + required_base_revision: revision - 1, + checkpoint: revision == 1, + } + } +} diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 171ba86b..0bbefc9a 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -1,4 +1,5 @@ #![cfg_attr(not(feature = "std"), no_std)] +#![recursion_limit = "256"] extern crate alloc; @@ -29,6 +30,9 @@ pub const STATUS_INVALID_REQUEST: u32 = 6; pub const STATUS_RESULT_TOO_LARGE: u32 = 7; pub const STATUS_POLICY_CONFLICT: u32 = 8; pub const STATUS_POLICY_MISSING: u32 = 9; +pub const STATUS_SESSION_CONFLICT: u32 = 10; +pub const STATUS_SESSION_MISSING: u32 = 11; +pub const STATUS_REVISION_CONFLICT: u32 = 12; const BUFFER_FLAGS_MASK: u32 = 0xff; const MAX_CACHED_PLANS_PER_FONT: usize = 64; diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 52b18864..e394c302 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -1,10 +1,16 @@ -use alloc::{boxed::Box, vec::Vec}; +use alloc::{boxed::Box, collections::BTreeMap, vec::Vec}; use core::sync::atomic::{AtomicUsize, Ordering}; use crate::{ STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, STATUS_POLICY_CONFLICT, STATUS_POLICY_MISSING, - STATUS_RESULT_TOO_LARGE, ShaperRegistry, bidi, - engine::{EngineError, TextEngine, wire::parse_policy}, + STATUS_RESULT_TOO_LARGE, STATUS_REVISION_CONFLICT, STATUS_SESSION_CONFLICT, + STATUS_SESSION_MISSING, ShaperRegistry, + abi_contract::ENGINE_RESULT_HEADER_SIZE, + bidi, + engine::{ + EngineError, TextEngine, frame::SessionRevision, frame_wire::parse_update_request, + transport::FrameTransport, wire::parse_policy, + }, wire::{ pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, parse_shape_request, @@ -124,6 +130,151 @@ pub extern "C" fn pmndrs_text_engine_policy_count() -> u32 { with_state(|state| state.engine.policy_count()) } +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_create_session( + handle: u32, + request_capacity: u32, + result_capacity: u32, +) -> u32 { + with_state(|state| { + if handle == 0 { + return STATUS_INVALID_HANDLE; + } + if state.frames.contains_key(&handle) { + return STATUS_SESSION_CONFLICT; + } + let transport = match FrameTransport::new(request_capacity, result_capacity) { + Ok(transport) => transport, + Err(status) => return status, + }; + if let Err(error) = state.engine.create_session(handle) { + return engine_status(error); + } + state.frames.insert(handle, transport); + 0 + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_reserve_session( + handle: u32, + request_capacity: u32, + result_capacity: u32, +) -> u32 { + with_state(|state| { + let Some(transport) = state.frames.get_mut(&handle) else { + return STATUS_SESSION_MISSING; + }; + match transport.reserve(request_capacity, result_capacity) { + Ok(()) => 0, + Err(status) => status, + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_dispose_session(handle: u32) -> u32 { + with_state(|state| { + if !state.frames.contains_key(&handle) { + return STATUS_SESSION_MISSING; + } + if let Err(error) = state.engine.dispose_session(handle) { + return engine_status(error); + } + state.frames.remove(&handle); + 0 + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_session_count() -> u32 { + with_state(|state| state.engine.session_count()) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_request_ptr(handle: u32) -> u32 { + with_state(|state| { + state + .frames + .get(&handle) + .and_then(|transport| u32::try_from(transport.request_pointer()).ok()) + .unwrap_or(0) + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_request_capacity(handle: u32) -> u32 { + with_state(|state| { + state + .frames + .get(&handle) + .map_or(0, FrameTransport::request_capacity) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_engine_update( + session_id: u32, + request_offset: u32, + request_len: u32, +) -> u32 { + with_state(|state| { + let revision = match state.engine.session_revision(session_id) { + Ok(revision) => revision, + Err(_) => return 0, + }; + let request = { + let Some(transport) = state.frames.get(&session_id) else { + return 0; + }; + let bytes = match transport.request_at(request_offset as usize, request_len) { + Ok(bytes) => bytes, + Err(status) => { + return publish_failure(state, session_id, revision, status, request_len, 0); + } + }; + match parse_update_request(bytes, session_id) { + Ok(request) => request, + Err(status) => { + return publish_failure(state, session_id, revision, status, 0, 0); + } + } + }; + let prepared = match state.engine.prepare_update(request) { + Ok(prepared) => prepared, + Err(error) => { + return publish_failure(state, session_id, revision, engine_status(error), 0, 0); + } + }; + let Some(transport) = state.frames.get(&session_id) else { + return 0; + }; + if let Err(status) = transport.ensure_publish_capacity(ENGINE_RESULT_HEADER_SIZE) { + return publish_failure( + state, + session_id, + revision, + status, + 0, + ENGINE_RESULT_HEADER_SIZE, + ); + } + if let Err(status) = transport.next_publication_generation() { + return publish_failure(state, session_id, revision, status, 0, 0); + } + let commit = match state.engine.commit_update(prepared) { + Ok(commit) => commit, + Err(error) => { + return publish_failure(state, session_id, revision, engine_status(error), 0, 0); + } + }; + let Some(transport) = state.frames.get_mut(&session_id) else { + return 0; + }; + u32::try_from(transport.publish_success(commit)).unwrap_or(0) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn pmndrs_text_shaper_shape_batch(pointer: u32, length: u32) -> u32 { with_state(|state| { @@ -205,6 +356,7 @@ pub extern "C" fn pmndrs_text_shaper_result_len() -> u32 { struct WasmState { registry: ShaperRegistry, engine: TextEngine, + frames: BTreeMap, allocations: Vec, } @@ -282,9 +434,38 @@ fn engine_status(error: EngineError) -> u32 { EngineError::InvalidHandle => STATUS_INVALID_HANDLE, EngineError::HandleConflict => STATUS_POLICY_CONFLICT, EngineError::PolicyMissing => STATUS_POLICY_MISSING, + EngineError::SessionConflict => STATUS_SESSION_CONFLICT, + EngineError::SessionMissing => STATUS_SESSION_MISSING, + EngineError::RevisionConflict => STATUS_REVISION_CONFLICT, + EngineError::RevisionExhausted => STATUS_RESULT_TOO_LARGE, + EngineError::InvalidRequest => STATUS_INVALID_REQUEST, } } +fn publish_failure( + state: &mut WasmState, + session_id: u32, + revision: SessionRevision, + status: u32, + required_request_capacity: u32, + required_result_capacity: u32, +) -> u32 { + state + .frames + .get_mut(&session_id) + .and_then(|transport| { + u32::try_from(transport.publish_failure( + session_id, + revision, + status, + required_request_capacity, + required_result_capacity, + )) + .ok() + }) + .unwrap_or(0) +} + fn with_state(operation: impl FnOnce(&mut WasmState) -> Result) -> Result { let mut pointer = STATE.load(Ordering::Acquire); if pointer == 0 { diff --git a/packages/text/rust/shaper/src/wire.rs b/packages/text/rust/shaper/src/wire.rs index 94eb2fc6..5a02a334 100644 --- a/packages/text/rust/shaper/src/wire.rs +++ b/packages/text/rust/shaper/src/wire.rs @@ -350,7 +350,7 @@ pub(crate) fn read_u32(bytes: &[u8], offset: usize) -> Result { Ok(u32::from_le_bytes([value[0], value[1], value[2], value[3]])) } -fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { +pub(crate) fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 08bec9a4..3186d679 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -33,22 +33,34 @@ export const textShaperAbi = { } }, "endianness": "little", + "engine": { + "resultFlags": { + "checkpoint": 1 + } + }, "functions": { "allocate": "pmndrs_text_shaper_alloc", "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", + "createSession": "pmndrs_text_engine_create_session", "deallocate": "pmndrs_text_shaper_dealloc", "disposeFont": "pmndrs_text_shaper_dispose_font", "disposePolicy": "pmndrs_text_engine_dispose_policy", + "disposeSession": "pmndrs_text_engine_dispose_session", "fontCount": "pmndrs_text_shaper_font_count", "planCount": "pmndrs_text_shaper_plan_count", "policyCount": "pmndrs_text_engine_policy_count", "registerFont": "pmndrs_text_shaper_register_font", "registerPolicy": "pmndrs_text_engine_register_policy", + "requestCapacity": "pmndrs_text_engine_request_capacity", + "requestPointer": "pmndrs_text_engine_request_ptr", + "reserveSession": "pmndrs_text_engine_reserve_session", "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", "resultLength": "pmndrs_text_shaper_result_len", "resultPointer": "pmndrs_text_shaper_result_ptr", "retainedFontBytes": "pmndrs_text_shaper_retained_font_bytes", - "shapeBatch": "pmndrs_text_shaper_shape_batch" + "sessionCount": "pmndrs_text_engine_session_count", + "shapeBatch": "pmndrs_text_shaper_shape_batch", + "textUpdate": "pmndrs_text_engine_update" }, "layouts": { "bidiRequest": { @@ -70,6 +82,74 @@ export const textShaperAbi = { "size": 32, "textLength": 12 }, + "engineResult": { + "abiVersion": 0, + "alignment": 16, + "bufferCount": 76, + "buffersOffset": 72, + "byteLength": 4, + "diagnosticCount": 116, + "diagnosticsOffset": 112, + "drawCount": 100, + "drawsOffset": 96, + "engineRevision": 20, + "flags": 12, + "outputSlot": 36, + "patchCount": 84, + "patchesOffset": 80, + "planRevision": 24, + "primitiveCount": 92, + "primitivesOffset": 88, + "publicationGeneration": 32, + "requestCapacity": 40, + "requiredBaseRevision": 28, + "requiredRequestCapacity": 44, + "requiredResultCapacity": 52, + "resourceCount": 68, + "resourcesOffset": 64, + "resultCapacity": 48, + "retirementCount": 108, + "retirementsOffset": 104, + "semanticsCount": 60, + "semanticsOffset": 56, + "sessionId": 16, + "size": 128, + "status": 8 + }, + "engineUpdateRequest": { + "abiVersion": 0, + "alignment": 4, + "byteLength": 4, + "capabilitySet": 24, + "constraintCount": 84, + "constraintsOffset": 80, + "consumedPlanRevision": 16, + "exclusionCount": 100, + "exclusionsOffset": 96, + "expectedEngineRevision": 12, + "flags": 28, + "inlineObjectCount": 108, + "inlineObjectsOffset": 104, + "maxClusters": 36, + "maxExclusions": 48, + "maxInlineObjects": 52, + "maxLines": 40, + "maxOutputBytes": 60, + "maxRegions": 44, + "maxSlotsPerBand": 56, + "policyHandle": 20, + "policyParametersLength": 116, + "policyParametersOffset": 112, + "regionCount": 92, + "regionsOffset": 88, + "semanticViewMask": 32, + "sessionId": 8, + "size": 120, + "styleMutationCount": 76, + "styleMutationsOffset": 72, + "textMutationCount": 68, + "textMutationsOffset": 64 + }, "feature": { "alignment": 4, "end": 12, @@ -221,7 +301,10 @@ export const textShaperAbi = { "ok": 0, "policyConflict": 8, "policyMissing": 9, - "resultTooLarge": 7 + "resultTooLarge": 7, + "revisionConflict": 12, + "sessionConflict": 10, + "sessionMissing": 11 }, "version": 0, "versions": { diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs new file mode 100644 index 00000000..545a77ec --- /dev/null +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { copyIntoAllocation, engineUpdateBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); +const sessionId = 5; +const policyHandle = 11; + +test('publishes retained frame transactions through aligned A/B Wasm arenas', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const instance = await WebAssembly.instantiate(await WebAssembly.compile(wasm), {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), + ); + assert.ok(memory instanceof WebAssembly.Memory); + + const policy = renderPolicyBytes(abi); + const policyPointer = copyIntoAllocation(memory, fn.allocate, policy); + assert.equal(fn.registerPolicy(policyHandle, policyPointer, policy.byteLength), abi.status.ok); + fn.deallocate(policyPointer, policy.byteLength); + + const requestLayout = abi.layouts.engineUpdateRequest; + const resultLayout = abi.layouts.engineResult; + assert.equal(resultLayout.size, 128); + assert.equal(resultLayout.alignment, 16); + assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size), abi.status.ok); + assert.equal(fn.sessionCount(), 1); + let requestPointer = fn.requestPointer(sessionId); + assert.notEqual(requestPointer, 0); + assert.equal(requestPointer % 16, 0); + assert.ok(fn.requestCapacity(sessionId) >= requestLayout.size); + + writeRequest(memory, requestPointer, abi, 0, 0); + const firstPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assert.notEqual(firstPointer, 0); + assert.equal(firstPointer % resultLayout.alignment, 0); + assertResult(memory, firstPointer, abi, { + status: abi.status.ok, + engineRevision: 1, + planRevision: 1, + requiredBaseRevision: 0, + publicationGeneration: 1, + outputSlot: 0, + flags: abi.engine.resultFlags.checkpoint, + }); + const firstHeader = resultBytes(memory, firstPointer, resultLayout).slice(); + + const warmBuffer = memory.buffer; + writeRequest(memory, requestPointer, abi, 1, 1); + const secondPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assert.strictEqual(memory.buffer, warmBuffer, 'a warm empty transaction must not grow Wasm memory'); + assert.notEqual(secondPointer, firstPointer); + assertResult(memory, secondPointer, abi, { + status: abi.status.ok, + engineRevision: 2, + planRevision: 2, + requiredBaseRevision: 1, + publicationGeneration: 2, + outputSlot: 1, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, firstPointer, resultLayout), firstHeader); + const secondHeader = resultBytes(memory, secondPointer, resultLayout).slice(); + + writeRequest(memory, requestPointer, abi, 1, 2); + const failedPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assert.notEqual(failedPointer, secondPointer); + assertResult(memory, failedPointer, abi, { + status: abi.status.revisionConflict, + engineRevision: 2, + planRevision: 2, + requiredBaseRevision: 2, + publicationGeneration: 2, + outputSlot: 0, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, secondPointer, resultLayout), secondHeader); + + writeRequest(memory, requestPointer, abi, 2, 0); + const checkpointPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assertResult(memory, checkpointPointer, abi, { + status: abi.status.ok, + engineRevision: 3, + planRevision: 3, + requiredBaseRevision: 0, + publicationGeneration: 3, + outputSlot: 0, + flags: abi.engine.resultFlags.checkpoint, + }); + const checkpointHeader = resultBytes(memory, checkpointPointer, resultLayout).slice(); + + writeRequest(memory, requestPointer, abi, 3, 3); + new DataView(memory.buffer, requestPointer, requestLayout.size).setUint32(requestLayout.regionCount, 1, true); + const unsupportedPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assertResult(memory, unsupportedPointer, abi, { + status: abi.status.invalidRequest, + engineRevision: 3, + planRevision: 3, + requiredBaseRevision: 3, + publicationGeneration: 3, + outputSlot: 1, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, checkpointPointer, resultLayout), checkpointHeader); + + const oldBuffer = memory.buffer; + const grownCapacity = 8 * 1024 * 1024; + assert.equal(fn.reserveSession(sessionId, grownCapacity, grownCapacity), abi.status.ok); + assert.notStrictEqual(memory.buffer, oldBuffer); + assert.equal(oldBuffer.byteLength, 0, 'memory.grow must detach fixed-length views in the pinned runtime'); + requestPointer = fn.requestPointer(sessionId); + assert.equal(requestPointer % 16, 0); + assert.ok(fn.requestCapacity(sessionId) >= grownCapacity); + + assert.equal(fn.disposeSession(sessionId), abi.status.ok); + assert.equal(fn.sessionCount(), 0); + assert.equal(fn.disposeSession(sessionId), abi.status.sessionMissing); + assert.equal(fn.textUpdate(sessionId, requestPointer, requestLayout.size), 0); + assert.equal(fn.disposePolicy(policyHandle), abi.status.ok); +}); + +function writeRequest(memory, pointer, abi, expectedEngineRevision, consumedPlanRevision) { + const bytes = engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision, + consumedPlanRevision, + }); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); +} + +function assertResult(memory, pointer, abi, expected) { + const layout = abi.layouts.engineResult; + const view = new DataView(memory.buffer, pointer, layout.size); + assert.equal(view.getUint32(layout.abiVersion, true), abi.version); + assert.equal(view.getUint32(layout.byteLength, true), layout.size); + assert.equal(view.getUint32(layout.sessionId, true), sessionId); + for (const [field, value] of Object.entries(expected)) { + assert.equal(view.getUint32(layout[field], true), value, field); + } + for (const field of [ + 'semanticsCount', + 'resourceCount', + 'bufferCount', + 'patchCount', + 'primitiveCount', + 'drawCount', + 'retirementCount', + 'diagnosticCount', + ]) { + assert.equal(view.getUint32(layout[field], true), 0, field); + } +} + +function resultBytes(memory, pointer, layout) { + return new Uint8Array(memory.buffer, pointer, layout.size); +} diff --git a/packages/text/tests/integration/render-policy-registration.test.mjs b/packages/text/tests/integration/render-policy-registration.test.mjs index 1090af7a..7f57c421 100644 --- a/packages/text/tests/integration/render-policy-registration.test.mjs +++ b/packages/text/tests/integration/render-policy-registration.test.mjs @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; +import { renderPolicyBytes } from '../support/engine-abi.mjs'; + const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); @@ -46,48 +48,3 @@ test('registers compiler-mapped render policies as retained typed Wasm state', a assert.equal(count(), 0); assert.equal(register(8, pointer, bytes.byteLength), abi.status.invalidRequest); }); - -function renderPolicyBytes(abi) { - const request = abi.layouts.policyRequest; - const program = abi.layouts.policyProgram; - const buffer = abi.layouts.policyBuffer; - const operation = abi.layouts.policyOperation; - const programsOffset = align(request.size, program.alignment); - const buffersOffset = align(programsOffset + program.size, buffer.alignment); - const operationsOffset = align(buffersOffset + buffer.size, operation.alignment); - const operationCount = 2; - const bytes = new Uint8Array(operationsOffset + operation.size * operationCount); - const view = new DataView(bytes.buffer); - - view.setUint32(request.byteLength, bytes.byteLength, true); - view.setUint32(request.programsOffset, programsOffset, true); - view.setUint32(request.programCount, 1, true); - view.setUint32(request.buffersOffset, buffersOffset, true); - view.setUint32(request.bufferCount, 1, true); - view.setUint32(request.operationsOffset, operationsOffset, true); - view.setUint32(request.operationCount, operationCount, true); - - view.setUint32(programsOffset + program.techniqueId, 1, true); - view.setUint32(programsOffset + program.programId, 1, true); - view.setUint8(programsOffset + program.f32InputCount, 1); - view.setUint16(programsOffset + program.bufferCount, 1, true); - view.setUint16(programsOffset + program.operationCount, operationCount, true); - - view.setUint16(buffersOffset + buffer.id, 1, true); - view.setUint8(buffersOffset + buffer.scalar, abi.policy.scalarTypes.f32); - view.setUint8(buffersOffset + buffer.vectorWidth, 1); - - view.setUint8(operationsOffset + operation.opcode, abi.policy.opcodes.loadF32); - view.setUint8(operationsOffset + operation.target, 0); - view.setUint8(operationsOffset + operation.operand0, 0); - - const storeOffset = operationsOffset + operation.size; - view.setUint8(storeOffset + operation.opcode, abi.policy.opcodes.storeF32); - view.setUint8(storeOffset + operation.operand0, 0); - view.setUint32(storeOffset + operation.immediate0, 1, true); - return bytes; -} - -function align(value, alignment) { - return Math.ceil(value / alignment) * alignment; -} diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs new file mode 100644 index 00000000..62e800a8 --- /dev/null +++ b/packages/text/tests/support/engine-abi.mjs @@ -0,0 +1,75 @@ +export function renderPolicyBytes(abi) { + const request = abi.layouts.policyRequest; + const program = abi.layouts.policyProgram; + const buffer = abi.layouts.policyBuffer; + const operation = abi.layouts.policyOperation; + const programsOffset = align(request.size, program.alignment); + const buffersOffset = align(programsOffset + program.size, buffer.alignment); + const operationsOffset = align(buffersOffset + buffer.size, operation.alignment); + const operationCount = 2; + const bytes = new Uint8Array(operationsOffset + operation.size * operationCount); + const view = new DataView(bytes.buffer); + + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.programsOffset, programsOffset, true); + view.setUint32(request.programCount, 1, true); + view.setUint32(request.buffersOffset, buffersOffset, true); + view.setUint32(request.bufferCount, 1, true); + view.setUint32(request.operationsOffset, operationsOffset, true); + view.setUint32(request.operationCount, operationCount, true); + + view.setUint32(programsOffset + program.techniqueId, 1, true); + view.setUint32(programsOffset + program.programId, 1, true); + view.setUint8(programsOffset + program.f32InputCount, 1); + view.setUint16(programsOffset + program.bufferCount, 1, true); + view.setUint16(programsOffset + program.operationCount, operationCount, true); + + view.setUint16(buffersOffset + buffer.id, 1, true); + view.setUint8(buffersOffset + buffer.scalar, abi.policy.scalarTypes.f32); + view.setUint8(buffersOffset + buffer.vectorWidth, 1); + + view.setUint8(operationsOffset + operation.opcode, abi.policy.opcodes.loadF32); + view.setUint8(operationsOffset + operation.target, 0); + view.setUint8(operationsOffset + operation.operand0, 0); + + const storeOffset = operationsOffset + operation.size; + view.setUint8(storeOffset + operation.opcode, abi.policy.opcodes.storeF32); + view.setUint8(storeOffset + operation.operand0, 0); + view.setUint32(storeOffset + operation.immediate0, 1, true); + return bytes; +} + +export function engineUpdateBytes(abi, { sessionId, policyHandle, expectedEngineRevision, consumedPlanRevision }) { + const layout = abi.layouts.engineUpdateRequest; + const bytes = new Uint8Array(layout.size); + const view = new DataView(bytes.buffer); + view.setUint32(layout.abiVersion, abi.version, true); + view.setUint32(layout.byteLength, bytes.byteLength, true); + view.setUint32(layout.sessionId, sessionId, true); + view.setUint32(layout.expectedEngineRevision, expectedEngineRevision, true); + view.setUint32(layout.consumedPlanRevision, consumedPlanRevision, true); + view.setUint32(layout.policyHandle, policyHandle, true); + for (const field of [ + 'maxClusters', + 'maxLines', + 'maxRegions', + 'maxExclusions', + 'maxInlineObjects', + 'maxSlotsPerBand', + ]) { + view.setUint32(layout[field], 1, true); + } + view.setUint32(layout.maxOutputBytes, abi.layouts.engineResult.size, true); + return bytes; +} + +export function copyIntoAllocation(memory, allocate, bytes) { + const pointer = allocate(bytes.byteLength); + if (pointer === 0) throw new Error('Wasm request allocation failed'); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + return pointer; +} + +function align(value, alignment) { + return Math.ceil(value / alignment) * alignment; +} From ea368b04b64fcd9e148a5cdd45f9c0dcb7a26fca Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 05:28:48 -0400 Subject: [PATCH 005/128] feat(text): return retired frame buffers --- docs/log.md | 8 + docs/packages/text.md | 12 +- .../text/src/internal/frame-transfer-pool.ts | 325 ++++++++++++++++++ .../package/frame-transfer-pool.test.mjs | 199 +++++++++++ 4 files changed, 543 insertions(+), 1 deletion(-) create mode 100644 packages/text/src/internal/frame-transfer-pool.ts create mode 100644 packages/text/tests/package/frame-transfer-pool.test.mjs diff --git a/docs/log.md b/docs/log.md index 657f760c..c2aabe27 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Proved worker-owned frame transfer and return** — Added a test-only, byte-opaque transfer state machine around raw + Wasm publication bytes. One copy enters a bounded capacity-classed worker buffer; transfer to root detaches it and + charges its actual capacity to explicit count/byte backpressure limits. Retirement transfers the same storage back, + where a valid token/capacity pair either re-enters the bounded best-fit pool or becomes unreachable for worker-side + collection. Four focused tests prove exact bytes, two-way detachment, reuse, missing-return backpressure, forged and + duplicate return rejection, failed-send recovery, oversize rejection, and over-limit worker-side discard. The module + does not decode the compiler-defined frame ABI and remains unwired from the shipping TypeScript layout path. + - **Proved the retained A/B frame transaction in the optimized Wasm** — Added session lifecycle, cold reservation, one retained 16-byte-aligned request arena, two retained 16-byte-aligned result arenas, explicit engine/plan/publication revisions, base-revision checkpoints, and a compiler-derived 120-byte request plus 128-byte result header. The result diff --git a/docs/packages/text.md b/docs/packages/text.md index 9909c7da..6745e0e2 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:72c1c1f5b300b47900fba2ed6843e91e68f64fb401aa472b0c16ca02764e34de' +source_digest: 'sha256:e240ef80a048b85b175a32bd66ce824f91c217da5c51b727dc45b1fa9c2a05ec' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -404,6 +404,16 @@ conformance scenarios, and 172,156-byte packed-consumer proof at its deliberately stale checked package-size snapshot; this stage records the actual measured size without rewriting that unrelated historical evidence. +The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine +copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership +token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A +missing return therefore produces observable backpressure instead of unbounded allocation. Renderer retirement +transfers the same storage back to the worker; only a valid token/capacity pair can re-enter the bounded best-fit pool, +and an over-limit return becomes unreachable on the worker for worker-side collection. Tests cover detachment in both +directions, exact bytes, reuse, missing returns, forged and duplicate returns, failed sends, oversize rejection, and +worker-side discard. The transport never decodes the compiler-defined frame layout, and it remains unwired from the +shipping asynchronous TypeScript path until the Rust semantic tables exist. + Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. The runtime repeats no parallel wire-format implementation. Bitmap and MTSDF renderers plus both standalone validators consume the same dependency-light KTX2 and dense-record rules; only the standalone layer imports Khronos/Ajv. The renderers also share the lossless-atlas adapter, unit quad, parallel-array checks, and resolved-paint lookup. The MTSDF resource uploads only its authenticated base levels into one padded texture array, samples them bilinearly, sizes reconstruction with screen derivatives, and owns one material per logical array; disposal releases materials and textures transactionally. diff --git a/packages/text/src/internal/frame-transfer-pool.ts b/packages/text/src/internal/frame-transfer-pool.ts new file mode 100644 index 00000000..a005c628 --- /dev/null +++ b/packages/text/src/internal/frame-transfer-pool.ts @@ -0,0 +1,325 @@ +export const FRAME_TRANSFER_PROTOCOL_VERSION = 0 as const; + +const FRAME_PUBLICATION_TYPE = 'pmndrs-text-frame-v0' as const; +const FRAME_RETURN_TYPE = 'pmndrs-text-frame-return-v0' as const; + +export interface FrameTransferPoolLimits { + readonly minimumCapacity: number; + readonly maximumBufferBytes: number; + readonly maximumOutstandingTransfers: number; + readonly maximumOutstandingBytes: number; + readonly maximumPooledBuffers: number; + readonly maximumPooledBytes: number; +} + +export interface FrameTransferPublicationV0 { + readonly type: typeof FRAME_PUBLICATION_TYPE; + readonly protocolVersion: typeof FRAME_TRANSFER_PROTOCOL_VERSION; + readonly transferId: number; + readonly sessionId: number; + readonly planRevision: number; + readonly byteLength: number; + readonly capacity: number; + readonly buffer: ArrayBuffer; +} + +export interface FrameTransferReturnV0 { + readonly type: typeof FRAME_RETURN_TYPE; + readonly protocolVersion: typeof FRAME_TRANSFER_PROTOCOL_VERSION; + readonly transferId: number; + readonly capacity: number; + readonly buffer: ArrayBuffer; +} + +export interface FrameTransferPoolStats { + readonly allocations: number; + readonly poolHits: number; + readonly transfers: number; + readonly returns: number; + readonly rejectedReturns: number; + readonly discardedReturns: number; + readonly detachedTransferFailures: number; + readonly backpressureEvents: number; + readonly bytesCopied: number; + readonly transferredBytes: number; + readonly outstandingTransfers: number; + readonly outstandingBytes: number; + readonly pooledBuffers: number; + readonly pooledBytes: number; +} + +export type FrameTransferResult = + | Readonly<{ ok: true; publication: FrameTransferPublicationV0 }> + | Readonly<{ ok: false; reason: 'backpressure' | 'oversized' | 'transfer-failed'; error?: unknown }>; + +export type FrameReturnResult = + | Readonly<{ ok: true; pooled: boolean }> + | Readonly<{ ok: false; reason: 'invalid-message' | 'unknown-transfer' | 'capacity-mismatch' }>; + +export interface FrameTransferPool { + transfer( + bytes: Uint8Array, + publication: Readonly<{ sessionId: number; planRevision: number }>, + send: (message: FrameTransferPublicationV0, transfer: readonly Transferable[]) => void, + ): FrameTransferResult; + acceptReturn(message: unknown): FrameReturnResult; + stats(): FrameTransferPoolStats; +} + +interface MutableStats { + allocations: number; + poolHits: number; + transfers: number; + returns: number; + rejectedReturns: number; + discardedReturns: number; + detachedTransferFailures: number; + backpressureEvents: number; + bytesCopied: number; + transferredBytes: number; +} + +/** + * Owns the asynchronous copy boundary for raw frame bytes. The pool never decodes the compiler-defined Wasm layout. + * A successful `send` must transfer (and therefore detach) the supplied buffer before it returns. + */ +export function createFrameTransferPool(limits: FrameTransferPoolLimits): FrameTransferPool { + validateLimits(limits); + const pooled: ArrayBuffer[] = []; + const outstanding = new Map(); + const stats: MutableStats = { + allocations: 0, + poolHits: 0, + transfers: 0, + returns: 0, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 0, + transferredBytes: 0, + }; + let pooledBytes = 0; + let outstandingBytes = 0; + let nextTransferId = 1; + + return { + transfer(bytes, publication, send) { + assertPublicationMetadata(publication); + const minimumCapacity = transferCapacity(bytes.byteLength, limits); + if (minimumCapacity === undefined) return { ok: false, reason: 'oversized' }; + const availableOutstandingBytes = limits.maximumOutstandingBytes - outstandingBytes; + if (outstanding.size >= limits.maximumOutstandingTransfers || minimumCapacity > availableOutstandingBytes) { + stats.backpressureEvents += 1; + return { ok: false, reason: 'backpressure' }; + } + + const pooledIndex = bestFitIndex(pooled, minimumCapacity, availableOutstandingBytes); + const buffer = pooledIndex < 0 ? new ArrayBuffer(minimumCapacity) : pooled.splice(pooledIndex, 1)[0]!; + if (pooledIndex < 0) stats.allocations += 1; + else { + pooledBytes -= buffer.byteLength; + stats.poolHits += 1; + } + new Uint8Array(buffer, 0, bytes.byteLength).set(bytes); + stats.bytesCopied += bytes.byteLength; + + const transferId = nextAvailableTransferId(nextTransferId, outstanding); + nextTransferId = transferId === 0xffff_ffff ? 1 : transferId + 1; + const message: FrameTransferPublicationV0 = { + type: FRAME_PUBLICATION_TYPE, + protocolVersion: FRAME_TRANSFER_PROTOCOL_VERSION, + transferId, + sessionId: publication.sessionId, + planRevision: publication.planRevision, + byteLength: bytes.byteLength, + capacity: buffer.byteLength, + buffer, + }; + + try { + send(message, [buffer]); + } catch (error) { + if (buffer.byteLength > 0) pooledBytes = retainWorkerBuffer(pooled, pooledBytes, buffer, limits); + else { + outstanding.set(transferId, message.capacity); + outstandingBytes += message.capacity; + stats.detachedTransferFailures += 1; + } + return { ok: false, reason: 'transfer-failed', error }; + } + if (buffer.byteLength !== 0) { + pooledBytes = retainWorkerBuffer(pooled, pooledBytes, buffer, limits); + return { + ok: false, + reason: 'transfer-failed', + error: new TypeError('frame transfer sender returned without detaching its buffer'), + }; + } + + outstanding.set(transferId, message.capacity); + outstandingBytes += message.capacity; + stats.transfers += 1; + stats.transferredBytes += message.capacity; + return { ok: true, publication: message }; + }, + + acceptReturn(message) { + if (!isFrameTransferReturnV0(message)) { + stats.rejectedReturns += 1; + return { ok: false, reason: 'invalid-message' }; + } + const expectedCapacity = outstanding.get(message.transferId); + if (expectedCapacity === undefined) { + stats.rejectedReturns += 1; + return { ok: false, reason: 'unknown-transfer' }; + } + if (message.capacity !== expectedCapacity || message.buffer.byteLength !== expectedCapacity) { + stats.rejectedReturns += 1; + return { ok: false, reason: 'capacity-mismatch' }; + } + + outstanding.delete(message.transferId); + outstandingBytes -= expectedCapacity; + stats.returns += 1; + const canPool = + pooled.length < limits.maximumPooledBuffers && pooledBytes + expectedCapacity <= limits.maximumPooledBytes; + if (!canPool) { + stats.discardedReturns += 1; + return { ok: true, pooled: false }; + } + pooled.push(message.buffer); + pooledBytes += expectedCapacity; + return { ok: true, pooled: true }; + }, + + stats() { + return { + ...stats, + outstandingTransfers: outstanding.size, + outstandingBytes, + pooledBuffers: pooled.length, + pooledBytes, + }; + }, + }; +} + +/** Transfer a retired root-owned publication back to its originating worker. */ +export function returnFrameTransfer( + publication: FrameTransferPublicationV0, + send: (message: FrameTransferReturnV0, transfer: readonly Transferable[]) => void, +): void { + if (!isFrameTransferPublicationV0(publication)) throw new TypeError('invalid frame transfer publication'); + if (publication.buffer.byteLength !== publication.capacity) { + throw new TypeError('frame transfer is detached or has the wrong capacity'); + } + const message: FrameTransferReturnV0 = { + type: FRAME_RETURN_TYPE, + protocolVersion: FRAME_TRANSFER_PROTOCOL_VERSION, + transferId: publication.transferId, + capacity: publication.capacity, + buffer: publication.buffer, + }; + send(message, [publication.buffer]); + if (publication.buffer.byteLength !== 0) { + throw new TypeError('frame return sender returned without detaching its buffer'); + } +} + +export function isFrameTransferPublicationV0(value: unknown): value is FrameTransferPublicationV0 { + if (!isRecord(value) || value.type !== FRAME_PUBLICATION_TYPE || value.protocolVersion !== 0) return false; + return ( + positiveU32(value.transferId) && + positiveU32(value.sessionId) && + nonnegativeU32(value.planRevision) && + nonnegativeU32(value.byteLength) && + positiveU32(value.capacity) && + value.byteLength <= value.capacity && + value.buffer instanceof ArrayBuffer + ); +} + +export function isFrameTransferReturnV0(value: unknown): value is FrameTransferReturnV0 { + if (!isRecord(value) || value.type !== FRAME_RETURN_TYPE || value.protocolVersion !== 0) return false; + return positiveU32(value.transferId) && positiveU32(value.capacity) && value.buffer instanceof ArrayBuffer; +} + +function validateLimits(limits: FrameTransferPoolLimits): void { + assertPositiveU32('minimumCapacity', limits.minimumCapacity); + assertPositiveU32('maximumBufferBytes', limits.maximumBufferBytes); + assertPositiveU32('maximumOutstandingTransfers', limits.maximumOutstandingTransfers); + assertPositiveU32('maximumOutstandingBytes', limits.maximumOutstandingBytes); + assertU32('maximumPooledBuffers', limits.maximumPooledBuffers); + assertU32('maximumPooledBytes', limits.maximumPooledBytes); + if (limits.minimumCapacity > limits.maximumBufferBytes) { + throw new RangeError('minimumCapacity cannot exceed maximumBufferBytes'); + } + if (limits.maximumBufferBytes > limits.maximumOutstandingBytes) { + throw new RangeError('maximumBufferBytes cannot exceed maximumOutstandingBytes'); + } +} + +function assertPublicationMetadata(value: Readonly<{ sessionId: number; planRevision: number }>): void { + if (!positiveU32(value.sessionId)) throw new RangeError('sessionId must be a positive u32'); + if (!nonnegativeU32(value.planRevision)) throw new RangeError('planRevision must be a u32'); +} + +function transferCapacity(byteLength: number, limits: FrameTransferPoolLimits): number | undefined { + if (!Number.isSafeInteger(byteLength) || byteLength < 0 || byteLength > limits.maximumBufferBytes) return undefined; + let capacity = limits.minimumCapacity; + while (capacity < byteLength && capacity <= Math.floor(limits.maximumBufferBytes / 2)) capacity *= 2; + return capacity < byteLength ? byteLength : capacity; +} + +function bestFitIndex(buffers: readonly ArrayBuffer[], minimumCapacity: number, maximumCapacity: number): number { + let found = -1; + for (let index = 0; index < buffers.length; index += 1) { + const candidate = buffers[index]!; + if (candidate.byteLength < minimumCapacity || candidate.byteLength > maximumCapacity) continue; + if (found < 0 || candidate.byteLength < buffers[found]!.byteLength) found = index; + } + return found; +} + +function retainWorkerBuffer( + buffers: ArrayBuffer[], + pooledBytes: number, + buffer: ArrayBuffer, + limits: FrameTransferPoolLimits, +): number { + if (buffers.length >= limits.maximumPooledBuffers || pooledBytes + buffer.byteLength > limits.maximumPooledBytes) { + return pooledBytes; + } + buffers.push(buffer); + return pooledBytes + buffer.byteLength; +} + +function nextAvailableTransferId(start: number, outstanding: ReadonlyMap): number { + let candidate = start; + do { + if (!outstanding.has(candidate)) return candidate; + candidate = candidate === 0xffff_ffff ? 1 : candidate + 1; + } while (candidate !== start); + throw new RangeError('frame transfer identifiers are exhausted'); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function positiveU32(value: unknown): value is number { + return Number.isInteger(value) && Number(value) > 0 && Number(value) <= 0xffff_ffff; +} + +function nonnegativeU32(value: unknown): value is number { + return Number.isInteger(value) && Number(value) >= 0 && Number(value) <= 0xffff_ffff; +} + +function assertPositiveU32(name: string, value: number): void { + if (!positiveU32(value)) throw new RangeError(`${name} must be a positive u32`); +} + +function assertU32(name: string, value: number): void { + if (!nonnegativeU32(value)) throw new RangeError(`${name} must be a u32`); +} diff --git a/packages/text/tests/package/frame-transfer-pool.test.mjs b/packages/text/tests/package/frame-transfer-pool.test.mjs new file mode 100644 index 00000000..6bb6b637 --- /dev/null +++ b/packages/text/tests/package/frame-transfer-pool.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + createFrameTransferPool, + isFrameTransferPublicationV0, + returnFrameTransfer, +} from '../../dist/internal/frame-transfer-pool.js'; + +const limits = { + minimumCapacity: 256, + maximumBufferBytes: 1_024, + maximumOutstandingTransfers: 1, + maximumOutstandingBytes: 1_024, + maximumPooledBuffers: 1, + maximumPooledBytes: 1_024, +}; + +test('frame transfers copy opaque Wasm bytes once and return the same storage to the worker pool', () => { + const pool = createFrameTransferPool(limits); + const source = Uint8Array.from({ length: 129 }, (_, index) => (index * 17) & 0xff); + let rootPublication; + const first = pool.transfer(source, { sessionId: 7, planRevision: 11 }, (message, transfer) => { + rootPublication = structuredClone(message, { transfer }); + }); + + assert.equal(first.ok, true); + assert.equal(first.publication.buffer.byteLength, 0); + assert.equal(isFrameTransferPublicationV0(rootPublication), true); + assert.equal(rootPublication.capacity, 256); + assert.deepEqual(new Uint8Array(rootPublication.buffer, 0, rootPublication.byteLength), source); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 0, + transfers: 1, + returns: 0, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 129, + transferredBytes: 256, + outstandingTransfers: 1, + outstandingBytes: 256, + pooledBuffers: 0, + pooledBytes: 0, + }); + + const blocked = pool.transfer(source, { sessionId: 7, planRevision: 12 }, () => { + assert.fail('backpressure must reject before sending'); + }); + assert.deepEqual(blocked, { ok: false, reason: 'backpressure' }); + + let returned; + returnFrameTransfer(rootPublication, (message, transfer) => { + returned = structuredClone(message, { transfer }); + }); + assert.equal(rootPublication.buffer.byteLength, 0); + assert.deepEqual(pool.acceptReturn(returned), { ok: true, pooled: true }); + + let secondRootPublication; + const second = pool.transfer(source.subarray(0, 64), { sessionId: 7, planRevision: 12 }, (message, transfer) => { + secondRootPublication = structuredClone(message, { transfer }); + }); + assert.equal(second.ok, true); + assert.equal(secondRootPublication.transferId, 2); + assert.deepEqual( + new Uint8Array(secondRootPublication.buffer, 0, secondRootPublication.byteLength), + source.subarray(0, 64), + ); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 1, + transfers: 2, + returns: 1, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 1, + bytesCopied: 193, + transferredBytes: 512, + outstandingTransfers: 1, + outstandingBytes: 256, + pooledBuffers: 0, + pooledBytes: 0, + }); +}); + +test('frame transfers reject detached misuse, forged returns, and oversized publications', () => { + const pool = createFrameTransferPool(limits); + const oversized = pool.transfer(new Uint8Array(1_025), { sessionId: 1, planRevision: 0 }, () => { + assert.fail('oversized transfer must reject before sending'); + }); + assert.deepEqual(oversized, { ok: false, reason: 'oversized' }); + + let rootPublication; + const transferred = pool.transfer(new Uint8Array(1), { sessionId: 1, planRevision: 0 }, (message, transfer) => { + rootPublication = structuredClone(message, { transfer }); + }); + assert.equal(transferred.ok, true); + assert.deepEqual( + pool.acceptReturn({ + type: 'pmndrs-text-frame-return-v0', + protocolVersion: 0, + transferId: 99, + capacity: 256, + buffer: new ArrayBuffer(256), + }), + { ok: false, reason: 'unknown-transfer' }, + ); + assert.deepEqual( + pool.acceptReturn({ + type: 'pmndrs-text-frame-return-v0', + protocolVersion: 0, + transferId: rootPublication.transferId, + capacity: 512, + buffer: new ArrayBuffer(512), + }), + { ok: false, reason: 'capacity-mismatch' }, + ); + + let returned; + returnFrameTransfer(rootPublication, (message, transfer) => { + returned = structuredClone(message, { transfer }); + }); + assert.throws(() => returnFrameTransfer(rootPublication, () => {}), /detached/); + assert.deepEqual(pool.acceptReturn(returned), { ok: true, pooled: true }); + assert.deepEqual(pool.acceptReturn(returned), { ok: false, reason: 'unknown-transfer' }); +}); + +test('failed send retains worker ownership and never consumes outstanding capacity', () => { + const pool = createFrameTransferPool(limits); + const source = new Uint8Array(16); + const thrown = new Error('postMessage failed'); + const failure = pool.transfer(source, { sessionId: 1, planRevision: 1 }, () => { + throw thrown; + }); + assert.equal(failure.ok, false); + assert.equal(failure.reason, 'transfer-failed'); + assert.equal(failure.error, thrown); + + const missingTransfer = pool.transfer(source, { sessionId: 1, planRevision: 1 }, () => {}); + assert.equal(missingTransfer.ok, false); + assert.equal(missingTransfer.reason, 'transfer-failed'); + assert.match(String(missingTransfer.error), /without detaching/); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 1, + transfers: 0, + returns: 0, + rejectedReturns: 0, + discardedReturns: 0, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 32, + transferredBytes: 0, + outstandingTransfers: 0, + outstandingBytes: 0, + pooledBuffers: 1, + pooledBytes: 256, + }); +}); + +test('returned buffers over the pool bound become collectible only after worker ownership resumes', () => { + const pool = createFrameTransferPool({ + ...limits, + maximumPooledBuffers: 0, + maximumPooledBytes: 0, + }); + let rootPublication; + assert.equal( + pool.transfer(new Uint8Array(32), { sessionId: 1, planRevision: 1 }, (message, transfer) => { + rootPublication = structuredClone(message, { transfer }); + }).ok, + true, + ); + + let returned; + returnFrameTransfer(rootPublication, (message, transfer) => { + returned = structuredClone(message, { transfer }); + }); + assert.deepEqual(pool.acceptReturn(returned), { ok: true, pooled: false }); + assert.deepEqual(pool.stats(), { + allocations: 1, + poolHits: 0, + transfers: 1, + returns: 1, + rejectedReturns: 0, + discardedReturns: 1, + detachedTransferFailures: 0, + backpressureEvents: 0, + bytesCopied: 32, + transferredBytes: 256, + outstandingTransfers: 0, + outstandingBytes: 0, + pooledBuffers: 0, + pooledBytes: 0, + }); +}); From fa8c9b5aabc1fe1e11a75ee8184271a560cc8a9f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 06:18:05 -0400 Subject: [PATCH 006/128] perf(text): select retained SIMD kernels --- .../scripts/benchmark-text-engine-kernels.mts | 155 ++++++ docs/log.md | 11 + docs/packages/benchmarks.md | 15 +- docs/packages/text.md | 20 +- docs/planning/decision-register.md | 43 +- docs/planning/rust-layout-engine.md | 172 +++--- packages/text/rust/shaper/Cargo.toml | 2 + .../shaper/evidence/kernel-layout-v0.json | 108 ++++ .../text/rust/shaper/src/engine/kernel_lab.rs | 514 ++++++++++++++++++ packages/text/rust/shaper/src/engine/mod.rs | 3 + packages/text/rust/shaper/src/wasm.rs | 85 +++ .../text/scripts/benchmark-engine-kernels.mts | 81 +++ .../scripts/benchmark-paragraph-layout.mts | 60 +- .../text/scripts/build-engine-kernel-lab.mjs | 71 +++ packages/text/scripts/build.mjs | 13 +- .../scripts/support/engine-kernel-fixture.mts | 84 +++ .../scripts/support/engine-kernel-runner.mjs | 212 ++++++++ .../support/paragraph-benchmark-fixture.mts | 47 ++ 18 files changed, 1547 insertions(+), 149 deletions(-) create mode 100644 apps/benchmarks/scripts/benchmark-text-engine-kernels.mts create mode 100644 packages/text/rust/shaper/evidence/kernel-layout-v0.json create mode 100644 packages/text/rust/shaper/src/engine/kernel_lab.rs create mode 100644 packages/text/scripts/benchmark-engine-kernels.mts create mode 100644 packages/text/scripts/build-engine-kernel-lab.mjs create mode 100644 packages/text/scripts/support/engine-kernel-fixture.mts create mode 100644 packages/text/scripts/support/engine-kernel-runner.mjs create mode 100644 packages/text/scripts/support/paragraph-benchmark-fixture.mts diff --git a/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts b/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts new file mode 100644 index 00000000..a0060c62 --- /dev/null +++ b/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts @@ -0,0 +1,155 @@ +/* @workflow { + "name": "text:kernel-lab-browser", + "summary": "Runs the scalar, auto-vectorized, and explicit SIMD retained-engine kernel packet in project Chromium.", + "requirements": "Built @pmndrs/text and package-local kernel-lab artifacts. Accepts --json.", + "writes": "stdout only, or the JSON report path passed to --json" +} */ +import { readFile, writeFile } from 'node:fs/promises'; +import { createServer, type Server } from 'node:http'; +import type { Browser } from 'playwright'; + +import { captureKernelWorkloads } from '../../../packages/text/scripts/support/engine-kernel-fixture.mts'; +import { launchProjectChromium } from './support/project-chromium.mts'; + +const artifactRoot = new URL('../../../packages/text/rust/shaper/target/kernel-lab/', import.meta.url); +const runnerUrl = new URL('../../../packages/text/scripts/support/engine-kernel-runner.mjs', import.meta.url); +const variants = ['scalar', 'auto', 'explicit'] as const; +const warmup = 40; +const samples = 101; +const workloads = await captureKernelWorkloads([22_000, 86_000]); + +let browser: Browser | undefined; +let server: Server | undefined; +try { + server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html', 'cache-control': 'no-store' }); + response.end('pmndrs text kernel lab'); + }); + await new Promise((resolve, reject) => { + server!.once('error', reject); + server!.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('kernel-lab server did not expose a TCP port'); + browser = await launchProjectChromium({ headless: true }); + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${address.port}/`, { waitUntil: 'domcontentloaded' }); + const errors: string[] = []; + page.on('console', (message) => { + if (message.type() === 'error') errors.push(message.text()); + }); + page.on('pageerror', (error) => errors.push(error.message)); + const runnerSource = await readFile(runnerUrl, 'utf8'); + const moduleUrl = await page.evaluate( + (source) => URL.createObjectURL(new Blob([source], { type: 'text/javascript' })), + runnerSource, + ); + const variantReports = []; + let oracleHashes: ReadonlyMap | undefined; + for (const name of variants) { + const wasm = await readFile(new URL(`${name}.wasm`, artifactRoot)); + const reports = []; + const hashes = new Map(); + for (const workload of workloads) { + const result = await page.evaluate( + async ({ + moduleUrl: browserModuleUrl, + wasmBase64, + input, + name: artifactName, + warmup: benchmarkWarmup, + samples: benchmarkSamples, + }) => { + const module = await import(browserModuleUrl); + const decode = (value: string) => { + const binary = atob(value); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return bytes; + }; + const typedInput = { + label: input.label, + glyphs: input.glyphs, + x: new Float32Array(decode(input.x).buffer), + y: new Float32Array(decode(input.y).buffer), + fontSize: new Float32Array(decode(input.fontSize).buffer), + planeLeft: new Float32Array(decode(input.planeLeft).buffer), + planeBottom: new Float32Array(decode(input.planeBottom).buffer), + planeRight: new Float32Array(decode(input.planeRight).buffer), + planeTop: new Float32Array(decode(input.planeTop).buffer), + advances: new Int32Array(decode(input.advances).buffer), + flags: decode(input.flags), + levels: decode(input.levels), + }; + return module.benchmarkKernelArtifact(decode(wasmBase64), artifactName, typedInput, { + warmup: benchmarkWarmup, + samples: benchmarkSamples, + }); + }, + { + moduleUrl, + wasmBase64: wasm.toString('base64'), + input: encodeInput(workload), + name, + warmup, + samples, + }, + ); + reports.push(result); + hashes.set(workload.label, result.outputHash); + } + if (oracleHashes === undefined) oracleHashes = hashes; + else { + for (const [label, hash] of hashes) { + if (hash !== oracleHashes.get(label)) { + throw new Error(`${name} output ${hash} does not match Chromium scalar oracle for ${label}`); + } + } + } + variantReports.push({ name, workloads: reports }); + } + await page.evaluate((url) => URL.revokeObjectURL(url), moduleUrl); + if (errors.length > 0) throw new Error(`kernel-lab Chromium errors: ${errors.join(' | ')}`); + const environment = await page.evaluate(() => ({ + userAgent: navigator.userAgent, + hardwareConcurrency: navigator.hardwareConcurrency, + simdArtifactExecuted: true, + })); + const report = { + generatedBy: 'text:kernel-lab-browser', + environment, + warmup, + samples, + variants: variantReports, + }; + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + const jsonPath = readArgument('--json'); + if (jsonPath !== undefined) await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`); +} finally { + if (browser !== undefined) await browser.close(); + if (server !== undefined) await new Promise((resolve) => server!.close(() => resolve())); +} + +function encodeInput(input: (typeof workloads)[number]) { + const encode = (value: ArrayBufferView) => + Buffer.from(value.buffer, value.byteOffset, value.byteLength).toString('base64'); + return { + label: input.label, + glyphs: input.glyphs, + x: encode(input.x), + y: encode(input.y), + fontSize: encode(input.fontSize), + planeLeft: encode(input.planeLeft), + planeBottom: encode(input.planeBottom), + planeRight: encode(input.planeRight), + planeTop: encode(input.planeTop), + advances: encode(input.advances), + flags: encode(input.flags), + levels: encode(input.levels), + }; +} + +function readArgument(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + return index < 0 ? undefined : process.argv[index + 1]; +} diff --git a/docs/log.md b/docs/log.md index c2aabe27..cdd2bf98 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,17 @@ ## 2026-08-08 +- **Selected the first SIMD-shaped retained storage** — Added a test-only direct-pointer kernel lab over real 25,515- + and 100,602-glyph paragraph arrays and three isolated Wasm builds. Node 24 and Chromium 149 reproduce exact scalar + hashes for horizontal, vertical, partial-tail, and four-byte-aligned inputs with no warm allocation path or memory + growth. Compiler-vectorized source beats hand-written record packing; explicit 16-lane break/bidi masks and + integer-exact summaries pass the 20% admission threshold; large-workload evidence selects ABI-private 64-cluster, + 16-byte-aligned SoA chunks. The selected lab delta is 1,652 raw / 1,158 Brotli bytes, while the standard production + SIMD artifact is 289 raw / 26 Brotli bytes smaller than scalar. SIMD now builds by default without runtime dispatch; + `PMNDRS_TEXT_SHAPER_SIMD=0` produces the same-ABI scalar artifact, whose disassembly contains no vector instructions + and whose 34 focused semantic tests pass. Policy execution, boundary search, native SIMD, and end-to-end contribution + stay measured follow-ups rather than inferred wins. + - **Proved worker-owned frame transfer and return** — Added a test-only, byte-opaque transfer state machine around raw Wasm publication bytes. One copy enters a bounded capacity-classed worker buffer; transfer to root detaches it and charges its actual capacity to explicit count/byte backpressure limits. Retirement transfers the same storage back, diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 711d8893..3d4027f7 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:79e8650499e9764b5e7449d16ca6446f60caaef77c8b53ac08fc2901cfd10985' +source_digest: 'sha256:3185896663acc3394d719ec4eddf94c2c038a9386b7119e67d4bfd10eac30d88' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -198,8 +198,8 @@ sources: resource: ../../apps/benchmarks/vitexec/raster-technique-compare.probe.ts title: Realtime comparison product probe generated: - by: anthropic-claude/opus-5 - at: '2026-08-08T08:15:00Z' + by: openai-codex/gpt-5 + at: '2026-08-08T10:15:42Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -485,6 +485,15 @@ The V0 autoresearch baseline is a fail-closed control artifact, not an active op The packed-consumer lane builds and packs both workspace packages, extracts only their published tarballs into an isolated Vite application, and executes `@pmndrs/text/runtime-bake` through the installed module Worker in Chromium. Canonical Inter returns the exact 172,156-byte artifact and SHA-256 `af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b`. This closes the gap between source-workspace Worker evidence and what an installed consumer actually resolves. +The `text:kernel-lab-browser` workflow runs the package-owned scalar, compiler-vectorized, and selected hybrid shaper +artifacts in the project-pinned Chromium from a trustworthy loopback origin. It consumes the same captured 25,515- and +100,602-glyph typed arrays as the Node workflow and fails before timing unless every artifact reproduces the scalar +horizontal, vertical, partial-tail, and unaligned hashes. The current Chromium 149 run executed the SIMD artifact, +observed no warm memory growth, and supports the 64-cluster choice: at 100,602 glyphs its selected-hybrid p95 was 0.01875 +ms versus 0.06875 ms scalar for chunk summaries, 0.009375 versus 0.053125 ms for break masks, and 0.0125 versus 0.046875 +ms for bidi masks. Browser timer quantization is visible in those figures, so Node retains the finer candidate ranking +while Chromium supplies the independent engine-admission check. + The bake-host report separates the consumer phases without timing conformance work. Each offline sample creates a fresh Wasm baker and records initialization plus first bake as cold, then records a second bake on that instance as warm. Each isolated Chromium context queues two requests onto one Worker: first completion contains Worker/Wasm startup plus its bake, while the interval to second completion is the warm reused-instance bake. Three captured arm64/Chromium 149 samples preserve complete artifact parity; medians were 4.16 ms cold / 2.94 ms warm offline and 21.70 ms cold / 3.50 ms warm in the Worker. These are observations, not cross-host thresholds. A released scene leaves the provider-owned canvas attached with its last complete frame while its replacement effect activates, avoiding a detach/reparent flash; provider teardown or removal of the owning anchor remains the detach boundary. The continuity probe requires the same connected canvas and exactly one renderer through explicit DPR 2→1→2 and Bitmap→MTSDF→Bitmap handoffs. diff --git a/docs/packages/text.md b/docs/packages/text.md index 6745e0e2..3d73cb59 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:e240ef80a048b85b175a32bd66ce824f91c217da5c51b727dc45b1fa9c2a05ec' +source_digest: 'sha256:bf341bec67c931df02db97003cf7c5e433364f399ec2d750dfa16395e651c5de' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -171,8 +171,8 @@ sources: resource: ../../packages/text/src/internal/unicode.ts title: Unicode analysis implementation generated: - by: anthropic-claude/opus-5 - at: '2026-08-08T08:15:00Z' + by: openai-codex/gpt-5 + at: '2026-08-08T10:15:42Z' --- # Package reference: `@pmndrs/text` @@ -414,6 +414,20 @@ directions, exact bytes, reuse, missing returns, forged and duplicate returns, f worker-side discard. The transport never decodes the compiler-defined frame layout, and it remains unwired from the shipping asynchronous TypeScript path until the Rust semantic tables exist. +The retained-engine kernel lab now fixes the first storage choices before semantic chunks land. It captures the real +25,515- and 100,602-glyph paragraph arrays, derives deterministic metric/advance lanes, and compares scalar, +compiler-vectorized, and selected hybrid artifacts through one direct-pointer interface. Node 24 and Chromium 149 +produce identical horizontal, vertical, partial-tail, and four-byte-aligned hashes with no warm allocation path or +memory growth. Compiler-vectorized straight-line source owns record packing because hand-written shuffle packing did not +beat it. Explicit `i8x16` break/bidi masks and integer-exact `i64x2` summaries pass the 20% phase threshold; the large +workload selects ABI-private 64-cluster, 16-byte-aligned SoA chunks over 32 and 128. Binaryen output contains the intended +vector instructions. The selected lab artifact adds 1,652 raw / 1,158 Brotli bytes over scalar. More importantly, the +standard production `+simd128` artifact measures 725,013 raw / 266,615 gzip / 210,841 Brotli bytes, 289 / 550 / 26 bytes +smaller than the same-ABI scalar build. SIMD is the default build with no runtime dispatch; +`PMNDRS_TEXT_SHAPER_SIMD=0` produces the scalar release valve, whose disassembly contains no SIMD instructions and whose +34 focused shaping/layout/frame tests pass unchanged. Policy execution, boundary search, native SIMD, and the complete +hot-update contribution remain explicit open measurements until those engine stages exist. + Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. The runtime repeats no parallel wire-format implementation. Bitmap and MTSDF renderers plus both standalone validators consume the same dependency-light KTX2 and dense-record rules; only the standalone layer imports Khronos/Ajv. The renderers also share the lossless-atlas adapter, unit quad, parallel-array checks, and resolved-paint lookup. The MTSDF resource uploads only its authenticated base levels into one padded texture array, samples them bilinearly, sizes reconstruction with screen derivatives, and owns one material per logical array; disposal releases materials and textures transactionally. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index e9544858..7c569bf6 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -127,26 +127,26 @@ Rasters attach only when shaping hash, glyph count, glyph-ID width, raster key, ## Baking and loading -| ID | Decision | Status | -| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------: | -| D-030 | Node and Worker hosts share one portable bake core. | Accepted | -| D-031 | The loader is baked-first and dynamically imports Worker fallback. | Accepted | -| D-032 | Omitted `baked` keeps the baked-first probe; explicit `baked: null` skips discovery and enters the source/runtime path without adding a second boolean policy. | Accepted | -| D-033 | In-memory deduplication is required; persistent bake caching is later. | Deferred | -| D-158 | Each maintained engine integration pairs a technique with its program through a technique-scoped subpath: `/three/bitmap`, `/three/mtsdf`, `/three/slug`, and the same shape under `/typegpu`. That subpath re-exports the portable technique and registers its engine program, so a consumer writes one import rather than two and a bundler drops the techniques an application never names. `/three` and `/typegpu` keep only technique-agnostic surface. Registration cannot live in `/raster/*`, which must never import a renderer. Deleting the merged-v0 surface exposed why this needs stating: registering every built-in program at `/three` module scope collapsed the three measured runtime graphs to within three bytes of each other, defeating the package's `sideEffects: false` declaration, and no document had specified the split it broke. | Accepted | -| D-156 | Font metrics bake underline position/thickness and strikeout position/size in v1 even though no renderer draws decorations yet. Those values live in the source `post` and OS/2 tables and are absent from the artifact today, so adding them after release would bump the artifact version and invalidate every font already baked. Carrying them costs a few bytes and no public API, which makes text decoration a purely additive renderer feature later. | Accepted | -| D-157 | Hyphenated justification defers to later work, but v1 first proves the shaping and layout contract can represent a hyphen the line breaker inserts at a break. Such a glyph has no source cluster, while every glyph today maps back to a UTF-16 cluster in the paragraph text. Language patterns, break selection, and justification quality controls are additive; the cluster invariant is not, so it is settled before the API freezes. | Accepted | +| ID | Decision | Status | +| ----- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------: | +| D-030 | Node and Worker hosts share one portable bake core. | Accepted | +| D-031 | The loader is baked-first and dynamically imports Worker fallback. | Accepted | +| D-032 | Omitted `baked` keeps the baked-first probe; explicit `baked: null` skips discovery and enters the source/runtime path without adding a second boolean policy. | Accepted | +| D-033 | In-memory deduplication is required; persistent bake caching is later. | Deferred | +| D-158 | Each maintained engine integration pairs a technique with its program through a technique-scoped subpath: `/three/bitmap`, `/three/mtsdf`, `/three/slug`, and the same shape under `/typegpu`. That subpath re-exports the portable technique and registers its engine program, so a consumer writes one import rather than two and a bundler drops the techniques an application never names. `/three` and `/typegpu` keep only technique-agnostic surface. Registration cannot live in `/raster/*`, which must never import a renderer. Deleting the merged-v0 surface exposed why this needs stating: registering every built-in program at `/three` module scope collapsed the three measured runtime graphs to within three bytes of each other, defeating the package's `sideEffects: false` declaration, and no document had specified the split it broke. | Accepted | +| D-156 | Font metrics bake underline position/thickness and strikeout position/size in v1 even though no renderer draws decorations yet. Those values live in the source `post` and OS/2 tables and are absent from the artifact today, so adding them after release would bump the artifact version and invalidate every font already baked. Carrying them costs a few bytes and no public API, which makes text decoration a purely additive renderer feature later. | Accepted | +| D-157 | Hyphenated justification defers to later work, but v1 first proves the shaping and layout contract can represent a hyphen the line breaker inserts at a break. Such a glyph has no source cluster, while every glyph today maps back to a UTF-16 cluster in the paragraph text. Language patterns, break selection, and justification quality controls are additive; the cluster invariant is not, so it is settled before the API freezes. | Accepted | | D-159 | Paragraph layout is tiered by what each product actually depends on, and a change enters at its own tier. Text analysis is decided by the text and its base direction; shaping adds the fonts, spans, and style topology; metrics add font size and spacing; the line plan adds the content box; geometry adds alignment. The batch previously collapsed all of it into one `needsShape` boolean and rebuilt the paragraph object per update, so its caches never answered and a width drag cost a cold build: at 25515 glyphs a resize, a reflow, a text edit, and a build from nothing all measured within noise of 131ms. A retained layout session and per-tier reuse separated them to 33.72ms and 27.98ms without changing a byte of layout output. Font fallback reads shaped glyph identity rather than laying the paragraph out to find `.notdef`, so selection cannot depend on where the text happened to wrap. | Accepted | -| D-160 | Layout performance claims come from `text:layout-benchmark`, which reports a median of warmed repetitions per invalidation class with its relative standard deviation, never an average across classes. Phase spans, not a sampling profiler, decide where the time goes: sampling reports self time, which fragments one phase across its callees and understated Unicode analysis as 7% where it was 24.6% inclusive, and V8 inlining moved the same chain's self time between the caller and the leaf across two runs of identical code. A sampling profile remains the right tool for finding a hot loop inside one phase. Every position axis accumulates in double precision and narrows once, because alignment and justification read the axis back; the mixed-direction golden caught that as a last-digit drift. | Accepted | -| D-034 | The integration proof generates one grayscale bitmap strike. | Accepted | -| D-035 | Raster modules and generators are optional imports. | Accepted | -| D-036 | Baked assets are data; baker surfaces are libraries/modules. | Accepted | -| D-066 | The CLI resolves baker modules through an imported or explicitly named package's flat `pmndrs.text` map and public ESM exports; package semver governs compatibility and the CLI never scans dependency directories. | Settled for V0 | -| D-071 | The Node baker statically discovers `defineFont` uses and literal raster descriptors without executing application code; dynamic font origins remain valid when an unambiguous local pathname can be resolved, otherwise runtime fallback remains authoritative. | Accepted | -| D-070 | Bitmap strike tuples are non-empty, duplicate-free static positive integer literals and are part of raster identity; a missing declared strike makes a baked raster incompatible. | Accepted | -| D-077 | The portable bake core ships one `wasm32-unknown-unknown` module behind a versioned JSON-described C ABI and direct linear-memory TypeScript shim; it ships no platform binaries, WASI dependency, Embind, or binding-generator runtime. | Settled for V0 | -| D-078 | The bake and shaping Wasm modules use `no_std + alloc`, aborting panics, and pinned ABI-private dynamic Talc 5.0.4. Across the optimized four-module corpus it saves 46,610 raw, 15,121 gzip, and 12,121 Brotli bytes relative to `dlmalloc` without changing initial-memory class or artifacts. A 128 MiB global arena is rejected because it saves no meaningful transfer bytes while raising initial memory to about 129 MiB and imposing a fixed ceiling. A request-local scratch arena remains eligible only with proven lifetime ownership outside persistent Worker state. Host code owns gzip/Brotli measurement. | Accepted | -| D-084 | Font format parsing and outline/metric interpretation use maintained Fontations `read-fonts` and `skrifa`; project code owns bake policy and artifact contracts, not a parallel OpenType parser or geometry engine. | Settled for V0 | +| D-160 | Layout performance claims come from `text:layout-benchmark`, which reports a median of warmed repetitions per invalidation class with its relative standard deviation, never an average across classes. Phase spans, not a sampling profiler, decide where the time goes: sampling reports self time, which fragments one phase across its callees and understated Unicode analysis as 7% where it was 24.6% inclusive, and V8 inlining moved the same chain's self time between the caller and the leaf across two runs of identical code. A sampling profile remains the right tool for finding a hot loop inside one phase. Every position axis accumulates in double precision and narrows once, because alignment and justification read the axis back; the mixed-direction golden caught that as a last-digit drift. | Accepted | +| D-034 | The integration proof generates one grayscale bitmap strike. | Accepted | +| D-035 | Raster modules and generators are optional imports. | Accepted | +| D-036 | Baked assets are data; baker surfaces are libraries/modules. | Accepted | +| D-066 | The CLI resolves baker modules through an imported or explicitly named package's flat `pmndrs.text` map and public ESM exports; package semver governs compatibility and the CLI never scans dependency directories. | Settled for V0 | +| D-071 | The Node baker statically discovers `defineFont` uses and literal raster descriptors without executing application code; dynamic font origins remain valid when an unambiguous local pathname can be resolved, otherwise runtime fallback remains authoritative. | Accepted | +| D-070 | Bitmap strike tuples are non-empty, duplicate-free static positive integer literals and are part of raster identity; a missing declared strike makes a baked raster incompatible. | Accepted | +| D-077 | The portable bake core ships one `wasm32-unknown-unknown` module behind a versioned JSON-described C ABI and direct linear-memory TypeScript shim; it ships no platform binaries, WASI dependency, Embind, or binding-generator runtime. | Settled for V0 | +| D-078 | The bake and shaping Wasm modules use `no_std + alloc`, aborting panics, and pinned ABI-private dynamic Talc 5.0.4. Across the optimized four-module corpus it saves 46,610 raw, 15,121 gzip, and 12,121 Brotli bytes relative to `dlmalloc` without changing initial-memory class or artifacts. A 128 MiB global arena is rejected because it saves no meaningful transfer bytes while raising initial memory to about 129 MiB and imposing a fixed ceiling. A request-local scratch arena remains eligible only with proven lifetime ownership outside persistent Worker state. Host code owns gzip/Brotli measurement. | Accepted | +| D-084 | Font format parsing and outline/metric interpretation use maintained Fontations `read-fonts` and `skrifa`; project code owns bake policy and artifact contracts, not a parallel OpenType parser or geometry engine. | Settled for V0 | The [architecture](architecture.md) owns loading behavior and dependency rules. The [API contract](api-shapes.md) owns host and Worker shapes. @@ -224,8 +224,9 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | | D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | | D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | -| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | -| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | +| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | +| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | +| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 2bc805aa..e43c634a 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -186,12 +186,12 @@ That evidence changes several claims in the earlier draft: On the current branch, the pinned `text:layout-benchmark -- --glyphs 22000` workload renders 25,515 glyphs and measured: -| Invalidation | Median | p95 | Relative to 8.33 ms | -| --- | ---: | ---: | ---: | -| cold | 53.78 ms | 73.83 ms | 6.5× median | -| font size | 12.50 ms | 17.75 ms | 1.5× median | -| layout width | 9.38 ms | 13.69 ms | 1.1× median | -| text edit | 39.11 ms | 41.72 ms | 4.7× median | +| Invalidation | Median | p95 | Relative to 8.33 ms | +| ------------ | -------: | -------: | ------------------: | +| cold | 53.78 ms | 73.83 ms | 6.5× median | +| font size | 12.50 ms | 17.75 ms | 1.5× median | +| layout width | 9.38 ms | 13.69 ms | 1.1× median | +| text edit | 39.11 ms | 41.72 ms | 4.7× median | The warm lanes showed 12.7–15.4% relative standard deviation in that run. These are a local baseline, not a universal forecast, but they establish that the present full-paragraph warm path does not synchronously meet 120 Hz. @@ -447,21 +447,21 @@ The engine API should expose explicit typed values rather than cloning CSS strin need a published reference. The initial contract is organized as capabilities so unsupported combinations fail or report a fallback instead of silently approximating them. -| Area | Required engine behavior | -| --- | --- | -| Fonts and runs | language and script, fallback, OpenType features, horizontal and vertical metrics, baselines; static fonts only | -| Span positioning | explicit baseline shift, superscript/subscript positioning, and OpenType `sups`/`subs` features without a second text stream | -| Spacing | letter spacing, word spacing, line height, paragraph space before/after, first-line and hanging indents, spacing in logical axes | -| Tabs | authored left/right/center/decimal stops and bounded leader glyphs; the decimal alignment character is explicit rather than supplied by a locale database | -| Breaking | word/character/no-wrap, whitespace handling, explicit soft hyphen and inserted-hyphen provenance | -| Alignment | logical start/end/center, script-aware justification opportunities, hanging punctuation | -| Writing modes | horizontal and both vertical progressions, mixed/upright/sideways orientation, vertical substitutions and origins | -| Decorations | underline, overline, and line-through; color, thickness, offset, solid/double/dotted/dashed/wavy style, skip spaces, and bounded ink-box skipping | -| Editorial flow | multiple regions and sequential columns, exclusions with multiple slots per band, inline objects, drop caps, forced breaks | -| Pagination | explicit page/column breaks and resume tokens; no balancing or widow/orphan/keep solver | -| CJK emphasis | emphasis marks and short horizontal runs in vertical text; ruby and warichu are out of scope | -| Emoji | Unicode 17 grapheme, variation-selector, modifier, flag, tag, and ZWJ behavior through the ordinary font-fallback and shaping path; optional color art remains a raster resource | -| Interaction | logical/visual ranges, cluster maps, caret stops, hit testing, selection geometry, accessibility reading order | +| Area | Required engine behavior | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Fonts and runs | language and script, fallback, OpenType features, horizontal and vertical metrics, baselines; static fonts only | +| Span positioning | explicit baseline shift, superscript/subscript positioning, and OpenType `sups`/`subs` features without a second text stream | +| Spacing | letter spacing, word spacing, line height, paragraph space before/after, first-line and hanging indents, spacing in logical axes | +| Tabs | authored left/right/center/decimal stops and bounded leader glyphs; the decimal alignment character is explicit rather than supplied by a locale database | +| Breaking | word/character/no-wrap, whitespace handling, explicit soft hyphen and inserted-hyphen provenance | +| Alignment | logical start/end/center, script-aware justification opportunities, hanging punctuation | +| Writing modes | horizontal and both vertical progressions, mixed/upright/sideways orientation, vertical substitutions and origins | +| Decorations | underline, overline, and line-through; color, thickness, offset, solid/double/dotted/dashed/wavy style, skip spaces, and bounded ink-box skipping | +| Editorial flow | multiple regions and sequential columns, exclusions with multiple slots per band, inline objects, drop caps, forced breaks | +| Pagination | explicit page/column breaks and resume tokens; no balancing or widow/orphan/keep solver | +| CJK emphasis | emphasis marks and short horizontal runs in vertical text; ruby and warichu are out of scope | +| Emoji | Unicode 17 grapheme, variation-selector, modifier, flag, tag, and ZWJ behavior through the ordinary font-fallback and shaping path; optional color art remains a raster resource | +| Interaction | logical/visual ranges, cluster maps, caret stops, hit testing, selection geometry, accessibility reading order | Word spacing applies to identified word separators, while letter spacing operates between typographic character units after shaping and bidi ordering; nonzero letter spacing also interacts with optional ligatures. Justification cannot be @@ -483,16 +483,16 @@ horizontal metrics, dense glyph extents, and optional `BASE`, `VORG`, `vhea`, an Measured source-table sizes for the repository fixtures establish the relevant bound: -| Capability | Per-font shaping-data effect | -| --- | --- | -| Word/letter spacing, breaking, regions, exclusions, and sequential flow | zero bytes | -| Tate-chu-yoko | zero bytes; uses existing glyphs and existing GSUB width features when available | -| Emphasis marks | zero bytes; uses an ordinary shaped/cached mark glyph | -| Underline and strike | at most four extracted `i16` metrics, eight raw numeric bytes before container overhead | -| Bounded ink-box skipping | zero bytes; reuses the existing eight-byte dense extent record per glyph | -| Vertical shaping, Inter and Amiri fixtures | zero bytes; source fonts have no vertical tables and use the declared fallback | -| Vertical shaping, Noto Sans CJK JP | `vmtx` 261,386 + `vhea` 36 + `VORG` 920 = 262,342 raw bytes, about 36.8 KiB when the three source tables are Brotli-compressed independently; already retained today | -| `BASE` in Noto Sans CJK JP | 240 raw bytes; already retained and not exclusively vertical | +| Capability | Per-font shaping-data effect | +| ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Word/letter spacing, breaking, regions, exclusions, and sequential flow | zero bytes | +| Tate-chu-yoko | zero bytes; uses existing glyphs and existing GSUB width features when available | +| Emphasis marks | zero bytes; uses an ordinary shaped/cached mark glyph | +| Underline and strike | at most four extracted `i16` metrics, eight raw numeric bytes before container overhead | +| Bounded ink-box skipping | zero bytes; reuses the existing eight-byte dense extent record per glyph | +| Vertical shaping, Inter and Amiri fixtures | zero bytes; source fonts have no vertical tables and use the declared fallback | +| Vertical shaping, Noto Sans CJK JP | `vmtx` 261,386 + `vhea` 36 + `VORG` 920 = 262,342 raw bytes, about 36.8 KiB when the three source tables are Brotli-compressed independently; already retained today | +| `BASE` in Noto Sans CJK JP | 240 raw bytes; already retained and not exclusively vertical | Vertical layout therefore adds no new per-font bytes to the current artifact contract; it begins consuming data already preserved. Underline metrics must be extracted from `post`, not retained by adding the whole table: Inter's source @@ -568,20 +568,20 @@ reshapes and fits a tagged short run as one vertical inline atom. Both belong in The product scope is therefore: -| Capability | When it is used | Initial scope | Cost control | -| --- | --- | --- | --- | -| Japanese line-start/end restrictions and tailoring | ordinary Japanese horizontal and vertical prose | include | compact rule/table delta; same line-break vectors plus tailored cases | -| Tate-chu-yoko | short dates, counters, page numbers, and occasional Latin inside vertical text | include | bounded tagged runs; no general nested layout | -| Emphasis marks | CJK emphasis where italics are inappropriate | include with decorations | cached mark shape; one extra primitive only where applied | -| Ruby | educational text, names, pronunciation guides, translations, manga, and specialist CJK publishing | cut | no base/annotation model or nested shaping stream | -| Warichu | compact Japanese parenthetical notes set as two small lines inside one line | cut | no nested inline line-layout model | -| Automatic language hyphenation | narrow justified columns in language-aware publishing | cut | no dictionaries, data-pack ABI, or language hyphenation algorithm; explicit soft hyphens remain | -| Balanced columns | final newspaper, magazine, and page composition | cut | columns fill sequentially; applications may choose region geometry externally | -| Widow/orphan and keep constraints | page/column finalization | cut | explicit page/column breaks and resume tokens remain | -| Automatic footnotes and sidenotes | page-coupled notes and scholarly annotations | cut | applications manually compose note text in independent regions; no second text channel or coupled page solver enters the engine | -| OpenType math layout | formulas, stretchy operators, fractions, scripts, and equation structure | cut | the `MATH` table is not a complete math-layout specification and would require a separate recursive box engine | -| Text on a path | labels following arbitrary curves | cut | no arc-length mapping, tangent placement, curve-aware interaction, or path-decoration system in the core | -| OpenType-SVG glyph paint | SVG-authored color glyphs and icons | cut | no XML/SVG parser, DOM, scripting, animation, filter, or external-resource runtime; color emoji uses the bounded bitmap companion | +| Capability | When it is used | Initial scope | Cost control | +| -------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | +| Japanese line-start/end restrictions and tailoring | ordinary Japanese horizontal and vertical prose | include | compact rule/table delta; same line-break vectors plus tailored cases | +| Tate-chu-yoko | short dates, counters, page numbers, and occasional Latin inside vertical text | include | bounded tagged runs; no general nested layout | +| Emphasis marks | CJK emphasis where italics are inappropriate | include with decorations | cached mark shape; one extra primitive only where applied | +| Ruby | educational text, names, pronunciation guides, translations, manga, and specialist CJK publishing | cut | no base/annotation model or nested shaping stream | +| Warichu | compact Japanese parenthetical notes set as two small lines inside one line | cut | no nested inline line-layout model | +| Automatic language hyphenation | narrow justified columns in language-aware publishing | cut | no dictionaries, data-pack ABI, or language hyphenation algorithm; explicit soft hyphens remain | +| Balanced columns | final newspaper, magazine, and page composition | cut | columns fill sequentially; applications may choose region geometry externally | +| Widow/orphan and keep constraints | page/column finalization | cut | explicit page/column breaks and resume tokens remain | +| Automatic footnotes and sidenotes | page-coupled notes and scholarly annotations | cut | applications manually compose note text in independent regions; no second text channel or coupled page solver enters the engine | +| OpenType math layout | formulas, stretchy operators, fractions, scripts, and equation structure | cut | the `MATH` table is not a complete math-layout specification and would require a separate recursive box engine | +| Text on a path | labels following arbitrary curves | cut | no arc-length mapping, tangent placement, curve-aware interaction, or path-decoration system in the core | +| OpenType-SVG glyph paint | SVG-authored color glyphs and icons | cut | no XML/SVG parser, DOM, scripting, animation, filter, or external-resource runtime; color emoji uses the bounded bitmap companion | The cut features reserve no runtime tables, optional dictionaries, policy opcodes, semantic records, or implementation stages. More generally, the core never automatically lays out a second authored text channel beside the selected prose. @@ -754,16 +754,16 @@ milestone. ### Kernel map -| Kernel | Lane shape | Planned treatment | -| --- | --- | --- | -| Built-in Bitmap/MSDF/Slug packing | four independent `f32`/`u32` records | explicit four-lane load/transform/store; strongest first admission candidate | -| Declarative policy transforms | four independent semantic records | vector bytecode/graph execution so dispatch is amortized across four records | -| Bidi-level transitions | sixteen `u8` levels | compare shifted contiguous levels and extract a bitmask | -| Break and cluster flags | sixteen `u8` flags | vector masks plus `bitmask`/trailing-zero candidate selection | -| Patch verification/coalescing | sixteen bytes or four words | `v128` compare on already invalidated spans; never scan the whole live buffer as the primary algorithm | -| Glyph-to-cluster aggregation | repeated cluster IDs and scatter writes | reshape once into cluster-contiguous runs; core Wasm SIMD has no gather/scatter instruction | -| Line-width search | chunk summaries plus ordered boundary scan | skip summary blocks; preserve scalar addition order at the exact width boundary | -| Decoration bounds/packing | four independent segments | vectorize bounds and physical packing after line fragmentation is fixed | +| Kernel | Lane shape | Planned treatment | +| --------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------------------ | +| Built-in Bitmap/MSDF/Slug packing | four independent `f32`/`u32` records | explicit four-lane load/transform/store; strongest first admission candidate | +| Declarative policy transforms | four independent semantic records | vector bytecode/graph execution so dispatch is amortized across four records | +| Bidi-level transitions | sixteen `u8` levels | compare shifted contiguous levels and extract a bitmask | +| Break and cluster flags | sixteen `u8` flags | vector masks plus `bitmask`/trailing-zero candidate selection | +| Patch verification/coalescing | sixteen bytes or four words | `v128` compare on already invalidated spans; never scan the whole live buffer as the primary algorithm | +| Glyph-to-cluster aggregation | repeated cluster IDs and scatter writes | reshape once into cluster-contiguous runs; core Wasm SIMD has no gather/scatter instruction | +| Line-width search | chunk summaries plus ordered boundary scan | skip summary blocks; preserve scalar addition order at the exact width boundary | +| Decoration bounds/packing | four independent segments | vectorize bounds and physical packing after line fragmentation is fixed | Line-breaking correctness forbids changing floating-point association. Core WebAssembly SIMD provides deterministic `v128` integer and float operations, but relaxed-SIMD operations permit implementation-dependent results and are not @@ -801,6 +801,17 @@ by more than 2%. Its production Brotli delta is capped at the smaller of 12 KiB separate decision records stronger end-to-end evidence. These thresholds intentionally reject the repository's prior MTSDF pattern of double-digit code growth for low-single-digit bounded gains. +The first packet checkpoint selects 64-cluster, 16-byte-aligned SoA storage. Across real 25,515- and 100,602-glyph +arrays, scalar, compiler-vectorized, and selected hybrid artifacts produced identical horizontal, vertical, partial-tail, +and four-byte-aligned output hashes in Node 24.18.0 and Chromium 149 without warm allocation or memory growth. Compiler +vectorization, not hand-written shuffles, owns straight-line record packing. Explicit `i8x16` break/bidi masks and +integer-exact `i64x2` summaries exceeded the 20% phase threshold; 64-cluster summaries won the large workload against +32 and 128. The selected lab artifact adds 1,652 raw / 1,158 Brotli bytes over scalar, while the standard production +`+simd128` build is 289 raw / 26 Brotli bytes smaller than its same-ABI scalar release-valve build. The optimized +disassembly contains the intended vector loads, stores, bitmasks, shuffles, and integer lanes. Policy execution, +boundary search, representative native SIMD, and end-to-end contribution remain open parts of the packet because their +production stages do not exist yet; they are not inferred from these admitted kernels. + ## Implementation stack Each stage is a small Conventional Commit series with unchanged fixtures and an independently reviewable invariant. @@ -924,53 +935,70 @@ pipeline before adding new publishing features. The single ABI is its final cuto interface. It is retained as a release valve and published only when an actual consumer requires the older browser tail; SIMD remains the default build and package. -[^css-text]: [CSS Text Level 4](https://www.w3.org/TR/css-text-4/) defines the relevant spacing, hanging-punctuation, +[^css-text]: + [CSS Text Level 4](https://www.w3.org/TR/css-text-4/) defines the relevant spacing, hanging-punctuation, and justification concepts. The engine API need not duplicate CSS syntax. -[^harfbuzz]: [HarfBuzz buffer flags](https://harfbuzz.github.io/harfbuzz-hb-buffer.html) define safe-to-insert and +[^harfbuzz]: + [HarfBuzz buffer flags](https://harfbuzz.github.io/harfbuzz-hb-buffer.html) define safe-to-insert and unsafe-to-concatenate boundaries used for narrowed reshaping. -[^safari-simd]: [WebKit's Safari 16.4 release notes](https://webkit.org/blog/13966/webkit-features-in-safari-16-4/) +[^safari-simd]: + [WebKit's Safari 16.4 release notes](https://webkit.org/blog/13966/webkit-features-in-safari-16-4/) record the addition of WebAssembly 128-bit SIMD. -[^chrome-simd]: [The Chromium 91 release announcement](https://blog.chromium.org/2021/04/chrome-91-handwriting-recognition-webxr.html) +[^chrome-simd]: + [The Chromium 91 release announcement](https://blog.chromium.org/2021/04/chrome-91-handwriting-recognition-webxr.html) records WebAssembly SIMD becoming enabled by default. -[^firefox-simd]: [Mozilla's WebAssembly SIMD shipping record](https://bugzilla.mozilla.org/show_bug.cgi?id=1625130) +[^firefox-simd]: + [Mozilla's WebAssembly SIMD shipping record](https://bugzilla.mozilla.org/show_bug.cgi?id=1625130) records Firefox 89 for x86/x64, Firefox 90 for arm64, and no planned arm32 or mips64 implementation. -[^icu4x]: [`icu_segmenter` documentation](https://docs.rs/icu_segmenter/latest/icu_segmenter/) currently documents +[^icu4x]: + [`icu_segmenter` documentation](https://docs.rs/icu_segmenter/latest/icu_segmenter/) currently documents Unicode 15.1 line-break data, so it cannot replace this repository's Unicode 17 gate without new evidence. -[^jlreq]: [JLREQ](https://www.w3.org/TR/jlreq/) documents Japanese vertical composition, punctuation, ruby, emphasis, +[^jlreq]: + [JLREQ](https://www.w3.org/TR/jlreq/) documents Japanese vertical composition, punctuation, ruby, emphasis, and line-start/end restrictions. -[^parley]: [Parley layout](https://docs.rs/parley/latest/parley/layout/) demonstrates retained rich-text layout, +[^parley]: + [Parley layout](https://docs.rs/parley/latest/parley/layout/) demonstrates retained rich-text layout, decorations, cursor/selection data, and line-breaking iteration in a current Rust implementation. -[^pretext]: [Pretext](https://github.com/chenglou/pretext) demonstrates a prepared text object advanced by a cursor and +[^pretext]: + [Pretext](https://github.com/chenglou/pretext) demonstrates a prepared text object advanced by a cursor and a different width for each line; its Canvas measurement model is not adopted here. -[^ruby]: [CSS Ruby Annotation Layout Level 1](https://www.w3.org/TR/css-ruby-1/) defines base/annotation pairing, +[^ruby]: + [CSS Ruby Annotation Layout Level 1](https://www.w3.org/TR/css-ruby-1/) defines base/annotation pairing, levels, positioning, merging, and distribution that make ruby a coupled second layout stream. -[^rust-simd]: [Rust's stable `wasm32` architecture documentation](https://doc.rust-lang.org/core/arch/wasm32/index.html) +[^rust-simd]: + [Rust's stable `wasm32` architecture documentation](https://doc.rust-lang.org/core/arch/wasm32/index.html) documents `simd128` intrinsics, compilation requirements, and the lack of in-module runtime feature detection. -[^staging]: [`wgpu::util::StagingBelt`](https://docs.rs/wgpu/latest/wgpu/util/struct.StagingBelt.html) is an example of +[^staging]: + [`wgpu::util::StagingBelt`](https://docs.rs/wgpu/latest/wgpu/util/struct.StagingBelt.html) is an example of renderer-owned reuse for many buffer writes; it is separate from Wasm result publication. -[^text-decoration]: [CSS Text Decoration Level 4](https://www.w3.org/TR/css-text-decor-4/) defines the decoration +[^text-decoration]: + [CSS Text Decoration Level 4](https://www.w3.org/TR/css-text-decor-4/) defines the decoration dimensions and skip behavior used as the semantic reference. -[^webrender]: [Firefox's rendering overview](https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html) +[^webrender]: + [Firefox's rendering overview](https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html) separates retained display-list intent from renderer-specific scene building and submission. -[^wasm-simd]: [The WebAssembly core specification](https://webassembly.github.io/spec/core/) defines fixed-width +[^wasm-simd]: + [The WebAssembly core specification](https://webassembly.github.io/spec/core/) defines fixed-width `v128` operations and records that relaxed operations may have implementation-dependent results. -[^worker-transfer]: [The HTML Worker model](https://html.spec.whatwg.org/multipage/workers.html) defines worker +[^worker-transfer]: + [The HTML Worker model](https://html.spec.whatwg.org/multipage/workers.html) defines worker `postMessage` transfer lists used to move `ArrayBuffer` ownership between worker and root. -[^writing-modes]: [CSS Writing Modes Level 4](https://www.w3.org/TR/css-writing-modes-4/) defines logical inline/block +[^writing-modes]: + [CSS Writing Modes Level 4](https://www.w3.org/TR/css-writing-modes-4/) defines logical inline/block directions and distinguishes writing mode from glyph orientation. diff --git a/packages/text/rust/shaper/Cargo.toml b/packages/text/rust/shaper/Cargo.toml index 82ef7d70..b4c780d8 100644 --- a/packages/text/rust/shaper/Cargo.toml +++ b/packages/text/rust/shaper/Cargo.toml @@ -18,6 +18,8 @@ required-features = ["std"] [features] default = ["std"] std = ["serde_json/std"] +kernel-lab = [] +simd128 = [] [dependencies] harfrust = { version = "0.12.0", default-features = false, features = ["libm"] } diff --git a/packages/text/rust/shaper/evidence/kernel-layout-v0.json b/packages/text/rust/shaper/evidence/kernel-layout-v0.json new file mode 100644 index 00000000..03bd8b5e --- /dev/null +++ b/packages/text/rust/shaper/evidence/kernel-layout-v0.json @@ -0,0 +1,108 @@ +{ + "schemaVersion": 0, + "generatedBy": "text:kernel-lab + text:kernel-lab-browser", + "recordedAt": "2026-08-08", + "target": { + "machine": "Apple M2 Pro, 10 cores, 32 GiB", + "node": "24.18.0", + "chromium": "149.0.7827.55", + "rust": "1.97.1", + "wasmOptimizer": "binaryen 129.0.0" + }, + "workloads": [ + { + "glyphs": 25515, + "outputSha256": "84ba1678f750f7fc35ff5e665aa5cb07585734aa12357ab55ee499208b2e3e5e" + }, + { + "glyphs": 100602, + "outputSha256": "42e3c29bf66c1f18caea36ec2156ee32a38d09e797bc8cea453433c02f8ff84e" + } + ], + "correctness": { + "scalarAutoAndSelectedHashesEqual": true, + "horizontalVerticalAndPartialChunksCovered": true, + "alignedAndFourByteAlignedOutputsEqual": true, + "warmMemoryGrowth": false, + "warmAllocationCalls": 0, + "allocationEvidence": "The measured kernel entry points create only borrowed slices over caller-owned memory and contain no allocation path; the warm memory buffer identity also remains unchanged." + }, + "artifactBytes": { + "kernelLab": { + "scalar": { "raw": 728860, "brotli": 211729 }, + "auto": { "raw": 729828, "brotli": 212390 }, + "selectedHybrid": { "raw": 730512, "brotli": 212887 } + }, + "production": { + "scalar": { "raw": 725302, "gzip": 267165, "brotli": 210867 }, + "simd128": { "raw": 725013, "gzip": 266615, "brotli": 210841 } + } + }, + "optimizedSelectedInstructions": { + "v128Loads": 649, + "v128Stores": 723, + "f32x4Operations": 18, + "i8x16Bitmasks": 3, + "i8x16Shuffles": 35, + "i64x2Operations": 28 + }, + "p95Milliseconds": { + "node": { + "25515": { + "scalar": { "pack": 0.043445, "breakMasks": 0.013195, "bidiMasks": 0.010682, "chunk64": 0.015655 }, + "auto": { "pack": 0.013099, "breakMasks": 0.01123, "bidiMasks": 0.01386, "chunk64": 0.008819 }, + "selectedHybrid": { "pack": 0.01306, "breakMasks": 0.001452, "bidiMasks": 0.002551, "chunk64": 0.004156 } + }, + "100602": { + "scalar": { "pack": 0.177563, "breakMasks": 0.04904, "bidiMasks": 0.041621, "chunk64": 0.05982 }, + "auto": { "pack": 0.058224, "breakMasks": 0.045216, "bidiMasks": 0.053385, "chunk64": 0.033901 }, + "selectedHybrid": { "pack": 0.062641, "breakMasks": 0.005815, "bidiMasks": 0.010034, "chunk64": 0.014711 } + } + }, + "chromium": { + "25515": { + "scalar": { "pack": 0.04375, "breakMasks": 0.014063, "bidiMasks": 0.010938, "chunk64": 0.01875 }, + "auto": { "pack": 0.01875, "breakMasks": 0.010938, "bidiMasks": 0.0125, "chunk64": 0.0125 }, + "selectedHybrid": { "pack": 0.01875, "breakMasks": 0.003125, "bidiMasks": 0.003125, "chunk64": 0.00625 } + }, + "100602": { + "scalar": { "pack": 0.175, "breakMasks": 0.053125, "bidiMasks": 0.046875, "chunk64": 0.06875 }, + "auto": { "pack": 0.075, "breakMasks": 0.04375, "bidiMasks": 0.05, "chunk64": 0.0375 }, + "selectedHybrid": { "pack": 0.0625, "breakMasks": 0.009375, "bidiMasks": 0.0125, "chunk64": 0.01875 } + } + } + }, + "endToEndCheckpoint": { + "command": "pnpm scripts run text:layout-benchmark -- --glyphs 22000", + "warmup": 8, + "samples": 31, + "newKernelsWired": false, + "cases": { + "cold": { "glyphs": 25515, "medianMs": 55.05, "p95Ms": 69.71 }, + "fontSize": { "glyphs": 25515, "medianMs": 12.1, "p95Ms": 14.82 }, + "layoutWidth": { "glyphs": 25515, "medianMs": 8.3, "p95Ms": 11.08 }, + "text": { "glyphs": 25507, "medianMs": 38.59, "p95Ms": 41.28 } + }, + "interpretation": "This remains the TypeScript layout pipeline, so the admitted kernels make no end-to-end performance claim yet." + }, + "selection": { + "retainedChunkClusters": 64, + "packing": "compiler-vectorizable straight-line scalar source compiled with +simd128", + "breakAndBidiMasks": "explicit simd128 with scalar tails", + "chunkSummaries": "explicit simd128 integer sums and masks with scalar tails", + "standardArtifact": "simd128", + "compatibilityDefine": "PMNDRS_TEXT_SHAPER_SIMD=0", + "runtimeDispatch": false, + "rejected": [ + "hand-written four-record packing, because it did not beat compiler vectorization and regressed the 100k Node result", + "32-cluster chunks, because their large-workload summary time exceeded 64", + "128-cluster chunks, because their large-workload summary time exceeded 64" + ], + "remainingPacket": [ + "policy-execution kernels after the scalar render-plan executor exists", + "boundary search after retained line cursors and summaries exist", + "representative native SIMD implementation and measurement", + "end-to-end hot-update contribution after the Rust semantic pipeline is wired" + ] + } +} diff --git a/packages/text/rust/shaper/src/engine/kernel_lab.rs b/packages/text/rust/shaper/src/engine/kernel_lab.rs new file mode 100644 index 00000000..2e5eb18f --- /dev/null +++ b/packages/text/rust/shaper/src/engine/kernel_lab.rs @@ -0,0 +1,514 @@ +//! Test-only kernels used to choose the retained engine's data layout before semantic storage lands. + +#[cfg(target_arch = "wasm32")] +use core::{mem, slice}; + +#[cfg(target_arch = "wasm32")] +use crate::STATUS_INVALID_REQUEST; + +const MAX_RECORDS: usize = 1_000_000; +const VALID_CHUNK_SIZES: [usize; 3] = [32, 64, 128]; + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +pub(crate) const BACKEND: u32 = 1; +#[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] +pub(crate) const BACKEND: u32 = 0; + +#[allow(clippy::too_many_arguments)] +pub(crate) fn pack_origins_and_sizes( + x: &[f32], + y: &[f32], + font_size: &[f32], + plane_left: &[f32], + plane_bottom: &[f32], + plane_right: &[f32], + plane_top: &[f32], + inverse_units_per_em: f32, + origins: &mut [f32], + sizes: &mut [f32], +) { + debug_assert_eq!(x.len(), y.len()); + debug_assert_eq!(x.len(), font_size.len()); + debug_assert_eq!(x.len(), plane_left.len()); + debug_assert_eq!(x.len(), plane_bottom.len()); + debug_assert_eq!(x.len(), plane_right.len()); + debug_assert_eq!(x.len(), plane_top.len()); + debug_assert_eq!(origins.len(), x.len() * 2); + debug_assert_eq!(sizes.len(), x.len() * 2); + + // This straight-line loop is deliberately shared by both builds. With `+simd128`, LLVM + // vectorizes it more efficiently than the rejected hand-written shuffle/store candidate. + pack_scalar( + 0, + x, + y, + font_size, + plane_left, + plane_bottom, + plane_right, + plane_top, + inverse_units_per_em, + origins, + sizes, + ); +} + +#[allow(clippy::too_many_arguments)] +fn pack_scalar( + start: usize, + x: &[f32], + y: &[f32], + font_size: &[f32], + plane_left: &[f32], + plane_bottom: &[f32], + plane_right: &[f32], + plane_top: &[f32], + inverse_units_per_em: f32, + origins: &mut [f32], + sizes: &mut [f32], +) { + for index in start..x.len() { + let scale = font_size[index] * inverse_units_per_em; + let output = index * 2; + origins[output] = x[index] + plane_left[index] * scale; + origins[output + 1] = y[index] - plane_top[index] * scale; + sizes[output] = (plane_right[index] - plane_left[index]) * scale; + sizes[output + 1] = (plane_top[index] - plane_bottom[index]) * scale; + } +} + +pub(crate) fn break_masks(flags: &[u8], output: &mut [u16]) { + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let completed = unsafe { break_masks_simd(flags, output) }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let completed = 0; + + for (block, output_mask) in output.iter_mut().enumerate().skip(completed / 16) { + let start = block * 16; + let end = flags.len().min(start + 16); + let mut mask = 0_u16; + for (lane, flag) in flags[start..end].iter().enumerate() { + if flag & 1 != 0 { + mask |= 1 << lane; + } + } + *output_mask = mask; + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn break_masks_simd(flags: &[u8], output: &mut [u16]) -> usize { + use core::arch::wasm32::{i8x16_bitmask, i8x16_ne, u8x16_splat, v128, v128_and, v128_load}; + + let completed = flags.len() & !15; + for start in (0..completed).step_by(16) { + // SAFETY: `completed` includes only full sixteen-byte blocks. + let values = unsafe { v128_load(flags.as_ptr().add(start).cast::()) }; + let allowed = i8x16_ne(v128_and(values, u8x16_splat(1)), u8x16_splat(0)); + output[start / 16] = i8x16_bitmask(allowed); + } + completed +} + +pub(crate) fn bidi_transition_masks(levels: &[u8], output: &mut [u16]) { + let mut previous = 0_u8; + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let completed = unsafe { bidi_masks_simd(levels, output, &mut previous) }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let completed = 0; + + for (block, output_mask) in output.iter_mut().enumerate().skip(completed / 16) { + let start = block * 16; + let end = levels.len().min(start + 16); + let mut mask = 0_u16; + for (lane, level) in levels[start..end].iter().copied().enumerate() { + if level != previous { + mask |= 1 << lane; + } + previous = level; + } + *output_mask = mask; + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn bidi_masks_simd(levels: &[u8], output: &mut [u16], previous: &mut u8) -> usize { + use core::arch::wasm32::{ + i8x16_bitmask, i8x16_ne, i8x16_shuffle, u8x16_splat, v128, v128_load, + }; + + let completed = levels.len() & !15; + for start in (0..completed).step_by(16) { + // SAFETY: `completed` includes only full sixteen-byte blocks. + let values = unsafe { v128_load(levels.as_ptr().add(start).cast::()) }; + let prior = u8x16_splat(*previous); + let shifted = i8x16_shuffle::<0, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30>( + prior, values, + ); + output[start / 16] = i8x16_bitmask(i8x16_ne(values, shifted)); + *previous = levels[start + 15]; + } + completed +} + +pub(crate) fn chunk_summaries( + advances: &[i32], + flags: &[u8], + chunk_size: usize, + advance_sums: &mut [i64], + break_counts: &mut [u32], +) { + debug_assert!(VALID_CHUNK_SIZES.contains(&chunk_size)); + for chunk in 0..advance_sums.len() { + let start = chunk * chunk_size; + let end = advances.len().min(start + chunk_size); + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let (completed, mut advance_sum, mut break_count) = + unsafe { chunk_summary_simd(&advances[start..end], &flags[start..end]) }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let (completed, mut advance_sum, mut break_count) = (0, 0_i64, 0_u32); + + for index in completed..end - start { + advance_sum += i64::from(advances[start + index]); + break_count += u32::from(flags[start + index] & 1 != 0); + } + advance_sums[chunk] = advance_sum; + break_counts[chunk] = break_count; + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn chunk_summary_simd(advances: &[i32], flags: &[u8]) -> (usize, i64, u32) { + use core::arch::wasm32::{ + i8x16_bitmask, i8x16_ne, i64x2_add, i64x2_extend_high_i32x4, i64x2_extend_low_i32x4, + i64x2_extract_lane, i64x2_splat, u8x16_splat, v128, v128_and, v128_load, + }; + + let completed = advances.len() & !15; + let mut sums = i64x2_splat(0); + let mut break_count = 0_u32; + for start in (0..completed).step_by(16) { + for lane_group in 0..4 { + // SAFETY: each group contains four in-bounds i32 values. + let values = + unsafe { v128_load(advances.as_ptr().add(start + lane_group * 4).cast::()) }; + sums = i64x2_add(sums, i64x2_extend_low_i32x4(values)); + sums = i64x2_add(sums, i64x2_extend_high_i32x4(values)); + } + // SAFETY: `completed` includes only full sixteen-byte blocks. + let values = unsafe { v128_load(flags.as_ptr().add(start).cast::()) }; + let allowed = i8x16_ne(v128_and(values, u8x16_splat(1)), u8x16_splat(0)); + break_count += i8x16_bitmask(allowed).count_ones(); + } + ( + completed, + i64x2_extract_lane::<0>(sums) + i64x2_extract_lane::<1>(sums), + break_count, + ) +} + +#[cfg(target_arch = "wasm32")] +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn exported_pack( + count: u32, + x_pointer: u32, + y_pointer: u32, + font_size_pointer: u32, + plane_left_pointer: u32, + plane_bottom_pointer: u32, + plane_right_pointer: u32, + plane_top_pointer: u32, + inverse_units_per_em: f32, + origins_pointer: u32, + sizes_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let Some(pair_count) = count.checked_mul(2) else { + return STATUS_INVALID_REQUEST; + }; + let regions = [ + region::(x_pointer, count), + region::(y_pointer, count), + region::(font_size_pointer, count), + region::(plane_left_pointer, count), + region::(plane_bottom_pointer, count), + region::(plane_right_pointer, count), + region::(plane_top_pointer, count), + region::(origins_pointer, pair_count), + region::(sizes_pointer, pair_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: all typed regions are aligned, in linear memory, and pairwise disjoint. + unsafe { + pack_origins_and_sizes( + typed_slice(x_pointer, count), + typed_slice(y_pointer, count), + typed_slice(font_size_pointer, count), + typed_slice(plane_left_pointer, count), + typed_slice(plane_bottom_pointer, count), + typed_slice(plane_right_pointer, count), + typed_slice(plane_top_pointer, count), + inverse_units_per_em, + typed_slice_mut(origins_pointer, pair_count), + typed_slice_mut(sizes_pointer, pair_count), + ); + } + 0 +} + +#[cfg(target_arch = "wasm32")] +pub(crate) unsafe fn exported_break_masks( + count: u32, + flags_pointer: u32, + output_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let output_count = count.div_ceil(16); + let regions = [ + region::(flags_pointer, count), + region::(output_pointer, output_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: both typed regions are aligned, in linear memory, and disjoint. + unsafe { + break_masks( + typed_slice(flags_pointer, count), + typed_slice_mut(output_pointer, output_count), + ) + }; + 0 +} + +#[cfg(target_arch = "wasm32")] +pub(crate) unsafe fn exported_bidi_masks( + count: u32, + levels_pointer: u32, + output_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let output_count = count.div_ceil(16); + let regions = [ + region::(levels_pointer, count), + region::(output_pointer, output_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: both typed regions are aligned, in linear memory, and disjoint. + unsafe { + bidi_transition_masks( + typed_slice(levels_pointer, count), + typed_slice_mut(output_pointer, output_count), + ) + }; + 0 +} + +#[cfg(target_arch = "wasm32")] +pub(crate) unsafe fn exported_chunk_summaries( + count: u32, + chunk_size: u32, + advances_pointer: u32, + flags_pointer: u32, + advance_sums_pointer: u32, + break_counts_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let Ok(chunk_size) = usize::try_from(chunk_size) else { + return STATUS_INVALID_REQUEST; + }; + if !VALID_CHUNK_SIZES.contains(&chunk_size) { + return STATUS_INVALID_REQUEST; + } + let summary_count = count.div_ceil(chunk_size); + let regions = [ + region::(advances_pointer, count), + region::(flags_pointer, count), + region::(advance_sums_pointer, summary_count), + region::(break_counts_pointer, summary_count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: all typed regions are aligned, in linear memory, and pairwise disjoint. + unsafe { + chunk_summaries( + typed_slice(advances_pointer, count), + typed_slice(flags_pointer, count), + chunk_size, + typed_slice_mut(advance_sums_pointer, summary_count), + typed_slice_mut(break_counts_pointer, summary_count), + ); + } + 0 +} + +#[cfg(target_arch = "wasm32")] +#[derive(Clone, Copy)] +struct MemoryRegion { + start: usize, + end: usize, + alignment: usize, +} + +#[cfg(target_arch = "wasm32")] +fn region(pointer: u32, count: usize) -> Option { + let start = pointer as usize; + let bytes = count.checked_mul(mem::size_of::())?; + Some(MemoryRegion { + start, + end: start.checked_add(bytes)?, + alignment: mem::align_of::(), + }) +} + +#[cfg(target_arch = "wasm32")] +fn valid_disjoint_regions(regions: &[Option]) -> bool { + let memory_bytes = core::arch::wasm32::memory_size::<0>().saturating_mul(65_536); + for (index, region) in regions.iter().enumerate() { + let Some(region) = region else { + return false; + }; + if region.start == 0 || region.start % region.alignment != 0 || region.end > memory_bytes { + return false; + } + for previous in regions[..index].iter().flatten() { + if region.start < previous.end && previous.start < region.end { + return false; + } + } + } + true +} + +#[cfg(target_arch = "wasm32")] +unsafe fn typed_slice(pointer: u32, count: usize) -> &'static [Value] { + // SAFETY: exported entry points validate alignment, memory bounds, and non-overlap first. + unsafe { slice::from_raw_parts(pointer as *const Value, count) } +} + +#[cfg(target_arch = "wasm32")] +unsafe fn typed_slice_mut(pointer: u32, count: usize) -> &'static mut [Value] { + // SAFETY: exported entry points validate alignment, memory bounds, and non-overlap first. + unsafe { slice::from_raw_parts_mut(pointer as *mut Value, count) } +} + +fn bounded_count(count: u32) -> Option { + let count = usize::try_from(count).ok()?; + (count != 0 && count <= MAX_RECORDS).then_some(count) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn scalar_packing_is_bit_exact_for_partial_groups() { + let x = [10.0, -2.0, 3.5, 8.0, 1.0]; + let y = [20.0, 7.0, -4.0, 2.0, 9.0]; + let font_sizes = [16.0, 24.0, 12.0, 32.0, 18.0]; + let left = [-2.0, 0.0, 1.0, -1.0, 2.0]; + let bottom = [-3.0, -2.0, 0.0, 1.0, -1.0]; + let right = [8.0, 9.0, 7.0, 11.0, 10.0]; + let top = [12.0, 14.0, 9.0, 17.0, 13.0]; + let mut origins = [0.0; 10]; + let mut packed_sizes = [0.0; 10]; + pack_origins_and_sizes( + &x, + &y, + &font_sizes, + &left, + &bottom, + &right, + &top, + 1.0 / 16.0, + &mut origins, + &mut packed_sizes, + ); + let mut expected_origins = [0.0; 10]; + let mut expected_sizes = [0.0; 10]; + pack_scalar( + 0, + &x, + &y, + &font_sizes, + &left, + &bottom, + &right, + &top, + 1.0 / 16.0, + &mut expected_origins, + &mut expected_sizes, + ); + assert_eq!( + origins.map(f32::to_bits), + expected_origins.map(f32::to_bits) + ); + assert_eq!( + packed_sizes.map(f32::to_bits), + expected_sizes.map(f32::to_bits) + ); + } + + #[test] + fn masks_preserve_partial_blocks_and_cross_block_bidi_state() { + let flags = (0..35) + .map(|index| u8::from(index % 3 == 0)) + .collect::>(); + let mut masks = [0_u16; 3]; + break_masks(&flags, &mut masks); + assert_eq!(masks, [0x9249, 0x4924, 0x0002]); + + let levels = [0, 0, 1, 1, 1, 2, 2, 0, 0, 0, 1, 1, 0, 0, 0, 1, 1, 1, 0]; + let mut transitions = [0_u16; 2]; + bidi_transition_masks(&levels, &mut transitions); + assert_eq!(transitions, [0x94a4, 0x0004]); + } + + #[test] + fn every_candidate_chunk_size_has_exact_integer_summaries() { + let advances = (0..259) + .map(|index| index % 17 - 8) + .collect::>(); + let flags = (0..259) + .map(|index| u8::from(index % 7 == 0)) + .collect::>(); + for chunk_size in VALID_CHUNK_SIZES { + let count = advances.len().div_ceil(chunk_size); + let mut sums = vec![0_i64; count]; + let mut breaks = vec![0_u32; count]; + chunk_summaries(&advances, &flags, chunk_size, &mut sums, &mut breaks); + for chunk in 0..count { + let start = chunk * chunk_size; + let end = advances.len().min(start + chunk_size); + assert_eq!( + sums[chunk], + advances[start..end] + .iter() + .map(|value| i64::from(*value)) + .sum::() + ); + assert_eq!( + breaks[chunk], + flags[start..end] + .iter() + .map(|value| u32::from(value & 1)) + .sum::() + ); + } + } + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index b219ceb5..a28857ab 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -7,6 +7,9 @@ pub(crate) mod frame; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod frame_wire; +#[cfg(feature = "kernel-lab")] +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) mod kernel_lab; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] mod state; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index e394c302..8cb79f78 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -212,6 +212,91 @@ pub extern "C" fn pmndrs_text_engine_request_capacity(handle: u32) -> u32 { }) } +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_kernel_lab_backend() -> u32 { + crate::engine::kernel_lab::BACKEND +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_pack( + count: u32, + x_pointer: u32, + y_pointer: u32, + font_size_pointer: u32, + plane_left_pointer: u32, + plane_bottom_pointer: u32, + plane_right_pointer: u32, + plane_top_pointer: u32, + inverse_units_per_em: f32, + origins_pointer: u32, + sizes_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { + crate::engine::kernel_lab::exported_pack( + count, + x_pointer, + y_pointer, + font_size_pointer, + plane_left_pointer, + plane_bottom_pointer, + plane_right_pointer, + plane_top_pointer, + inverse_units_per_em, + origins_pointer, + sizes_pointer, + ) + } +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_break_masks( + count: u32, + flags_pointer: u32, + output_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { crate::engine::kernel_lab::exported_break_masks(count, flags_pointer, output_pointer) } +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_bidi_masks( + count: u32, + levels_pointer: u32, + output_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { crate::engine::kernel_lab::exported_bidi_masks(count, levels_pointer, output_pointer) } +} + +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_chunk_summaries( + count: u32, + chunk_size: u32, + advances_pointer: u32, + flags_pointer: u32, + advance_sums_pointer: u32, + break_counts_pointer: u32, +) -> u32 { + // SAFETY: the test-only kernel validates every direct-memory region before creating slices. + unsafe { + crate::engine::kernel_lab::exported_chunk_summaries( + count, + chunk_size, + advances_pointer, + flags_pointer, + advance_sums_pointer, + break_counts_pointer, + ) + } +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn pmndrs_text_engine_update( session_id: u32, diff --git a/packages/text/scripts/benchmark-engine-kernels.mts b/packages/text/scripts/benchmark-engine-kernels.mts new file mode 100644 index 00000000..8a07566f --- /dev/null +++ b/packages/text/scripts/benchmark-engine-kernels.mts @@ -0,0 +1,81 @@ +/* @workflow { + "name": "text:kernel-lab", + "summary": "Compares scalar, auto-vectorized, and explicit SIMD retained-engine kernels over real paragraph arrays.", + "requirements": "Built package and kernel-lab artifacts: node ./scripts/build-engine-kernel-lab.mjs. Accepts --json.", + "writes": "stdout only, or the JSON report path passed to --json" +} */ +import { readFile, writeFile } from 'node:fs/promises'; +import { brotliCompressSync, constants as zlibConstants } from 'node:zlib'; + +import { captureKernelWorkloads } from './support/engine-kernel-fixture.mts'; +import { benchmarkKernelArtifact } from './support/engine-kernel-runner.mjs'; + +const ARTIFACT_ROOT = new URL('../rust/shaper/target/kernel-lab/', import.meta.url); +const VARIANTS = ['scalar', 'auto', 'explicit'] as const; +const TARGET_GLYPHS = [22_000, 86_000] as const; +const WARMUP = 40; +const SAMPLES = 101; + +const workloads = await captureKernelWorkloads(TARGET_GLYPHS); + +const variants = []; +let oracleHashes: ReadonlyMap | undefined; +for (const name of VARIANTS) { + const wasm = await readFile(new URL(`${name}.wasm`, ARTIFACT_ROOT)); + const wat = await readFile(new URL(`${name}.wat`, ARTIFACT_ROOT), 'utf8'); + const workloadReports = []; + const hashes = new Map(); + for (const workload of workloads) { + const report = await benchmarkKernelArtifact(wasm, name, workload, { warmup: WARMUP, samples: SAMPLES }); + workloadReports.push(report); + hashes.set(workload.label, report.outputHash); + } + if (oracleHashes === undefined) oracleHashes = hashes; + else { + for (const [label, hash] of hashes) { + if (hash !== oracleHashes.get(label)) { + throw new Error(`${name} output ${hash} does not match scalar oracle ${oracleHashes.get(label)} for ${label}`); + } + } + } + variants.push({ + name, + rawBytes: wasm.byteLength, + brotliBytes: brotliCompressSync(wasm, { + params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 }, + }).byteLength, + instructions: { + v128Loads: matches(wat, /\bv128\.load\b/g), + v128Stores: matches(wat, /\bv128\.store\b/g), + f32x4: matches(wat, /\bf32x4\.[a-z_]+\b/g), + bitmasks: matches(wat, /\bi8x16\.bitmask\b/g), + shuffles: matches(wat, /\bi8x16\.shuffle\b/g), + i64x2: matches(wat, /\bi64x2\.[a-z_]+\b/g), + }, + workloads: workloadReports, + }); +} + +const report = { + generatedBy: 'text:kernel-lab', + environment: { + runtime: `Node ${process.versions.node}`, + platform: `${process.platform}/${process.arch}`, + cpu: 'target machine; report with host inventory', + }, + warmup: WARMUP, + samples: SAMPLES, + variants, +}; +console.log(JSON.stringify(report, null, 2)); +const jsonPath = readArgument('--json'); +if (jsonPath !== undefined) await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`); + +function matches(value: string, expression: RegExp): number { + return value.match(expression)?.length ?? 0; +} + +function readArgument(flag: string): string | undefined { + const index = process.argv.indexOf(flag); + return index < 0 ? undefined : process.argv[index + 1]; +} diff --git a/packages/text/scripts/benchmark-paragraph-layout.mts b/packages/text/scripts/benchmark-paragraph-layout.mts index ccfc5d6c..b209312f 100644 --- a/packages/text/scripts/benchmark-paragraph-layout.mts +++ b/packages/text/scripts/benchmark-paragraph-layout.mts @@ -4,12 +4,15 @@ "requirements": "Built package: pnpm --filter @pmndrs/text build. Accepts --glyphs, --reps, --warmup, --case, --json.", "writes": "stdout only, or the JSON report path passed to --json" } */ -import { readFile, writeFile } from 'node:fs/promises'; +import { writeFile } from 'node:fs/promises'; import { setFlagsFromString } from 'node:v8'; import { runInNewContext } from 'node:vm'; -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '../dist/index.js'; -import { bitmap } from '../dist/raster/bitmap-technique.js'; +import { + createBenchmarkParagraph, + loadParagraphBenchmarkFixture, + paragraphTextForGlyphs, +} from './support/paragraph-benchmark-fixture.mts'; /** * Answers one question: how long does a paragraph batch take to reach uploadable instance data, per glyph, at the sizes @@ -35,12 +38,6 @@ const DEFAULT_REPETITIONS = 31; /** Glyph counts to sweep. The largest is past four columns of six thousand, which is the stated worst case. */ const DEFAULT_SCALES = [5_500, 11_000, 22_000, 33_000] as const; -const paragraphSource = [ - 'Typography is a moving system. AVATAR To Wa Yo repeat familiar kerning pairs while a responsive panel changes the space around them. The quick visual check is useful, but the benchmark records the cost of shaping, layout, upload, and every rendered frame.', - 'A practical interface mixes prose with 0123456789, prices such as 24.50, ranges from 8-512 px, and punctuation-"quotes", (parentheses), brackets, commas, and semicolons. Repeated office, affine, difficult, and shuffle words retain ff, fi, fl, ffi, and ffl candidates.', - 'Scientific copy adds x2+y2~z2, 0<=a<=1, and pi. Arrows point both ways. These symbols expose missing coverage, uneven baselines, bad advances, and atlas placement errors that plain alphabet samples can hide.', -].join('\n'); - type CaseName = 'cold' | 'font-size' | 'layout-width' | 'text'; interface Sample { @@ -64,12 +61,11 @@ interface CaseReport { const options = parseArguments(process.argv.slice(2)); const collectGarbage = exposeGarbageCollection(); -const root = new URL('../../../', import.meta.url); -const font = await loadFont(); +const font = await loadParagraphBenchmarkFixture(); const reports: CaseReport[] = []; for (const targetGlyphs of options.scales) { - const text = textForGlyphs(targetGlyphs); + const text = paragraphTextForGlyphs(targetGlyphs); for (const name of options.cases) { reports.push(await measureCase(name, text)); } @@ -92,13 +88,13 @@ async function measureCase(name: CaseName, text: string): Promise { // A cold case must build a fresh batch every repetition; the others measure an update to a warm one, which is what a // frame actually does. Both still run the same warmup discipline. const heapDeltas: number[] = []; - const warm = name === 'cold' ? undefined : createParagraph(runtime, text, 600); + const warm = name === 'cold' ? undefined : createBenchmarkParagraph(font, text, 600); if (warm !== undefined) runtime.update(); for (let repetition = 0; repetition < total; repetition += 1) { const recording = repetition >= options.warmup; - const created = name === 'cold' ? createParagraph(runtime, text, 600) : undefined; + const created = name === 'cold' ? createBenchmarkParagraph(font, text, 600) : undefined; if (warm !== undefined) applyChange(name, warm.paragraph, repetition, text); collectGarbage(); @@ -149,50 +145,16 @@ function applyChange(name: CaseName, paragraph: ParagraphHandle, repetition: num } else paragraph.text = `${text.slice(0, text.length - repetition)}`; } -type TextRuntimeHandle = Awaited>; +type TextRuntimeHandle = (typeof font)['runtime']; type ParagraphBatchHandle = ReturnType; type ParagraphHandle = ReturnType; -function createParagraph(runtime: TextRuntimeHandle, text: string, width: number) { - const batch = runtime.createParagraphBatch({ technique: bitmap }); - const paragraph = batch.add({ - font: font.loaded, - text, - contentBox: { width: { mode: 'exact', size: width }, wrap: 'word' }, - style: { fontSize: 24 }, - }); - return { batch, paragraph }; -} - function glyphCount(batch: ParagraphBatchHandle): number { let total = 0; for (const paragraph of batch.current.paragraphs) total += paragraph.layout.glyphIds.length; return total; } -function textForGlyphs(target: number): string { - // The source paragraph is measured once, then repeated to reach the target. Repetition keeps the shaping work - // representative while making the scale exact enough to compare per-glyph costs across rows. - const perCopy = paragraphSource.replaceAll(/\s/gu, '').length; - const copies = Math.max(1, Math.round(target / perCopy)); - return Array.from({ length: copies }, () => paragraphSource).join('\n'); -} - -async function loadFont() { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('packages/text/dist/text_shaper.wasm', root)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const bytes = await readFile(new URL('apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', root)); - const loaded = await runtime.loadFont({ - input: { baked: `data:application/octet-stream;base64,${bytes.toString('base64')}` }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); - return { runtime, loaded }; -} - function printReport(rows: readonly CaseReport[]): void { console.log( `\nwarmup ${options.warmup} discarded · ${options.repetitions} recorded repetitions · budget ${budget60.toFixed(2)}ms @60Hz · ${budget120.toFixed(2)}ms @120Hz\n`, diff --git a/packages/text/scripts/build-engine-kernel-lab.mjs b/packages/text/scripts/build-engine-kernel-lab.mjs new file mode 100644 index 00000000..5cc1f348 --- /dev/null +++ b/packages/text/scripts/build-engine-kernel-lab.mjs @@ -0,0 +1,71 @@ +import { spawn } from 'node:child_process'; +import { mkdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +import { reproducibleRustEnvironment } from '../../font-baker/scripts/reproducible-rust-env.mjs'; + +const packageRoot = fileURLToPath(new URL('../', import.meta.url)); +const workspaceRoot = fileURLToPath(new URL('../../../', import.meta.url)); +const outputRoot = fileURLToPath(new URL('../rust/shaper/target/kernel-lab/', import.meta.url)); +const executable = process.platform === 'win32' ? 'wasm-opt.CMD' : 'wasm-opt'; +const wasmOpt = fileURLToPath(new URL(`../node_modules/.bin/${executable}`, import.meta.url)); +const disExecutable = process.platform === 'win32' ? 'wasm-dis.CMD' : 'wasm-dis'; +const wasmDis = fileURLToPath(new URL(`../node_modules/.bin/${disExecutable}`, import.meta.url)); +const encodedFlagSeparator = '\u001f'; +const variants = [ + { name: 'scalar', targetFeature: '-simd128', features: 'kernel-lab', simd: false }, + { name: 'auto', targetFeature: '+simd128', features: 'kernel-lab', simd: true }, + { name: 'explicit', targetFeature: '+simd128', features: 'kernel-lab,simd128', simd: true }, +]; + +await mkdir(outputRoot, { recursive: true }); +for (const variant of variants) { + const targetDirectory = `${outputRoot}/${variant.name}-target`; + const environment = reproducibleRustEnvironment(workspaceRoot); + environment.CARGO_ENCODED_RUSTFLAGS += `${encodedFlagSeparator}-C${encodedFlagSeparator}target-feature=${variant.targetFeature}`; + await run( + 'cargo', + [ + 'build', + '--manifest-path', + 'rust/shaper/Cargo.toml', + '--target', + 'wasm32-unknown-unknown', + '--target-dir', + targetDirectory, + '--release', + '--locked', + '--no-default-features', + '--features', + variant.features, + ], + environment, + ); + const rustWasm = `${targetDirectory}/wasm32-unknown-unknown/release/pmndrs_text_shaper.wasm`; + await run(wasmOpt, [ + '--enable-bulk-memory', + '--enable-nontrapping-float-to-int', + ...(variant.simd ? ['--enable-simd'] : []), + '-Oz', + rustWasm, + '-o', + `${outputRoot}/${variant.name}.wasm`, + ]); + await run(wasmDis, [ + ...(variant.simd ? ['--enable-simd'] : []), + `${outputRoot}/${variant.name}.wasm`, + '-o', + `${outputRoot}/${variant.name}.wat`, + ]); +} + +function run(command, arguments_, environment = process.env) { + return new Promise((resolve, reject) => { + const child = spawn(command, arguments_, { cwd: packageRoot, env: environment, stdio: 'inherit' }); + child.once('error', reject); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`${command} exited with ${code ?? signal}`)); + }); + }); +} diff --git a/packages/text/scripts/build.mjs b/packages/text/scripts/build.mjs index d4b0a70f..eb94b815 100644 --- a/packages/text/scripts/build.mjs +++ b/packages/text/scripts/build.mjs @@ -12,6 +12,15 @@ const tsc = fileURLToPath( new URL(process.platform === 'win32' ? '../node_modules/.bin/tsc.CMD' : '../node_modules/.bin/tsc', import.meta.url), ); const rustEnvironment = reproducibleRustEnvironment(workspaceRoot); +const shaperSimdSetting = process.env.PMNDRS_TEXT_SHAPER_SIMD; +if (shaperSimdSetting !== undefined && shaperSimdSetting !== '0' && shaperSimdSetting !== '1') { + throw new Error('PMNDRS_TEXT_SHAPER_SIMD must be 0 or 1'); +} +const shaperSimd = shaperSimdSetting !== '0'; +const shaperRustEnvironment = { + ...rustEnvironment, + CARGO_ENCODED_RUSTFLAGS: `${rustEnvironment.CARGO_ENCODED_RUSTFLAGS}\u001f-C\u001ftarget-feature=${shaperSimd ? '+simd128' : '-simd128'}`, +}; const executable = process.platform === 'win32' ? 'wasm-opt.CMD' : 'wasm-opt'; const wasmOpt = fileURLToPath(new URL(`../node_modules/.bin/${executable}`, import.meta.url)); const rustWasm = fileURLToPath( @@ -153,8 +162,9 @@ await run( '--release', '--locked', '--no-default-features', + ...(shaperSimd ? ['--features', 'simd128'] : []), ], - rustEnvironment, + shaperRustEnvironment, ); await run(tsc, ['-p', 'tsconfig.build.json']); await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); @@ -172,6 +182,7 @@ await run(wasmOpt, [ await run(wasmOpt, [ '--enable-bulk-memory', '--enable-nontrapping-float-to-int', + ...(shaperSimd ? ['--enable-simd'] : []), '-Oz', shaperWasm, '-o', diff --git a/packages/text/scripts/support/engine-kernel-fixture.mts b/packages/text/scripts/support/engine-kernel-fixture.mts new file mode 100644 index 00000000..52f2e646 --- /dev/null +++ b/packages/text/scripts/support/engine-kernel-fixture.mts @@ -0,0 +1,84 @@ +import { + createBenchmarkParagraph, + loadParagraphBenchmarkFixture, + paragraphTextForGlyphs, +} from './paragraph-benchmark-fixture.mts'; + +export interface CapturedKernelInput { + readonly label: string; + readonly glyphs: number; + readonly x: Float32Array; + readonly y: Float32Array; + readonly fontSize: Float32Array; + readonly planeLeft: Float32Array; + readonly planeBottom: Float32Array; + readonly planeRight: Float32Array; + readonly planeTop: Float32Array; + readonly advances: Int32Array; + readonly flags: Uint8Array; + readonly levels: Uint8Array; +} + +export async function captureKernelWorkloads(targets: readonly number[]): Promise { + const fixture = await loadParagraphBenchmarkFixture(); + try { + return targets.map((target) => captureWorkload(fixture, target)); + } finally { + fixture.runtime.dispose(); + fixture.loaded.dispose(); + } +} + +function captureWorkload( + fixture: Awaited>, + targetGlyphs: number, +): CapturedKernelInput { + const text = paragraphTextForGlyphs(targetGlyphs); + const created = createBenchmarkParagraph(fixture, text, 600); + fixture.runtime.update(); + const layout = created.paragraph.committed?.layout; + if (layout === undefined) throw new Error('paragraph benchmark fixture did not publish a layout'); + const glyphs = layout.glyphIds.length; + const planeLeft = new Float32Array(glyphs); + const planeBottom = new Float32Array(glyphs); + const planeRight = new Float32Array(glyphs); + const planeTop = new Float32Array(glyphs); + const advances = new Int32Array(glyphs); + const flags = new Uint8Array(glyphs); + const levels = new Uint8Array(glyphs); + for (let index = 0; index < glyphs; index += 1) { + const glyphId = layout.glyphIds[index]!; + const left = (glyphId % 13) - 4; + const bottom = (glyphId % 7) - 3; + planeLeft[index] = left; + planeBottom[index] = bottom; + planeRight[index] = left + 6 + (glyphId % 9); + planeTop[index] = bottom + 8 + (glyphId % 11); + const nextX = layout.x[index + 1]; + const positionedAdvance = + nextX === undefined ? layout.glyphFontSizes[index]! * 0.5 : Math.abs(nextX - layout.x[index]!); + advances[index] = Math.max(1, Math.round(positionedAdvance * 64)); + const cluster = layout.clusters[index]!; + const codeUnit = text.charCodeAt(cluster); + flags[index] = codeUnit === 0x20 || codeUnit === 0x0a || codeUnit === 0x2d ? 1 : 0; + // Preserve long uniform spans and real cluster boundaries while injecting mixed-direction transitions. The kernel + // consumes resolved levels; script coverage and direction resolution themselves remain separate semantic gates. + levels[index] = ((Math.floor(cluster / 53) % 5) & 1) === 0 ? 0 : 1 + (glyphId & 1); + } + const captured = { + label: `${glyphs}-glyphs`, + glyphs, + x: layout.x.slice(), + y: layout.y.slice(), + fontSize: layout.glyphFontSizes.slice(), + planeLeft, + planeBottom, + planeRight, + planeTop, + advances, + flags, + levels, + }; + created.batch.dispose(); + return captured; +} diff --git a/packages/text/scripts/support/engine-kernel-runner.mjs b/packages/text/scripts/support/engine-kernel-runner.mjs new file mode 100644 index 00000000..bc7aa560 --- /dev/null +++ b/packages/text/scripts/support/engine-kernel-runner.mjs @@ -0,0 +1,212 @@ +const CHUNK_SIZES = [32, 64, 128]; + +export async function benchmarkKernelArtifact(wasm, name, input, options) { + const module = await WebAssembly.compile(wasm); + const instance = await WebAssembly.instantiate(module, {}); + const exports = instance.exports; + const expectedBackend = name === 'explicit' ? 1 : 0; + if (exports.pmndrs_text_kernel_lab_backend() !== expectedBackend) { + throw new Error(`${name} artifact selected the wrong compile-time kernel backend`); + } + const aligned = createMemoryFixture(exports, input, 0); + const unaligned = createMemoryFixture(exports, input, 4); + const memoryBefore = exports.memory.buffer; + const alignedHash = await executeAndHash(exports, aligned, false); + const verticalHash = await executeAndHash(exports, aligned, true); + const unalignedHash = await executeAndHash(exports, unaligned, false); + if (alignedHash !== unalignedHash) throw new Error(`${name} aligned and unaligned outputs differ`); + + const iterations = input.glyphs < 50_000 ? 16 : 8; + const timings = { + pack: measure(() => checkedCall(() => callPack(exports, aligned, false)), iterations, options), + breakMasks: measure( + () => + checkedCall(() => exports.pmndrs_text_kernel_lab_break_masks(input.glyphs, aligned.flags, aligned.breakMasks)), + iterations * 4, + options, + ), + bidiMasks: measure( + () => + checkedCall(() => exports.pmndrs_text_kernel_lab_bidi_masks(input.glyphs, aligned.levels, aligned.bidiMasks)), + iterations * 4, + options, + ), + chunk32: measure(() => checkedCall(() => callSummaries(exports, aligned, 32)), iterations * 2, options), + chunk64: measure(() => checkedCall(() => callSummaries(exports, aligned, 64)), iterations * 2, options), + chunk128: measure(() => checkedCall(() => callSummaries(exports, aligned, 128)), iterations * 2, options), + }; + if (exports.memory.buffer !== memoryBefore) throw new Error(`${name} grew memory during a warm kernel`); + exports.pmndrs_text_shaper_dealloc(aligned.allocationPointer, aligned.allocationLength); + exports.pmndrs_text_shaper_dealloc(unaligned.allocationPointer, unaligned.allocationLength); + return { + label: input.label, + glyphs: input.glyphs, + outputHash: await hashParts([new TextEncoder().encode(alignedHash), new TextEncoder().encode(verticalHash)]), + alignedOutputHash: alignedHash, + unalignedOutputHash: unalignedHash, + verticalOutputHash: verticalHash, + warmMemoryGrowth: false, + timings, + }; +} + +function createMemoryFixture(exports, input, skew) { + const allocationLength = input.glyphs * 64 + 4_096; + const allocationPointer = exports.pmndrs_text_shaper_alloc(allocationLength); + if (allocationPointer === 0) throw new Error('kernel-lab allocation failed'); + let cursor = alignWithSkew(allocationPointer, 16, skew); + const reserve = (count, bytesPerElement, alignment) => { + const addressSkew = skew === 0 ? 0 : alignment === 8 ? 8 : 4; + cursor = alignWithSkew(cursor, 16, addressSkew); + const pointer = cursor; + cursor += count * bytesPerElement; + return pointer; + }; + const count = input.glyphs; + const x = reserve(count, 4, 4); + const y = reserve(count, 4, 4); + const fontSize = reserve(count, 4, 4); + const planeLeft = reserve(count, 4, 4); + const planeBottom = reserve(count, 4, 4); + const planeRight = reserve(count, 4, 4); + const planeTop = reserve(count, 4, 4); + const advances = reserve(count, 4, 4); + const flags = reserve(count, 1, 1); + const levels = reserve(count, 1, 1); + const origins = reserve(count * 2, 4, 4); + const sizes = reserve(count * 2, 4, 4); + const maskCount = Math.ceil(count / 16); + const breakMasks = reserve(maskCount, 2, 2); + const bidiMasks = reserve(maskCount, 2, 2); + const summaryCapacity = Math.ceil(count / 32); + const advanceSums = reserve(summaryCapacity, 8, 8); + const breakCounts = reserve(summaryCapacity, 4, 4); + if (cursor > allocationPointer + allocationLength) throw new Error('kernel-lab memory layout exceeds its allocation'); + + new Float32Array(exports.memory.buffer, x, count).set(input.x); + new Float32Array(exports.memory.buffer, y, count).set(input.y); + new Float32Array(exports.memory.buffer, fontSize, count).set(input.fontSize); + new Float32Array(exports.memory.buffer, planeLeft, count).set(input.planeLeft); + new Float32Array(exports.memory.buffer, planeBottom, count).set(input.planeBottom); + new Float32Array(exports.memory.buffer, planeRight, count).set(input.planeRight); + new Float32Array(exports.memory.buffer, planeTop, count).set(input.planeTop); + new Int32Array(exports.memory.buffer, advances, count).set(input.advances); + new Uint8Array(exports.memory.buffer, flags, count).set(input.flags); + new Uint8Array(exports.memory.buffer, levels, count).set(input.levels); + return { + count, + allocationPointer, + allocationLength, + x, + y, + fontSize, + planeLeft, + planeBottom, + planeRight, + planeTop, + advances, + flags, + levels, + origins, + sizes, + breakMasks, + bidiMasks, + advanceSums, + breakCounts, + summaryCapacity, + memory: exports.memory, + }; +} + +async function executeAndHash(exports, fixture, vertical) { + checkedCall(() => callPack(exports, fixture, vertical)); + checkedCall(() => exports.pmndrs_text_kernel_lab_break_masks(fixture.count, fixture.flags, fixture.breakMasks)); + checkedCall(() => exports.pmndrs_text_kernel_lab_bidi_masks(fixture.count, fixture.levels, fixture.bidiMasks)); + const parts = [ + bytes(fixture, fixture.origins, fixture.count * 2 * 4), + bytes(fixture, fixture.sizes, fixture.count * 2 * 4), + bytes(fixture, fixture.breakMasks, Math.ceil(fixture.count / 16) * 2), + bytes(fixture, fixture.bidiMasks, Math.ceil(fixture.count / 16) * 2), + ]; + for (const chunkSize of CHUNK_SIZES) { + checkedCall(() => callSummaries(exports, fixture, chunkSize)); + const summaryCount = Math.ceil(fixture.count / chunkSize); + parts.push(bytes(fixture, fixture.advanceSums, summaryCount * 8)); + parts.push(bytes(fixture, fixture.breakCounts, summaryCount * 4)); + } + return hashParts(parts); +} + +function callPack(exports, fixture, vertical) { + return exports.pmndrs_text_kernel_lab_pack( + fixture.count, + vertical ? fixture.y : fixture.x, + vertical ? fixture.x : fixture.y, + fixture.fontSize, + fixture.planeLeft, + fixture.planeBottom, + fixture.planeRight, + fixture.planeTop, + 1 / 2_048, + fixture.origins, + fixture.sizes, + ); +} + +function callSummaries(exports, fixture, chunkSize) { + return exports.pmndrs_text_kernel_lab_chunk_summaries( + fixture.count, + chunkSize, + fixture.advances, + fixture.flags, + fixture.advanceSums, + fixture.breakCounts, + ); +} + +function measure(operation, iterations, options) { + for (let sample = 0; sample < options.warmup; sample += 1) { + for (let index = 0; index < iterations; index += 1) operation(); + } + const values = []; + for (let sample = 0; sample < options.samples; sample += 1) { + const started = performance.now(); + for (let index = 0; index < iterations; index += 1) operation(); + values.push((performance.now() - started) / iterations); + } + values.sort((left, right) => left - right); + return { + p50Ms: percentile(values, 0.5), + p95Ms: percentile(values, 0.95), + p99Ms: percentile(values, 0.99), + }; +} + +function checkedCall(operation) { + const status = operation(); + if (status !== 0) throw new Error(`kernel-lab call failed with status ${status}`); +} + +function bytes(fixture, pointer, length) { + return new Uint8Array(fixture.memory.buffer, pointer, length).slice(); +} + +async function hashParts(parts) { + const byteLength = parts.reduce((total, part) => total + part.byteLength, 0); + const joined = new Uint8Array(byteLength); + let offset = 0; + for (const part of parts) { + joined.set(part, offset); + offset += part.byteLength; + } + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', joined)); + return [...digest].map((value) => value.toString(16).padStart(2, '0')).join(''); +} + +function percentile(sorted, quantile) { + return sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * quantile))] ?? 0; +} + +function alignWithSkew(value, alignment, skew) { + return Math.ceil((value - skew) / alignment) * alignment + skew; +} diff --git a/packages/text/scripts/support/paragraph-benchmark-fixture.mts b/packages/text/scripts/support/paragraph-benchmark-fixture.mts new file mode 100644 index 00000000..910f0164 --- /dev/null +++ b/packages/text/scripts/support/paragraph-benchmark-fixture.mts @@ -0,0 +1,47 @@ +import { readFile } from 'node:fs/promises'; + +import { createRuntimeShaper, createTextRuntime, FontRegistry } from '../../dist/index.js'; +import { bitmap } from '../../dist/raster/bitmap-technique.js'; + +export const paragraphBenchmarkSource = [ + 'Typography is a moving system. AVATAR To Wa Yo repeat familiar kerning pairs while a responsive panel changes the space around them. The quick visual check is useful, but the benchmark records the cost of shaping, layout, upload, and every rendered frame.', + 'A practical interface mixes prose with 0123456789, prices such as 24.50, ranges from 8-512 px, and punctuation-"quotes", (parentheses), brackets, commas, and semicolons. Repeated office, affine, difficult, and shuffle words retain ff, fi, fl, ffi, and ffl candidates.', + 'Scientific copy adds x2+y2~z2, 0<=a<=1, and pi. Arrows point both ways. These symbols expose missing coverage, uneven baselines, bad advances, and atlas placement errors that plain alphabet samples can hide.', +].join('\n'); + +export async function loadParagraphBenchmarkFixture() { + const workspaceRoot = new URL('../../../../', import.meta.url); + const registry = new FontRegistry(); + const shaper = await createRuntimeShaper({ + registry, + wasm: await readFile(new URL('packages/text/dist/text_shaper.wasm', workspaceRoot)), + }); + const runtime = await createTextRuntime({ registry, shaper }); + const bytes = await readFile(new URL('apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', workspaceRoot)); + const loaded = await runtime.loadFont({ + input: { baked: `data:application/octet-stream;base64,${bytes.toString('base64')}` }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + return { runtime, loaded }; +} + +export function createBenchmarkParagraph( + fixture: Awaited>, + text: string, + width: number, +) { + const batch = fixture.runtime.createParagraphBatch({ technique: bitmap }); + const paragraph = batch.add({ + font: fixture.loaded, + text, + contentBox: { width: { mode: 'exact', size: width }, wrap: 'word' }, + style: { fontSize: 24 }, + }); + return { batch, paragraph }; +} + +export function paragraphTextForGlyphs(target: number): string { + const perCopy = paragraphBenchmarkSource.replaceAll(/\s/gu, '').length; + const copies = Math.max(1, Math.round(target / perCopy)); + return Array.from({ length: copies }, () => paragraphBenchmarkSource).join('\n'); +} From 1c8afbd5039fd48f80406401dce02d6816788a1b Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 06:49:14 -0400 Subject: [PATCH 007/128] perf(text): execute render policies in Rust --- .../scripts/benchmark-text-engine-kernels.mts | 2 + docs/log.md | 9 + docs/packages/benchmarks.md | 11 +- docs/packages/text.md | 22 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 15 +- .../shaper/evidence/kernel-layout-v0.json | 101 ++- .../text/rust/shaper/src/engine/kernel_lab.rs | 100 +++ .../text/rust/shaper/src/engine/policy.rs | 723 +++++++++++++++++- packages/text/rust/shaper/src/wasm.rs | 83 ++ .../scripts/support/engine-kernel-fixture.mts | 14 +- .../scripts/support/engine-kernel-runner.mjs | 39 +- packages/text/tests/support/engine-abi.d.mts | 15 + packages/text/tests/support/engine-abi.mjs | 149 +++- 14 files changed, 1203 insertions(+), 81 deletions(-) create mode 100644 packages/text/tests/support/engine-abi.d.mts diff --git a/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts b/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts index a0060c62..5cee0029 100644 --- a/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts +++ b/apps/benchmarks/scripts/benchmark-text-engine-kernels.mts @@ -80,6 +80,7 @@ try { advances: new Int32Array(decode(input.advances).buffer), flags: decode(input.flags), levels: decode(input.levels), + policy: decode(input.policy), }; return module.benchmarkKernelArtifact(decode(wasmBase64), artifactName, typedInput, { warmup: benchmarkWarmup, @@ -146,6 +147,7 @@ function encodeInput(input: (typeof workloads)[number]) { advances: encode(input.advances), flags: encode(input.flags), levels: encode(input.levels), + policy: encode(input.policy), }; } diff --git a/docs/log.md b/docs/log.md index cdd2bf98..26135de7 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Admitted explicit-SIMD render-policy execution** — Added a production scalar interpreter for validated straight-line + render policies and a four-record `simd128` executor with scalar tails. Registration resolves policy buffer IDs once; + warm execution consumes borrowed semantic SoA fields, preflights every output, allocates nothing, and requires every + direct-memory region to remain inside a live host allocation before borrowing retained engine state. Scalar, + auto-vectorized, and explicit-SIMD artifacts produce identical horizontal, vertical, partial-tail, and four-byte- + aligned outputs. At 25,515 glyphs, the representative 17-operation policy improves p95 from 1.174 to 0.428 milliseconds + in Node and 1.113 to 0.438 milliseconds in Chromium. The production SIMD artifact is 530 raw bytes smaller and 62 + Brotli bytes larger than scalar. Boundary search, native SIMD, and whole-update contribution remain unmeasured. + - **Selected the first SIMD-shaped retained storage** — Added a test-only direct-pointer kernel lab over real 25,515- and 100,602-glyph paragraph arrays and three isolated Wasm builds. Node 24 and Chromium 149 reproduce exact scalar hashes for horizontal, vertical, partial-tail, and four-byte-aligned inputs with no warm allocation path or memory diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 3d4027f7..c10c1143 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:3185896663acc3394d719ec4eddf94c2c038a9386b7119e67d4bfd10eac30d88' +source_digest: 'sha256:d618b88fc753aa8e14d913555e6332bee03d2033e23264b0c42d9437eb738297' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -490,9 +490,12 @@ artifacts in the project-pinned Chromium from a trustworthy loopback origin. It 100,602-glyph typed arrays as the Node workflow and fails before timing unless every artifact reproduces the scalar horizontal, vertical, partial-tail, and unaligned hashes. The current Chromium 149 run executed the SIMD artifact, observed no warm memory growth, and supports the 64-cluster choice: at 100,602 glyphs its selected-hybrid p95 was 0.01875 -ms versus 0.06875 ms scalar for chunk summaries, 0.009375 versus 0.053125 ms for break masks, and 0.0125 versus 0.046875 -ms for bidi masks. Browser timer quantization is visible in those figures, so Node retains the finer candidate ranking -while Chromium supplies the independent engine-admission check. +ms versus 0.0625 ms scalar for chunk summaries, 0.009375 versus 0.053125 ms for break masks, and 0.0125 versus 0.04375 +ms for bidi masks. The same run executes the production validated-policy interpreter over a representative 17-operation +program and includes its F32×4, U32, and U16 buffers in the scalar/auto/SIMD byte-identity gate. At 25,515 glyphs, +explicit SIMD measures 0.438 ms p95 versus 1.113 ms scalar; at 100,602 it measures 1.750 versus 4.350 ms. Browser timer +quantization is visible in those figures, so Node retains the finer candidate ranking while Chromium supplies the +independent engine-admission check. The bake-host report separates the consumer phases without timing conformance work. Each offline sample creates a fresh Wasm baker and records initialization plus first bake as cold, then records a second bake on that instance as warm. Each isolated Chromium context queues two requests onto one Worker: first completion contains Worker/Wasm startup plus its bake, while the interval to second completion is the warm reused-instance bake. Three captured arm64/Chromium 149 samples preserve complete artifact parity; medians were 4.16 ms cold / 2.94 ms warm offline and 21.70 ms cold / 3.50 ms warm in the Worker. These are observations, not cross-host thresholds. diff --git a/docs/packages/text.md b/docs/packages/text.md index 3d73cb59..3d0b625d 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:bf341bec67c931df02db97003cf7c5e433364f399ec2d750dfa16395e651c5de' +source_digest: 'sha256:a79c32c44c09be60e4643a1a6c500bb0748901f89e4745e9ebe7cc262a9617c2' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -420,13 +420,19 @@ compiler-vectorized, and selected hybrid artifacts through one direct-pointer in produce identical horizontal, vertical, partial-tail, and four-byte-aligned hashes with no warm allocation path or memory growth. Compiler-vectorized straight-line source owns record packing because hand-written shuffle packing did not beat it. Explicit `i8x16` break/bidi masks and integer-exact `i64x2` summaries pass the 20% phase threshold; the large -workload selects ABI-private 64-cluster, 16-byte-aligned SoA chunks over 32 and 128. Binaryen output contains the intended -vector instructions. The selected lab artifact adds 1,652 raw / 1,158 Brotli bytes over scalar. More importantly, the -standard production `+simd128` artifact measures 725,013 raw / 266,615 gzip / 210,841 Brotli bytes, 289 / 550 / 26 bytes -smaller than the same-ABI scalar build. SIMD is the default build with no runtime dispatch; -`PMNDRS_TEXT_SHAPER_SIMD=0` produces the scalar release valve, whose disassembly contains no SIMD instructions and whose -34 focused shaping/layout/frame tests pass unchanged. Policy execution, boundary search, native SIMD, and the complete -hot-update contribution remain explicit open measurements until those engine stages exist. +workload selects ABI-private 64-cluster, 16-byte-aligned SoA chunks over 32 and 128. The production policy executor +retains the validated program, resolves store-buffer indices once at registration, rejects structurally invalid calls +before writes, and executes four records per explicit SIMD bytecode dispatch with scalar tails. The representative +17-operation program over 25,515 glyphs measures 1.174→0.428 ms p95 in Node and 1.113→0.438 ms in Chromium; the 100,602- +glyph comparison measures 4.499→1.698 ms and 4.350→1.750 ms. Compiler auto-vectorization did not improve policy +execution. All compared artifacts preserve exact output bytes and warm memory identity. Direct regions must belong to a +live host allocation before the executor can borrow retained engine state. Binaryen output contains the intended vector +instructions. The selected lab artifact adds 4,185 raw / 1,096 Brotli bytes over scalar. The standard production +`+simd128` artifact measures 725,572 raw / 269,260 gzip / 211,013 Brotli bytes: 530 raw and 633 gzip bytes smaller, but +62 Brotli bytes larger, than the 726,102 / 269,893 / 210,951 scalar build. SIMD is the default build with no runtime +dispatch; `PMNDRS_TEXT_SHAPER_SIMD=0` produces the scalar release valve, whose disassembly contains no SIMD instructions. +Boundary search, native SIMD, and the complete hot-update contribution remain explicit open measurements until those +engine stages exist. Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 7c569bf6..cd080546 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -227,6 +227,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | | D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | | D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | +| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index e43c634a..a15744ae 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -806,11 +806,16 @@ arrays, scalar, compiler-vectorized, and selected hybrid artifacts produced iden and four-byte-aligned output hashes in Node 24.18.0 and Chromium 149 without warm allocation or memory growth. Compiler vectorization, not hand-written shuffles, owns straight-line record packing. Explicit `i8x16` break/bidi masks and integer-exact `i64x2` summaries exceeded the 20% phase threshold; 64-cluster summaries won the large workload against -32 and 128. The selected lab artifact adds 1,652 raw / 1,158 Brotli bytes over scalar, while the standard production -`+simd128` build is 289 raw / 26 Brotli bytes smaller than its same-ABI scalar release-valve build. The optimized -disassembly contains the intended vector loads, stores, bitmasks, shuffles, and integer lanes. Policy execution, -boundary search, representative native SIMD, and end-to-end contribution remain open parts of the packet because their -production stages do not exist yet; they are not inferred from these admitted kernels. +32 and 128. The production policy executor resolves output-buffer indices at registration, preflights all semantic SoA +inputs and physical outputs before writing, and dispatches validated straight-line bytecode over four records per SIMD +iteration with scalar tails. Its representative 17-operation program over 25,515 glyphs measured 1.174→0.428 ms p95 +in Node and 1.113→0.438 ms in Chromium; compiler auto-vectorization remained within scalar variance. All three artifacts +produced the same output bytes. The selected lab artifact now adds 4,185 raw / 1,096 Brotli bytes over scalar, while the +standard production `+simd128` build is 530 raw bytes smaller and 62 Brotli bytes larger than its same-ABI scalar +release-valve build. The optimized disassembly contains the intended vector loads, stores, arithmetic, comparisons, +bitmasks, shuffles, and integer lanes. Boundary search, representative native SIMD, and end-to-end contribution remain +open parts of the packet because their production stages do not exist yet; they are not inferred from these admitted +kernels. ## Implementation stack diff --git a/packages/text/rust/shaper/evidence/kernel-layout-v0.json b/packages/text/rust/shaper/evidence/kernel-layout-v0.json index 03bd8b5e..9a5ea0c9 100644 --- a/packages/text/rust/shaper/evidence/kernel-layout-v0.json +++ b/packages/text/rust/shaper/evidence/kernel-layout-v0.json @@ -12,11 +12,11 @@ "workloads": [ { "glyphs": 25515, - "outputSha256": "84ba1678f750f7fc35ff5e665aa5cb07585734aa12357ab55ee499208b2e3e5e" + "outputSha256": "e79081ca22e1b77d1efa89722e62219f1d956e10086f44c81f4bdf0374f4e2de" }, { "glyphs": 100602, - "outputSha256": "42e3c29bf66c1f18caea36ec2156ee32a38d09e797bc8cea453433c02f8ff84e" + "outputSha256": "221b06997b4c0ded29a8bd21d42745111400910c95f5baf625a2210db93417f5" } ], "correctness": { @@ -29,19 +29,19 @@ }, "artifactBytes": { "kernelLab": { - "scalar": { "raw": 728860, "brotli": 211729 }, - "auto": { "raw": 729828, "brotli": 212390 }, - "selectedHybrid": { "raw": 730512, "brotli": 212887 } + "scalar": { "raw": 734374, "brotli": 213921 }, + "auto": { "raw": 735101, "brotli": 214366 }, + "selectedHybrid": { "raw": 738559, "brotli": 215017 } }, "production": { - "scalar": { "raw": 725302, "gzip": 267165, "brotli": 210867 }, - "simd128": { "raw": 725013, "gzip": 266615, "brotli": 210841 } + "scalar": { "raw": 726102, "gzip": 269893, "brotli": 210951 }, + "simd128": { "raw": 725572, "gzip": 269260, "brotli": 211013 } } }, "optimizedSelectedInstructions": { - "v128Loads": 649, - "v128Stores": 723, - "f32x4Operations": 18, + "v128Loads": 698, + "v128Stores": 767, + "f32x4Operations": 26, "i8x16Bitmasks": 3, "i8x16Shuffles": 35, "i64x2Operations": 28 @@ -49,26 +49,80 @@ "p95Milliseconds": { "node": { "25515": { - "scalar": { "pack": 0.043445, "breakMasks": 0.013195, "bidiMasks": 0.010682, "chunk64": 0.015655 }, - "auto": { "pack": 0.013099, "breakMasks": 0.01123, "bidiMasks": 0.01386, "chunk64": 0.008819 }, - "selectedHybrid": { "pack": 0.01306, "breakMasks": 0.001452, "bidiMasks": 0.002551, "chunk64": 0.004156 } + "scalar": { + "pack": 0.043354, + "breakMasks": 0.01269, + "bidiMasks": 0.010704, + "policy": 1.174357, + "chunk64": 0.015559 + }, + "auto": { + "pack": 0.013313, + "breakMasks": 0.011764, + "bidiMasks": 0.012434, + "policy": 1.187443, + "chunk64": 0.008818 + }, + "selectedHybrid": { + "pack": 0.013273, + "breakMasks": 0.001506, + "bidiMasks": 0.002633, + "policy": 0.427859, + "chunk64": 0.003832 + } }, "100602": { - "scalar": { "pack": 0.177563, "breakMasks": 0.04904, "bidiMasks": 0.041621, "chunk64": 0.05982 }, - "auto": { "pack": 0.058224, "breakMasks": 0.045216, "bidiMasks": 0.053385, "chunk64": 0.033901 }, - "selectedHybrid": { "pack": 0.062641, "breakMasks": 0.005815, "bidiMasks": 0.010034, "chunk64": 0.014711 } + "scalar": { + "pack": 0.169114, + "breakMasks": 0.049176, + "bidiMasks": 0.04191, + "policy": 4.498917, + "chunk64": 0.064013 + }, + "auto": { + "pack": 0.061651, + "breakMasks": 0.047813, + "bidiMasks": 0.05031, + "policy": 4.622422, + "chunk64": 0.034167 + }, + "selectedHybrid": { + "pack": 0.059286, + "breakMasks": 0.005646, + "bidiMasks": 0.010376, + "policy": 1.698078, + "chunk64": 0.014919 + } } }, "chromium": { "25515": { - "scalar": { "pack": 0.04375, "breakMasks": 0.014063, "bidiMasks": 0.010938, "chunk64": 0.01875 }, - "auto": { "pack": 0.01875, "breakMasks": 0.010938, "bidiMasks": 0.0125, "chunk64": 0.0125 }, - "selectedHybrid": { "pack": 0.01875, "breakMasks": 0.003125, "bidiMasks": 0.003125, "chunk64": 0.00625 } + "scalar": { + "pack": 0.04375, + "breakMasks": 0.014063, + "bidiMasks": 0.010938, + "policy": 1.1125, + "chunk64": 0.01875 + }, + "auto": { "pack": 0.01875, "breakMasks": 0.0125, "bidiMasks": 0.014063, "policy": 1.075, "chunk64": 0.009375 }, + "selectedHybrid": { + "pack": 0.01875, + "breakMasks": 0.003125, + "bidiMasks": 0.003125, + "policy": 0.4375, + "chunk64": 0.00625 + } }, "100602": { - "scalar": { "pack": 0.175, "breakMasks": 0.053125, "bidiMasks": 0.046875, "chunk64": 0.06875 }, - "auto": { "pack": 0.075, "breakMasks": 0.04375, "bidiMasks": 0.05, "chunk64": 0.0375 }, - "selectedHybrid": { "pack": 0.0625, "breakMasks": 0.009375, "bidiMasks": 0.0125, "chunk64": 0.01875 } + "scalar": { "pack": 0.175, "breakMasks": 0.053125, "bidiMasks": 0.04375, "policy": 4.35, "chunk64": 0.0625 }, + "auto": { "pack": 0.0625, "breakMasks": 0.04375, "bidiMasks": 0.05, "policy": 4.3, "chunk64": 0.0375 }, + "selectedHybrid": { + "pack": 0.075, + "breakMasks": 0.009375, + "bidiMasks": 0.0125, + "policy": 1.75, + "chunk64": 0.01875 + } } } }, @@ -90,16 +144,17 @@ "packing": "compiler-vectorizable straight-line scalar source compiled with +simd128", "breakAndBidiMasks": "explicit simd128 with scalar tails", "chunkSummaries": "explicit simd128 integer sums and masks with scalar tails", + "policyExecution": "validated straight-line bytecode dispatched once over four-record simd128 lanes with scalar tails and registration-time output-buffer resolution", "standardArtifact": "simd128", "compatibilityDefine": "PMNDRS_TEXT_SHAPER_SIMD=0", "runtimeDispatch": false, "rejected": [ "hand-written four-record packing, because it did not beat compiler vectorization and regressed the 100k Node result", + "compiler auto-vectorization for policy execution, because it remained within scalar variance", "32-cluster chunks, because their large-workload summary time exceeded 64", "128-cluster chunks, because their large-workload summary time exceeded 64" ], "remainingPacket": [ - "policy-execution kernels after the scalar render-plan executor exists", "boundary search after retained line cursors and summaries exist", "representative native SIMD implementation and measurement", "end-to-end hot-update contribution after the Rust semantic pipeline is wired" diff --git a/packages/text/rust/shaper/src/engine/kernel_lab.rs b/packages/text/rust/shaper/src/engine/kernel_lab.rs index 2e5eb18f..3a2c77c2 100644 --- a/packages/text/rust/shaper/src/engine/kernel_lab.rs +++ b/packages/text/rust/shaper/src/engine/kernel_lab.rs @@ -5,6 +5,11 @@ use core::{mem, slice}; #[cfg(target_arch = "wasm32")] use crate::STATUS_INVALID_REQUEST; +#[cfg(target_arch = "wasm32")] +use crate::engine::policy::{ + BufferId, BufferSchema, PhysicalBufferMut, ScalarType, SemanticInputBatch, TechniqueId, + ValidatedPolicy, +}; const MAX_RECORDS: usize = 1_000_000; const VALID_CHUNK_SIZES: [usize; 3] = [32, 64, 128]; @@ -355,6 +360,95 @@ pub(crate) unsafe fn exported_chunk_summaries( 0 } +#[cfg(target_arch = "wasm32")] +#[allow(clippy::too_many_arguments)] +pub(crate) unsafe fn exported_policy( + policy: &ValidatedPolicy, + technique: u32, + variant: u32, + count: u32, + f32_input0_pointer: u32, + f32_input1_pointer: u32, + f32_input2_pointer: u32, + f32_input3_pointer: u32, + u32_input0_pointer: u32, + f32_output_pointer: u32, + u32_output_pointer: u32, + u16_output_pointer: u32, +) -> u32 { + let Some(count) = bounded_count(count) else { + return STATUS_INVALID_REQUEST; + }; + let Ok(variant) = u16::try_from(variant) else { + return STATUS_INVALID_REQUEST; + }; + let Some(f32_output_count) = count.checked_mul(4) else { + return STATUS_INVALID_REQUEST; + }; + let regions = [ + region::(f32_input0_pointer, count), + region::(f32_input1_pointer, count), + region::(f32_input2_pointer, count), + region::(f32_input3_pointer, count), + region::(u32_input0_pointer, count), + region::(f32_output_pointer, f32_output_count), + region::(u32_output_pointer, count), + region::(u16_output_pointer, count), + ]; + if !valid_disjoint_regions(®ions) { + return STATUS_INVALID_REQUEST; + } + // SAFETY: all input and output regions are aligned, in linear memory, and pairwise disjoint. + unsafe { + let f32_inputs = [ + typed_slice(f32_input0_pointer, count), + typed_slice(f32_input1_pointer, count), + typed_slice(f32_input2_pointer, count), + typed_slice(f32_input3_pointer, count), + ]; + let u32_inputs = [typed_slice(u32_input0_pointer, count)]; + let mut outputs = [ + PhysicalBufferMut { + schema: BufferSchema { + id: BufferId(1), + scalar: ScalarType::F32, + vector_width: 4, + }, + bytes: byte_slice_mut(f32_output_pointer, f32_output_count * mem::size_of::()), + }, + PhysicalBufferMut { + schema: BufferSchema { + id: BufferId(2), + scalar: ScalarType::U32, + vector_width: 1, + }, + bytes: byte_slice_mut(u32_output_pointer, count * mem::size_of::()), + }, + PhysicalBufferMut { + schema: BufferSchema { + id: BufferId(3), + scalar: ScalarType::U16, + vector_width: 1, + }, + bytes: byte_slice_mut(u16_output_pointer, count * mem::size_of::()), + }, + ]; + policy + .execute( + TechniqueId(technique), + variant, + SemanticInputBatch { + f32_fields: &f32_inputs, + u32_fields: &u32_inputs, + record_count: count, + }, + 0, + &mut outputs, + ) + .map_or(STATUS_INVALID_REQUEST, |()| 0) + } +} + #[cfg(target_arch = "wasm32")] #[derive(Clone, Copy)] struct MemoryRegion { @@ -405,6 +499,12 @@ unsafe fn typed_slice_mut(pointer: u32, count: usize) -> &'static mut [Va unsafe { slice::from_raw_parts_mut(pointer as *mut Value, count) } } +#[cfg(target_arch = "wasm32")] +unsafe fn byte_slice_mut(pointer: u32, count: usize) -> &'static mut [u8] { + // SAFETY: exported entry points validate the corresponding typed region and non-overlap first. + unsafe { slice::from_raw_parts_mut(pointer as *mut u8, count) } +} + fn bounded_count(count: u32) -> Option { let count = usize::try_from(count).ok()?; (count != 0 && count <= MAX_RECORDS).then_some(count) diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index b46c89a1..962e681d 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -10,6 +10,8 @@ pub const MAX_BUFFERS_PER_PROGRAM: usize = 16; pub const MAX_OPERATIONS_PER_PROGRAM: usize = 128; pub const MAX_REGISTERS: usize = 32; pub const MAX_VECTOR_WIDTH: u8 = 4; +const MAX_OUTPUT_LANES: usize = MAX_BUFFERS_PER_PROGRAM * MAX_VECTOR_WIDTH as usize; +const NOT_A_STORE: u8 = u8::MAX; pub const OP_LOAD_F32: u8 = 1; pub const OP_LOAD_U32: u8 = 2; @@ -162,13 +164,22 @@ pub struct PolicyDescriptor { #[derive(Clone, Debug, PartialEq)] pub struct ValidatedPolicy { programs: Vec, + execution: Vec, } impl ValidatedPolicy { pub fn new(descriptor: PolicyDescriptor) -> Result { validate_policy(&descriptor)?; + let mut execution = Vec::new(); + execution + .try_reserve_exact(descriptor.programs.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + for program in &descriptor.programs { + execution.push(ExecutableProgram::new(program)?); + } Ok(Self { programs: descriptor.programs, + execution, }) } @@ -181,10 +192,88 @@ impl ValidatedPolicy { .iter() .find(|program| program.technique == technique && program.variant == variant) } + + pub fn execute( + &self, + technique: TechniqueId, + variant: u16, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], + ) -> Result<(), PolicyExecutionError> { + let program_index = self + .programs + .iter() + .position(|program| program.technique == technique && program.variant == variant) + .ok_or(PolicyExecutionError::ProgramMissing)?; + let program = self + .programs + .get(program_index) + .ok_or(PolicyExecutionError::ProgramMissing)?; + let execution = self + .execution + .get(program_index) + .ok_or(PolicyExecutionError::ProgramMissing)?; + execute_program(program, execution, inputs, output_start, outputs) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ExecutableProgram { + store_buffer_indices: Vec, +} + +impl ExecutableProgram { + fn new(program: &ProgramDescriptor) -> Result { + let mut store_buffer_indices = Vec::new(); + store_buffer_indices + .try_reserve_exact(program.operations.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + for operation in &program.operations { + let index = match store_buffer(operation) { + Some(buffer) => program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)? + .try_into() + .map_err(|_| PolicyError::TooManyBuffers)?, + None => NOT_A_STORE, + }; + store_buffer_indices.push(index); + } + Ok(Self { + store_buffer_indices, + }) + } +} + +#[derive(Clone, Copy)] +pub struct SemanticInputBatch<'a> { + pub f32_fields: &'a [&'a [f32]], + pub u32_fields: &'a [&'a [u32]], + pub record_count: usize, +} + +pub struct PhysicalBufferMut<'a> { + pub schema: BufferSchema, + pub bytes: &'a mut [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PolicyExecutionError { + ProgramMissing, + InputFieldCount, + InputLength, + OutputBufferCount, + OutputSchema, + OutputCapacity, + NonFiniteOutput, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PolicyError { + AllocationFailed, EmptyPolicy, TooManyPrograms, InvalidTechniqueId, @@ -202,6 +291,7 @@ pub enum PolicyError { UninitializedRegister, RegisterTypeMismatch, InvalidInputField, + NonFiniteConstant, UnknownBuffer, StoreTypeMismatch, InvalidStoreLane, @@ -209,6 +299,354 @@ pub enum PolicyError { IncompleteBuffer, } +fn execute_program( + program: &ProgramDescriptor, + execution: &ExecutableProgram, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], +) -> Result<(), PolicyExecutionError> { + validate_execution(program, inputs, output_start, outputs)?; + #[cfg(all(target_arch = "wasm32", feature = "simd128"))] + let completed = + unsafe { execute_simd_records(program, execution, inputs, output_start, outputs)? }; + #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] + let completed = 0; + for record in completed..inputs.record_count { + execute_record( + program, + execution, + inputs, + output_start + record, + record, + outputs, + )?; + } + Ok(()) +} + +fn validate_execution( + program: &ProgramDescriptor, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &[PhysicalBufferMut<'_>], +) -> Result<(), PolicyExecutionError> { + if inputs.f32_fields.len() != usize::from(program.f32_input_count) + || inputs.u32_fields.len() != usize::from(program.u32_input_count) + { + return Err(PolicyExecutionError::InputFieldCount); + } + if inputs + .f32_fields + .iter() + .any(|field| field.len() != inputs.record_count) + || inputs + .u32_fields + .iter() + .any(|field| field.len() != inputs.record_count) + { + return Err(PolicyExecutionError::InputLength); + } + if outputs.len() != program.buffers.len() { + return Err(PolicyExecutionError::OutputBufferCount); + } + let output_end = output_start + .checked_add(inputs.record_count) + .ok_or(PolicyExecutionError::OutputCapacity)?; + for (output, schema) in outputs.iter().zip(&program.buffers) { + if output.schema != *schema { + return Err(PolicyExecutionError::OutputSchema); + } + let required = output_end + .checked_mul(schema.stride()) + .ok_or(PolicyExecutionError::OutputCapacity)?; + if output.bytes.len() < required { + return Err(PolicyExecutionError::OutputCapacity); + } + } + Ok(()) +} + +fn execute_record( + program: &ProgramDescriptor, + execution: &ExecutableProgram, + inputs: SemanticInputBatch<'_>, + output_record: usize, + input_record: usize, + outputs: &mut [PhysicalBufferMut<'_>], +) -> Result<(), PolicyExecutionError> { + let mut registers = [0_u32; MAX_REGISTERS]; + let mut values = [0_u32; MAX_OUTPUT_LANES]; + for (operation_index, operation) in program.operations.iter().enumerate() { + match *operation { + Operation::LoadF32 { target, field } => { + registers[usize::from(target)] = + inputs.f32_fields[usize::from(field)][input_record].to_bits(); + } + Operation::LoadU32 { target, field } => { + registers[usize::from(target)] = + inputs.u32_fields[usize::from(field)][input_record]; + } + Operation::ConstantF32 { target, bits } => registers[usize::from(target)] = bits, + Operation::ConstantU32 { target, value } => registers[usize::from(target)] = value, + Operation::AddF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + (register_f32(®isters, left) + register_f32(®isters, right)).to_bits(); + } + Operation::SubtractF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + (register_f32(®isters, left) - register_f32(®isters, right)).to_bits(); + } + Operation::MultiplyF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + (register_f32(®isters, left) * register_f32(®isters, right)).to_bits(); + } + Operation::LessThanF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + u32::from(register_f32(®isters, left) < register_f32(®isters, right)); + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + registers[usize::from(target)] = if registers[usize::from(condition)] != 0 { + registers[usize::from(when_true)] + } else { + registers[usize::from(when_false)] + }; + } + Operation::ConvertU32ToF32 { target, source } => { + registers[usize::from(target)] = (registers[usize::from(source)] as f32).to_bits(); + } + Operation::StoreF32 { source, lane, .. } => { + let bits = registers[usize::from(source)]; + if !f32::from_bits(bits).is_finite() { + return Err(PolicyExecutionError::NonFiniteOutput); + } + values[store_slot(execution, operation_index, lane)] = bits; + } + Operation::StoreU32 { source, lane, .. } | Operation::StoreU16 { source, lane, .. } => { + values[store_slot(execution, operation_index, lane)] = + registers[usize::from(source)]; + } + } + } + for (buffer_index, (schema, output)) in program.buffers.iter().zip(outputs).enumerate() { + let record_offset = output_record * schema.stride(); + for lane in 0..schema.vector_width { + let value = values[buffer_index * MAX_VECTOR_WIDTH as usize + usize::from(lane)]; + let lane_offset = record_offset + usize::from(lane) * schema.scalar.byte_width(); + match schema.scalar { + ScalarType::F32 | ScalarType::U32 => { + output.bytes[lane_offset..lane_offset + 4] + .copy_from_slice(&value.to_le_bytes()); + } + ScalarType::U16 => { + output.bytes[lane_offset..lane_offset + 2] + .copy_from_slice(&(value as u16).to_le_bytes()); + } + } + } + } + Ok(()) +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +unsafe fn execute_simd_records( + program: &ProgramDescriptor, + execution: &ExecutableProgram, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], +) -> Result { + use core::arch::wasm32::{ + f32x4_add, f32x4_convert_u32x4, f32x4_lt, f32x4_mul, f32x4_sub, i32x4_ne, u32x4_splat, + v128, v128_and, v128_bitselect, v128_load, + }; + + let completed = inputs.record_count & !3; + for input_record in (0..completed).step_by(4) { + let mut registers = [u32x4_splat(0); MAX_REGISTERS]; + let mut values = [u32x4_splat(0); MAX_OUTPUT_LANES]; + for (operation_index, operation) in program.operations.iter().enumerate() { + match *operation { + Operation::LoadF32 { target, field } => { + // SAFETY: validation proves this field contains four records from `input_record`. + registers[usize::from(target)] = unsafe { + v128_load( + inputs.f32_fields[usize::from(field)] + .as_ptr() + .add(input_record) + .cast::(), + ) + }; + } + Operation::LoadU32 { target, field } => { + // SAFETY: validation proves this field contains four records from `input_record`. + registers[usize::from(target)] = unsafe { + v128_load( + inputs.u32_fields[usize::from(field)] + .as_ptr() + .add(input_record) + .cast::(), + ) + }; + } + Operation::ConstantF32 { target, bits } => { + registers[usize::from(target)] = u32x4_splat(bits); + } + Operation::ConstantU32 { target, value } => { + registers[usize::from(target)] = u32x4_splat(value); + } + Operation::AddF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + f32x4_add(registers[usize::from(left)], registers[usize::from(right)]); + } + Operation::SubtractF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + f32x4_sub(registers[usize::from(left)], registers[usize::from(right)]); + } + Operation::MultiplyF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + f32x4_mul(registers[usize::from(left)], registers[usize::from(right)]); + } + Operation::LessThanF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = v128_and( + f32x4_lt(registers[usize::from(left)], registers[usize::from(right)]), + u32x4_splat(1), + ); + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + let mask = i32x4_ne(registers[usize::from(condition)], u32x4_splat(0)); + registers[usize::from(target)] = v128_bitselect( + registers[usize::from(when_true)], + registers[usize::from(when_false)], + mask, + ); + } + Operation::ConvertU32ToF32 { target, source } => { + registers[usize::from(target)] = + f32x4_convert_u32x4(registers[usize::from(source)]); + } + Operation::StoreF32 { source, lane, .. } => { + let value = registers[usize::from(source)]; + if !simd_f32_is_finite(value) { + return Err(PolicyExecutionError::NonFiniteOutput); + } + values[store_slot(execution, operation_index, lane)] = value; + } + Operation::StoreU32 { source, lane, .. } + | Operation::StoreU16 { source, lane, .. } => { + values[store_slot(execution, operation_index, lane)] = + registers[usize::from(source)]; + } + } + } + write_simd_outputs(program, &values, output_start + input_record, outputs); + } + Ok(completed) +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +fn write_simd_outputs( + program: &ProgramDescriptor, + values: &[core::arch::wasm32::v128; MAX_OUTPUT_LANES], + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], +) { + for (buffer_index, (schema, output)) in program.buffers.iter().zip(outputs).enumerate() { + for lane in 0..schema.vector_width { + let lanes = simd_u32_lanes( + values[buffer_index * MAX_VECTOR_WIDTH as usize + usize::from(lane)], + ); + for (record, value) in lanes.into_iter().enumerate() { + let lane_offset = (output_start + record) * schema.stride() + + usize::from(lane) * schema.scalar.byte_width(); + match schema.scalar { + ScalarType::F32 | ScalarType::U32 => { + output.bytes[lane_offset..lane_offset + 4] + .copy_from_slice(&value.to_le_bytes()); + } + ScalarType::U16 => { + output.bytes[lane_offset..lane_offset + 2] + .copy_from_slice(&(value as u16).to_le_bytes()); + } + } + } + } + } +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +fn simd_f32_is_finite(value: core::arch::wasm32::v128) -> bool { + simd_u32_lanes(value) + .into_iter() + .all(|bits| f32::from_bits(bits).is_finite()) +} + +#[cfg(all(target_arch = "wasm32", feature = "simd128"))] +fn simd_u32_lanes(value: core::arch::wasm32::v128) -> [u32; 4] { + // SAFETY: `v128` and four `u32` lanes are both exactly 128 bits; this preserves their bits. + unsafe { core::mem::transmute(value) } +} + +fn register_f32(registers: &[u32; MAX_REGISTERS], register: u8) -> f32 { + f32::from_bits(registers[usize::from(register)]) +} + +fn store_slot(execution: &ExecutableProgram, operation_index: usize, lane: u8) -> usize { + usize::from(execution.store_buffer_indices[operation_index]) * MAX_VECTOR_WIDTH as usize + + usize::from(lane) +} + +fn store_buffer(operation: &Operation) -> Option { + match operation { + Operation::StoreF32 { buffer, .. } + | Operation::StoreU32 { buffer, .. } + | Operation::StoreU16 { buffer, .. } => Some(*buffer), + _ => None, + } +} + fn validate_policy(descriptor: &PolicyDescriptor) -> Result<(), PolicyError> { if descriptor.programs.is_empty() { return Err(PolicyError::EmptyPolicy); @@ -297,7 +735,12 @@ fn validate_operation( } initialize(registers, target, U32_REGISTER) } - Operation::ConstantF32 { target, .. } => initialize(registers, target, F32_REGISTER), + Operation::ConstantF32 { target, bits } => { + if !f32::from_bits(bits).is_finite() { + return Err(PolicyError::NonFiniteConstant); + } + initialize(registers, target, F32_REGISTER) + } Operation::ConstantU32 { target, .. } => initialize(registers, target, U32_REGISTER), Operation::AddF32 { target, @@ -583,4 +1026,282 @@ mod tests { .unwrap(); assert_eq!(policy.programs().len(), 2); } + + #[test] + fn scalar_executor_writes_a_bounded_record_range_without_touching_spares() { + let policy = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![valid_program()], + }) + .unwrap(); + let x = [1.25, -2.5, 8.0]; + let y = [4.0, 6.5, -9.0]; + let fields: [&[f32]; 2] = [&x, &y]; + let schema = policy.program(BITMAP, 0).unwrap().buffers[0]; + let mut bytes = [0x7f_u8; 5 * 8]; + { + let mut outputs = [PhysicalBufferMut { + schema, + bytes: &mut bytes, + }]; + policy + .execute( + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: x.len(), + }, + 1, + &mut outputs, + ) + .unwrap(); + } + assert_eq!(&bytes[..8], &[0x7f; 8]); + assert_eq!(&bytes[32..], &[0x7f; 8]); + for (record, expected) in x.into_iter().zip(y).enumerate() { + let offset = (record + 1) * 8; + assert_eq!(read_f32(&bytes, offset).to_bits(), expected.0.to_bits()); + assert_eq!(read_f32(&bytes, offset + 4).to_bits(), expected.1.to_bits()); + } + } + + #[test] + fn scalar_executor_preserves_typed_arithmetic_selection_and_narrowing() { + let color = BufferId(1); + let object = BufferId(2); + let page = BufferId(3); + let policy = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![ProgramDescriptor { + technique: BITMAP, + variant: 0, + id: PROGRAM, + f32_input_count: 1, + u32_input_count: 1, + capabilities: ProgramCapabilities::default(), + buffers: vec![ + BufferSchema { + id: color, + scalar: ScalarType::F32, + vector_width: 2, + }, + BufferSchema { + id: object, + scalar: ScalarType::U32, + vector_width: 1, + }, + BufferSchema { + id: page, + scalar: ScalarType::U16, + vector_width: 1, + }, + ], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::ConstantF32 { + target: 1, + bits: 0.0_f32.to_bits(), + }, + Operation::LessThanF32 { + target: 2, + left: 0, + right: 1, + }, + Operation::ConstantF32 { + target: 3, + bits: (-1.0_f32).to_bits(), + }, + Operation::SelectF32 { + target: 4, + condition: 2, + when_true: 3, + when_false: 0, + }, + Operation::LoadU32 { + target: 5, + field: 0, + }, + Operation::ConvertU32ToF32 { + target: 6, + source: 5, + }, + Operation::AddF32 { + target: 7, + left: 4, + right: 6, + }, + Operation::ConstantF32 { + target: 8, + bits: 2.0_f32.to_bits(), + }, + Operation::MultiplyF32 { + target: 9, + left: 0, + right: 8, + }, + Operation::StoreF32 { + source: 7, + buffer: color, + lane: 0, + }, + Operation::StoreF32 { + source: 9, + buffer: color, + lane: 1, + }, + Operation::StoreU32 { + source: 5, + buffer: object, + lane: 0, + }, + Operation::StoreU16 { + source: 5, + buffer: page, + lane: 0, + }, + ], + }], + }) + .unwrap(); + let signed = [-2.0, 3.0]; + let identifiers = [70_000, 42]; + let f32_fields: [&[f32]; 1] = [&signed]; + let u32_fields: [&[u32]; 1] = [&identifiers]; + let mut colors = [0_u8; 16]; + let mut objects = [0_u8; 8]; + let mut pages = [0_u8; 4]; + let program = policy.program(BITMAP, 0).unwrap(); + let mut outputs = [ + PhysicalBufferMut { + schema: program.buffers[0], + bytes: &mut colors, + }, + PhysicalBufferMut { + schema: program.buffers[1], + bytes: &mut objects, + }, + PhysicalBufferMut { + schema: program.buffers[2], + bytes: &mut pages, + }, + ]; + policy + .execute( + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &f32_fields, + u32_fields: &u32_fields, + record_count: 2, + }, + 0, + &mut outputs, + ) + .unwrap(); + assert_eq!(read_f32(&colors, 0), 69_999.0); + assert_eq!(read_f32(&colors, 4), -4.0); + assert_eq!(read_f32(&colors, 8), 45.0); + assert_eq!(read_f32(&colors, 12), 6.0); + assert_eq!(read_u32(&objects, 0), 70_000); + assert_eq!(read_u32(&objects, 4), 42); + assert_eq!(read_u16(&pages, 0), 70_000_u32 as u16); + assert_eq!(read_u16(&pages, 2), 42); + } + + #[test] + fn scalar_executor_rejects_invalid_shapes_before_writing() { + let policy = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![valid_program()], + }) + .unwrap(); + let x = [1.0, 2.0]; + let short_y = [3.0]; + let fields: [&[f32]; 2] = [&x, &short_y]; + let mut bytes = [0xa5_u8; 16]; + let mut outputs = [PhysicalBufferMut { + schema: policy.program(BITMAP, 0).unwrap().buffers[0], + bytes: &mut bytes, + }]; + assert_eq!( + policy.execute( + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: 2, + }, + 0, + &mut outputs, + ), + Err(PolicyExecutionError::InputLength) + ); + assert_eq!(bytes, [0xa5; 16]); + } + + #[test] + fn policy_and_executor_reject_nonfinite_physical_values() { + let mut constant = valid_program(); + constant.operations[0] = Operation::ConstantF32 { + target: 0, + bits: f32::INFINITY.to_bits(), + }; + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + programs: vec![constant], + }), + Err(PolicyError::NonFiniteConstant) + ); + + let mut overflow = valid_program(); + overflow.operations.insert( + 2, + Operation::MultiplyF32 { + target: 0, + left: 0, + right: 1, + }, + ); + let policy = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![overflow], + }) + .unwrap(); + let values = [f32::MAX]; + let fields: [&[f32]; 2] = [&values, &values]; + let mut bytes = [0x5a_u8; 8]; + let mut outputs = [PhysicalBufferMut { + schema: policy.program(BITMAP, 0).unwrap().buffers[0], + bytes: &mut bytes, + }]; + assert_eq!( + policy.execute( + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: 1, + }, + 0, + &mut outputs, + ), + Err(PolicyExecutionError::NonFiniteOutput) + ); + assert_eq!(bytes, [0x5a; 8]); + } + + fn read_f32(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn read_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()) + } } diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 8cb79f78..ee9fff6f 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -297,6 +297,75 @@ pub unsafe extern "C" fn pmndrs_text_kernel_lab_chunk_summaries( } } +#[cfg(feature = "kernel-lab")] +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn pmndrs_text_kernel_lab_policy( + policy_handle: u32, + technique: u32, + variant: u32, + count: u32, + f32_input0_pointer: u32, + f32_input1_pointer: u32, + f32_input2_pointer: u32, + f32_input3_pointer: u32, + u32_input0_pointer: u32, + f32_output_pointer: u32, + u32_output_pointer: u32, + u16_output_pointer: u32, +) -> u32 { + let Some(f32_bytes) = count.checked_mul(core::mem::size_of::() as u32) else { + return STATUS_INVALID_REQUEST; + }; + let Some(f32_output_bytes) = f32_bytes.checked_mul(4) else { + return STATUS_INVALID_REQUEST; + }; + let Some(u16_bytes) = count.checked_mul(core::mem::size_of::() as u32) else { + return STATUS_INVALID_REQUEST; + }; + with_state(|state| { + let regions = [ + (f32_input0_pointer, f32_bytes), + (f32_input1_pointer, f32_bytes), + (f32_input2_pointer, f32_bytes), + (f32_input3_pointer, f32_bytes), + (u32_input0_pointer, f32_bytes), + (f32_output_pointer, f32_output_bytes), + (u32_output_pointer, f32_bytes), + (u16_output_pointer, u16_bytes), + ]; + if !regions + .iter() + .all(|&(pointer, length)| owns_region(&state.allocations, pointer, length)) + { + return STATUS_INVALID_REQUEST; + } + let policy = match state.engine.policy(policy_handle) { + Ok(policy) => policy, + Err(_) => return STATUS_POLICY_MISSING, + }; + // SAFETY: every direct-memory region belongs to a live caller allocation, so it cannot + // alias engine-owned state. The kernel validates alignment, bounds, and pairwise + // disjointness before creating slices, and this state borrow remains synchronous. + unsafe { + crate::engine::kernel_lab::exported_policy( + policy, + technique, + variant, + count, + f32_input0_pointer, + f32_input1_pointer, + f32_input2_pointer, + f32_input3_pointer, + u32_input0_pointer, + f32_output_pointer, + u32_output_pointer, + u16_output_pointer, + ) + } + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn pmndrs_text_engine_update( session_id: u32, @@ -507,6 +576,20 @@ fn owned_bytes(allocations: &[Allocation], pointer: u32, length: u32) -> Option< .map(|entry| entry.bytes.as_slice()) } +#[cfg(feature = "kernel-lab")] +fn owns_region(allocations: &[Allocation], pointer: u32, length: u32) -> bool { + let Some(end) = pointer.checked_add(length) else { + return false; + }; + length != 0 + && allocations.iter().any(|entry| { + entry + .pointer + .checked_add(entry.requested_length) + .is_some_and(|allocation_end| pointer >= entry.pointer && end <= allocation_end) + }) +} + fn store_result(registry: &mut ShaperRegistry, result: Vec) -> u32 { match registry.set_result(result) { Ok(()) => 0, diff --git a/packages/text/scripts/support/engine-kernel-fixture.mts b/packages/text/scripts/support/engine-kernel-fixture.mts index 52f2e646..0e1ecbb8 100644 --- a/packages/text/scripts/support/engine-kernel-fixture.mts +++ b/packages/text/scripts/support/engine-kernel-fixture.mts @@ -1,8 +1,11 @@ +import { readFile } from 'node:fs/promises'; + import { createBenchmarkParagraph, loadParagraphBenchmarkFixture, paragraphTextForGlyphs, } from './paragraph-benchmark-fixture.mts'; +import { kernelPolicyBytes } from '../../tests/support/engine-abi.mjs'; export interface CapturedKernelInput { readonly label: string; @@ -17,12 +20,17 @@ export interface CapturedKernelInput { readonly advances: Int32Array; readonly flags: Uint8Array; readonly levels: Uint8Array; + readonly policy: Uint8Array; } export async function captureKernelWorkloads(targets: readonly number[]): Promise { - const fixture = await loadParagraphBenchmarkFixture(); + const [fixture, abi] = await Promise.all([ + loadParagraphBenchmarkFixture(), + readFile(new URL('../../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8').then(JSON.parse), + ]); + const policy = kernelPolicyBytes(abi); try { - return targets.map((target) => captureWorkload(fixture, target)); + return targets.map((target) => captureWorkload(fixture, target, policy)); } finally { fixture.runtime.dispose(); fixture.loaded.dispose(); @@ -32,6 +40,7 @@ export async function captureKernelWorkloads(targets: readonly number[]): Promis function captureWorkload( fixture: Awaited>, targetGlyphs: number, + policy: Uint8Array, ): CapturedKernelInput { const text = paragraphTextForGlyphs(targetGlyphs); const created = createBenchmarkParagraph(fixture, text, 600); @@ -78,6 +87,7 @@ function captureWorkload( advances, flags, levels, + policy, }; created.batch.dispose(); return captured; diff --git a/packages/text/scripts/support/engine-kernel-runner.mjs b/packages/text/scripts/support/engine-kernel-runner.mjs index bc7aa560..a95866ec 100644 --- a/packages/text/scripts/support/engine-kernel-runner.mjs +++ b/packages/text/scripts/support/engine-kernel-runner.mjs @@ -8,6 +8,7 @@ export async function benchmarkKernelArtifact(wasm, name, input, options) { if (exports.pmndrs_text_kernel_lab_backend() !== expectedBackend) { throw new Error(`${name} artifact selected the wrong compile-time kernel backend`); } + registerPolicy(exports, input.policy); const aligned = createMemoryFixture(exports, input, 0); const unaligned = createMemoryFixture(exports, input, 4); const memoryBefore = exports.memory.buffer; @@ -31,6 +32,7 @@ export async function benchmarkKernelArtifact(wasm, name, input, options) { iterations * 4, options, ), + policy: measure(() => checkedCall(() => callPolicy(exports, aligned, false)), iterations, options), chunk32: measure(() => checkedCall(() => callSummaries(exports, aligned, 32)), iterations * 2, options), chunk64: measure(() => checkedCall(() => callSummaries(exports, aligned, 64)), iterations * 2, options), chunk128: measure(() => checkedCall(() => callSummaries(exports, aligned, 128)), iterations * 2, options), @@ -51,7 +53,7 @@ export async function benchmarkKernelArtifact(wasm, name, input, options) { } function createMemoryFixture(exports, input, skew) { - const allocationLength = input.glyphs * 64 + 4_096; + const allocationLength = input.glyphs * 96 + 4_096; const allocationPointer = exports.pmndrs_text_shaper_alloc(allocationLength); if (allocationPointer === 0) throw new Error('kernel-lab allocation failed'); let cursor = alignWithSkew(allocationPointer, 16, skew); @@ -81,6 +83,9 @@ function createMemoryFixture(exports, input, skew) { const summaryCapacity = Math.ceil(count / 32); const advanceSums = reserve(summaryCapacity, 8, 8); const breakCounts = reserve(summaryCapacity, 4, 4); + const policyF32 = reserve(count * 4, 4, 4); + const policyU32 = reserve(count, 4, 4); + const policyU16 = reserve(count, 2, 2); if (cursor > allocationPointer + allocationLength) throw new Error('kernel-lab memory layout exceeds its allocation'); new Float32Array(exports.memory.buffer, x, count).set(input.x); @@ -113,6 +118,9 @@ function createMemoryFixture(exports, input, skew) { bidiMasks, advanceSums, breakCounts, + policyF32, + policyU32, + policyU16, summaryCapacity, memory: exports.memory, }; @@ -122,11 +130,15 @@ async function executeAndHash(exports, fixture, vertical) { checkedCall(() => callPack(exports, fixture, vertical)); checkedCall(() => exports.pmndrs_text_kernel_lab_break_masks(fixture.count, fixture.flags, fixture.breakMasks)); checkedCall(() => exports.pmndrs_text_kernel_lab_bidi_masks(fixture.count, fixture.levels, fixture.bidiMasks)); + checkedCall(() => callPolicy(exports, fixture, vertical)); const parts = [ bytes(fixture, fixture.origins, fixture.count * 2 * 4), bytes(fixture, fixture.sizes, fixture.count * 2 * 4), bytes(fixture, fixture.breakMasks, Math.ceil(fixture.count / 16) * 2), bytes(fixture, fixture.bidiMasks, Math.ceil(fixture.count / 16) * 2), + bytes(fixture, fixture.policyF32, fixture.count * 4 * 4), + bytes(fixture, fixture.policyU32, fixture.count * 4), + bytes(fixture, fixture.policyU16, fixture.count * 2), ]; for (const chunkSize of CHUNK_SIZES) { checkedCall(() => callSummaries(exports, fixture, chunkSize)); @@ -164,6 +176,23 @@ function callSummaries(exports, fixture, chunkSize) { ); } +function callPolicy(exports, fixture, vertical) { + return exports.pmndrs_text_kernel_lab_policy( + 1, + 1, + 0, + fixture.count, + vertical ? fixture.y : fixture.x, + vertical ? fixture.x : fixture.y, + fixture.fontSize, + fixture.planeLeft, + fixture.advances, + fixture.policyF32, + fixture.policyU32, + fixture.policyU16, + ); +} + function measure(operation, iterations, options) { for (let sample = 0; sample < options.warmup; sample += 1) { for (let index = 0; index < iterations; index += 1) operation(); @@ -210,3 +239,11 @@ function percentile(sorted, quantile) { function alignWithSkew(value, alignment, skew) { return Math.ceil((value - skew) / alignment) * alignment + skew; } + +function registerPolicy(exports, policy) { + const pointer = exports.pmndrs_text_shaper_alloc(policy.byteLength); + if (pointer === 0) throw new Error('kernel-lab policy allocation failed'); + new Uint8Array(exports.memory.buffer, pointer, policy.byteLength).set(policy); + checkedCall(() => exports.pmndrs_text_engine_register_policy(1, pointer, policy.byteLength)); + exports.pmndrs_text_shaper_dealloc(pointer, policy.byteLength); +} diff --git a/packages/text/tests/support/engine-abi.d.mts b/packages/text/tests/support/engine-abi.d.mts new file mode 100644 index 00000000..25743401 --- /dev/null +++ b/packages/text/tests/support/engine-abi.d.mts @@ -0,0 +1,15 @@ +export interface EngineUpdateFields { + readonly sessionId: number; + readonly policyHandle: number; + readonly expectedEngineRevision: number; + readonly consumedPlanRevision: number; +} + +export function renderPolicyBytes(abi: object): Uint8Array; +export function kernelPolicyBytes(abi: object): Uint8Array; +export function engineUpdateBytes(abi: object, fields: EngineUpdateFields): Uint8Array; +export function copyIntoAllocation( + memory: WebAssembly.Memory, + allocate: (byteLength: number) => number, + bytes: Uint8Array, +): number; diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 62e800a8..ee8242ed 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -1,42 +1,53 @@ export function renderPolicyBytes(abi) { - const request = abi.layouts.policyRequest; - const program = abi.layouts.policyProgram; - const buffer = abi.layouts.policyBuffer; - const operation = abi.layouts.policyOperation; - const programsOffset = align(request.size, program.alignment); - const buffersOffset = align(programsOffset + program.size, buffer.alignment); - const operationsOffset = align(buffersOffset + buffer.size, operation.alignment); - const operationCount = 2; - const bytes = new Uint8Array(operationsOffset + operation.size * operationCount); - const view = new DataView(bytes.buffer); - - view.setUint32(request.byteLength, bytes.byteLength, true); - view.setUint32(request.programsOffset, programsOffset, true); - view.setUint32(request.programCount, 1, true); - view.setUint32(request.buffersOffset, buffersOffset, true); - view.setUint32(request.bufferCount, 1, true); - view.setUint32(request.operationsOffset, operationsOffset, true); - view.setUint32(request.operationCount, operationCount, true); - - view.setUint32(programsOffset + program.techniqueId, 1, true); - view.setUint32(programsOffset + program.programId, 1, true); - view.setUint8(programsOffset + program.f32InputCount, 1); - view.setUint16(programsOffset + program.bufferCount, 1, true); - view.setUint16(programsOffset + program.operationCount, operationCount, true); - - view.setUint16(buffersOffset + buffer.id, 1, true); - view.setUint8(buffersOffset + buffer.scalar, abi.policy.scalarTypes.f32); - view.setUint8(buffersOffset + buffer.vectorWidth, 1); - - view.setUint8(operationsOffset + operation.opcode, abi.policy.opcodes.loadF32); - view.setUint8(operationsOffset + operation.target, 0); - view.setUint8(operationsOffset + operation.operand0, 0); + return policyBytes(abi, [ + { + techniqueId: 1, + programId: 1, + f32InputCount: 1, + u32InputCount: 0, + buffers: [{ id: 1, scalar: abi.policy.scalarTypes.f32, vectorWidth: 1 }], + operations: [ + { opcode: abi.policy.opcodes.loadF32, target: 0, operand0: 0 }, + { opcode: abi.policy.opcodes.storeF32, operand0: 0, immediate0: 1 }, + ], + }, + ]); +} - const storeOffset = operationsOffset + operation.size; - view.setUint8(storeOffset + operation.opcode, abi.policy.opcodes.storeF32); - view.setUint8(storeOffset + operation.operand0, 0); - view.setUint32(storeOffset + operation.immediate0, 1, true); - return bytes; +export function kernelPolicyBytes(abi) { + const opcodes = abi.policy.opcodes; + return policyBytes(abi, [ + { + techniqueId: 1, + programId: 1, + f32InputCount: 4, + u32InputCount: 1, + buffers: [ + { id: 1, scalar: abi.policy.scalarTypes.f32, vectorWidth: 4 }, + { id: 2, scalar: abi.policy.scalarTypes.u32, vectorWidth: 1 }, + { id: 3, scalar: abi.policy.scalarTypes.u16, vectorWidth: 1 }, + ], + operations: [ + { opcode: opcodes.loadF32, target: 0, operand0: 0 }, + { opcode: opcodes.loadF32, target: 1, operand0: 1 }, + { opcode: opcodes.addF32, target: 2, operand0: 0, operand1: 1 }, + { opcode: opcodes.loadF32, target: 3, operand0: 2 }, + { opcode: opcodes.multiplyF32, target: 4, operand0: 2, operand1: 3 }, + { opcode: opcodes.loadF32, target: 5, operand0: 3 }, + { opcode: opcodes.lessThanF32, target: 6, operand0: 5, operand1: 0 }, + { opcode: opcodes.selectF32, target: 7, operand0: 6, operand1: 4, immediate0: 2 }, + { opcode: opcodes.loadU32, target: 8, operand0: 0 }, + { opcode: opcodes.convertU32ToF32, target: 9, operand0: 8 }, + { opcode: opcodes.addF32, target: 10, operand0: 7, operand1: 9 }, + { opcode: opcodes.storeF32, operand0: 0, operand1: 0, immediate0: 1 }, + { opcode: opcodes.storeF32, operand0: 2, operand1: 1, immediate0: 1 }, + { opcode: opcodes.storeF32, operand0: 7, operand1: 2, immediate0: 1 }, + { opcode: opcodes.storeF32, operand0: 10, operand1: 3, immediate0: 1 }, + { opcode: opcodes.storeU32, operand0: 8, operand1: 0, immediate0: 2 }, + { opcode: opcodes.storeU16, operand0: 8, operand1: 0, immediate0: 3 }, + ], + }, + ]); } export function engineUpdateBytes(abi, { sessionId, policyHandle, expectedEngineRevision, consumedPlanRevision }) { @@ -73,3 +84,67 @@ export function copyIntoAllocation(memory, allocate, bytes) { function align(value, alignment) { return Math.ceil(value / alignment) * alignment; } + +function policyBytes(abi, programs) { + const requestLayout = abi.layouts.policyRequest; + const programLayout = abi.layouts.policyProgram; + const bufferLayout = abi.layouts.policyBuffer; + const operationLayout = abi.layouts.policyOperation; + const bufferCount = programs.reduce((total, program) => total + program.buffers.length, 0); + const operationCount = programs.reduce((total, program) => total + program.operations.length, 0); + const programsOffset = align(requestLayout.size, programLayout.alignment); + const buffersOffset = align(programsOffset + programLayout.size * programs.length, bufferLayout.alignment); + const operationsOffset = align(buffersOffset + bufferLayout.size * bufferCount, operationLayout.alignment); + const bytes = new Uint8Array(operationsOffset + operationLayout.size * operationCount); + const view = new DataView(bytes.buffer); + view.setUint32(requestLayout.byteLength, bytes.byteLength, true); + view.setUint32(requestLayout.programsOffset, programsOffset, true); + view.setUint32(requestLayout.programCount, programs.length, true); + view.setUint32(requestLayout.buffersOffset, buffersOffset, true); + view.setUint32(requestLayout.bufferCount, bufferCount, true); + view.setUint32(requestLayout.operationsOffset, operationsOffset, true); + view.setUint32(requestLayout.operationCount, operationCount, true); + + let bufferStart = 0; + let operationStart = 0; + for (let index = 0; index < programs.length; index += 1) { + const descriptor = programs[index]; + const offset = programsOffset + index * programLayout.size; + view.setUint32(offset + programLayout.techniqueId, descriptor.techniqueId, true); + view.setUint32(offset + programLayout.programId, descriptor.programId, true); + view.setUint16(offset + programLayout.variant, descriptor.variant ?? 0, true); + view.setUint8(offset + programLayout.f32InputCount, descriptor.f32InputCount); + view.setUint8(offset + programLayout.u32InputCount, descriptor.u32InputCount); + view.setUint32(offset + programLayout.paintCapabilities, descriptor.paintCapabilities ?? 0, true); + view.setUint32(offset + programLayout.compositingCapabilities, descriptor.compositingCapabilities ?? 0, true); + view.setUint32(offset + programLayout.bufferStart, bufferStart, true); + view.setUint16(offset + programLayout.bufferCount, descriptor.buffers.length, true); + view.setUint32(offset + programLayout.operationStart, operationStart, true); + view.setUint16(offset + programLayout.operationCount, descriptor.operations.length, true); + bufferStart += descriptor.buffers.length; + operationStart += descriptor.operations.length; + } + let bufferIndex = 0; + let operationIndex = 0; + for (const descriptor of programs) { + for (const buffer of descriptor.buffers) { + const offset = buffersOffset + bufferIndex * bufferLayout.size; + view.setUint16(offset + bufferLayout.id, buffer.id, true); + view.setUint8(offset + bufferLayout.scalar, buffer.scalar); + view.setUint8(offset + bufferLayout.vectorWidth, buffer.vectorWidth); + bufferIndex += 1; + } + for (const operation of descriptor.operations) { + const offset = operationsOffset + operationIndex * operationLayout.size; + view.setUint8(offset + operationLayout.opcode, operation.opcode); + view.setUint8(offset + operationLayout.target, operation.target ?? 0); + view.setUint8(offset + operationLayout.operand0, operation.operand0 ?? 0); + view.setUint8(offset + operationLayout.operand1, operation.operand1 ?? 0); + view.setUint32(offset + operationLayout.immediate0, operation.immediate0 ?? 0, true); + view.setUint32(offset + operationLayout.immediate1, operation.immediate1 ?? 0, true); + view.setUint32(offset + operationLayout.immediate2, operation.immediate2 ?? 0, true); + operationIndex += 1; + } + } + return bytes; +} From 2ba5e1976153e93bc436c63d0f6e2ae103f5c251 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 07:16:22 -0400 Subject: [PATCH 008/128] feat(text): define retained render plan wire records --- docs/log.md | 8 + docs/packages/text.md | 18 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 9 + packages/text/rust/shaper/src/abi_contract.rs | 333 +++++++++ packages/text/rust/shaper/src/engine/mod.rs | 2 + .../text/rust/shaper/src/engine/policy.rs | 127 ++++ .../rust/shaper/src/engine/render_plan.rs | 193 +++++ .../shaper/src/engine/render_plan_wire.rs | 699 ++++++++++++++++++ .../text/rust/shaper/src/engine/transport.rs | 181 ++++- packages/text/rust/shaper/src/wasm.rs | 55 +- .../text/src/generated/text-shaper-abi.ts | 199 ++++- .../render-plan-frame-abi.test.mjs | 18 +- 13 files changed, 1795 insertions(+), 48 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/render_plan.rs create mode 100644 packages/text/rust/shaper/src/engine/render_plan_wire.rs diff --git a/docs/log.md b/docs/log.md index 26135de7..0a0f364a 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Fixed the compiler-mapped render-plan wire grammar** — Extended the aligned result header from 128 to 144 bytes to + carry policy handle, capability set, and a deterministic validated-policy fingerprint. Added exact semantic, resource, + buffer, patch, primitive, draw, retirement, and diagnostic records plus tagged actions and allocation strategies. + Field-wise little-endian serialization rebases write-patch payloads inside the same immutable A/B publication and + rejects malformed spans before touching its inactive arena. Rust unit tests cover every table and the header-to-table + linkage; real Wasm integration reproduces policy identity, checkpoint/delta revisions, failure isolation, and A/B + immutability. Retained semantic compilation is not yet claimed. + - **Admitted explicit-SIMD render-policy execution** — Added a production scalar interpreter for validated straight-line render policies and a four-record `simd128` executor with scalar tails. Registration resolves policy buffer IDs once; warm execution consumes borrowed semantic SoA fields, preflights every output, allocates nothing, and requires every diff --git a/docs/packages/text.md b/docs/packages/text.md index 3d0b625d..69e813b4 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:a79c32c44c09be60e4643a1a6c500bb0748901f89e4745e9ebe7cc262a9617c2' +source_digest: 'sha256:5f930e6e6458e184cc932a772b91113765a245c0c139ed771bd902d594076d3d' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -388,8 +388,9 @@ The retained frame shell gives each engine session one 16-byte-aligned request a arenas. Cold creation and reservation may resize them; a warm update reads the already-pinned request and returns the selected result pointer in that single call. The compiler-derived request header is 120 bytes. Its section offsets cover text/style mutations, constraints, regions, exclusions, inline objects, and policy parameters; Stage 1 accepts only the -canonical empty transaction and rejects a nonempty section until its Rust consumer exists. The 128-byte aligned result -header already fixes revisions, base requirements, capacity watermarks, output slot and generation, plus semantic, +canonical empty transaction and rejects a nonempty section until its Rust consumer exists. The 144-byte aligned result +header fixes revisions, base requirements, capacity watermarks, output slot and generation, policy handle, capability +set, policy fingerprint, plus semantic, resource, physical-buffer, patch, primitive, draw, retirement, and diagnostic table locations. Successful publication alternates A/B slots; a failed parse or revision check writes the inactive slot without advancing or modifying the active publication. The real optimized-Wasm test proves that a warm update preserves `memory.buffer`, while an 8 MiB cold @@ -404,6 +405,17 @@ conformance scenarios, and 172,156-byte packed-consumer proof at its deliberately stale checked package-size snapshot; this stage records the actual measured size without rewriting that unrelated historical evidence. +The render-plan wire layer now gives those result tables concrete compiler-mapped records: semantic 44 bytes, resource +40, physical buffer 36, patch 36, primitive 64, draw 48, retirement 24, and diagnostic 24. Resource kind is independent +from create/update/retain action, and ordered-direct versus stable-indirect allocation is a dedicated buffer strategy. +Patch payload bytes live inside the same immutable publication and write records carry absolute rebased spans; allocate/ +resize, fill, copy, and retire records do not carry a payload address. Serialization is allocation-free, canonical +little-endian, and explicitly field-wise rather than a raw Rust-struct copy. Validation proves finite geometry, known +tags, bounded table ranges, and exact payload spans before touching the inactive arena. The result header publishes the +registered policy fingerprint with the plan, while failure headers expose neither that identity nor partial table state. +The current shipping update still emits an empty plan until retained semantic compilation lands; these records prove the +wire and publication contract, not incremental-layout performance. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index cd080546..6575df1f 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -228,6 +228,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | | D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | | D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | +| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not a backend command buffer or raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (48), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index a15744ae..9d1a83c1 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -651,6 +651,15 @@ retained array and applies later patches to it. WebGPU may consume re-pinned Was Bitmap uses `vec2`/`vec4` records and MSDF and Slug use `vec4`/`uvec4` records, all valid in the fallback. A minimal native consumer proves schema, patch, revision, and retirement semantics without claiming another renderer integration. +The V0 wire checkpoint uses a 144-byte, 16-byte-aligned result header followed by compiler-mapped little-endian tables: +44-byte semantic, 40-byte resource, 36-byte physical-buffer, 36-byte patch, 64-byte primitive, 48-byte draw, 24-byte +retirement, and 24-byte diagnostic records. Resource kind and create/update/retain action are separate. Buffer strategy +is an explicit ordered-direct or stable-indirect tag. Variable patch payload bytes are part of the same immutable +publication; write patches rebase their checked payload span to an absolute result offset. Other patch opcodes carry no +payload address. The header identifies the registered policy by handle and deterministic fingerprint. This fixes a +portable display-list/resource-transaction grammar; it does not expose Rust layout, padding, or native-endian struct +copies to consumers. + ### Minimal updates “Minimal” is policy-relative and measured. The objective includes bytes scanned, bytes rewritten, upload bytes, upload diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 44d71248..a38e79b6 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -8,6 +8,15 @@ use crate::engine::policy::{ OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, ScalarType, }; +use crate::engine::render_plan::{ + BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT, BufferRecord, DiagnosticRecord, DrawRecord, + PATCH_ALLOCATE_OR_RESIZE, PATCH_COPY, PATCH_FILL, PATCH_RETIRE, PATCH_WRITE, PRIMITIVE_CLIP, + PRIMITIVE_DECORATION, PRIMITIVE_GLYPH, PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, + PrimitiveRecord, RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, + RETIRE_BUFFER, RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, ResourceRecord, + RetirementRecord, SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_INSERTED_GLYPH, + SEMANTIC_LINE, SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, +}; pub const ABI_VERSION: u32 = 0; pub const SHAPER_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -138,6 +147,10 @@ struct EngineResultHeader { required_request_capacity: u32, result_capacity: u32, required_result_capacity: u32, + policy_handle: u32, + capability_set: u32, + policy_fingerprint_low: u32, + policy_fingerprint_high: u32, semantics_offset: u32, semantics_count: u32, resources_offset: u32, @@ -271,6 +284,34 @@ layout!( ENGINE_RESULT_HEADER_ALIGNMENT, EngineResultHeader ); +layout!( + SEMANTIC_RECORD_SIZE, + SEMANTIC_RECORD_ALIGNMENT, + SemanticRecord +); +layout!( + RESOURCE_RECORD_SIZE, + RESOURCE_RECORD_ALIGNMENT, + ResourceRecord +); +layout!(BUFFER_RECORD_SIZE, BUFFER_RECORD_ALIGNMENT, BufferRecord); +layout!(PATCH_RECORD_SIZE, PATCH_RECORD_ALIGNMENT, PatchRecord); +layout!( + PRIMITIVE_RECORD_SIZE, + PRIMITIVE_RECORD_ALIGNMENT, + PrimitiveRecord +); +layout!(DRAW_RECORD_SIZE, DRAW_RECORD_ALIGNMENT, DrawRecord); +layout!( + RETIREMENT_RECORD_SIZE, + RETIREMENT_RECORD_ALIGNMENT, + RetirementRecord +); +layout!( + DIAGNOSTIC_RECORD_SIZE, + DIAGNOSTIC_RECORD_ALIGNMENT, + DiagnosticRecord +); layout!(FEATURE_RECORD_SIZE, FEATURE_RECORD_ALIGNMENT, FeatureRecord); layout!(RUN_RECORD_SIZE, RUN_RECORD_ALIGNMENT, RunRecord); layout!( @@ -578,6 +619,26 @@ field_offset!( EngineResultHeader, required_result_capacity ); +field_offset!( + ENGINE_RESULT_POLICY_HANDLE, + EngineResultHeader, + policy_handle +); +field_offset!( + ENGINE_RESULT_CAPABILITY_SET, + EngineResultHeader, + capability_set +); +field_offset!( + ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + EngineResultHeader, + policy_fingerprint_low +); +field_offset!( + ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, + EngineResultHeader, + policy_fingerprint_high +); field_offset!( ENGINE_RESULT_SEMANTICS_OFFSET, EngineResultHeader, @@ -642,6 +703,113 @@ field_offset!( EngineResultHeader, diagnostic_count ); +field_offset!(SEMANTIC_ID, SemanticRecord, id); +field_offset!(SEMANTIC_KIND, SemanticRecord, kind); +field_offset!(SEMANTIC_FLAGS, SemanticRecord, flags); +field_offset!(SEMANTIC_PARENT_ID, SemanticRecord, parent_id); +field_offset!(SEMANTIC_TEXT_START, SemanticRecord, text_start); +field_offset!(SEMANTIC_TEXT_END, SemanticRecord, text_end); +field_offset!(SEMANTIC_ITEM_START, SemanticRecord, item_start); +field_offset!(SEMANTIC_ITEM_COUNT, SemanticRecord, item_count); +field_offset!(SEMANTIC_INLINE_START, SemanticRecord, inline_start); +field_offset!(SEMANTIC_BLOCK_START, SemanticRecord, block_start); +field_offset!(SEMANTIC_INLINE_EXTENT, SemanticRecord, inline_extent); +field_offset!(SEMANTIC_BLOCK_EXTENT, SemanticRecord, block_extent); +field_offset!(RESOURCE_ID, ResourceRecord, id); +field_offset!(RESOURCE_GENERATION, ResourceRecord, generation); +field_offset!(RESOURCE_TECHNIQUE_ID, ResourceRecord, technique_id); +field_offset!(RESOURCE_KIND, ResourceRecord, resource_kind); +field_offset!(RESOURCE_ACTION, ResourceRecord, action); +field_offset!(RESOURCE_FLAGS, ResourceRecord, flags); +field_offset!(RESOURCE_REFERENCE_ID, ResourceRecord, reference_id); +field_offset!(RESOURCE_LOWER_BOUND, ResourceRecord, lower_bound); +field_offset!(RESOURCE_UPPER_BOUND, ResourceRecord, upper_bound); +field_offset!(RESOURCE_AUXILIARY0, ResourceRecord, auxiliary0); +field_offset!(RESOURCE_AUXILIARY1, ResourceRecord, auxiliary1); +field_offset!(BUFFER_ID, BufferRecord, id); +field_offset!(BUFFER_GENERATION, BufferRecord, generation); +field_offset!(BUFFER_PROGRAM_ID, BufferRecord, program_id); +field_offset!(BUFFER_POLICY_BUFFER_ID, BufferRecord, policy_buffer_id); +field_offset!(BUFFER_SCALAR_TYPE, BufferRecord, scalar_type); +field_offset!(BUFFER_VECTOR_WIDTH, BufferRecord, vector_width); +field_offset!(BUFFER_STRATEGY, BufferRecord, strategy); +field_offset!(BUFFER_FLAGS, BufferRecord, flags); +field_offset!(BUFFER_LIVE_RECORDS, BufferRecord, live_records); +field_offset!(BUFFER_CAPACITY_RECORDS, BufferRecord, capacity_records); +field_offset!(BUFFER_BYTE_LENGTH, BufferRecord, byte_length); +field_offset!(BUFFER_ORDER_BUFFER_ID, BufferRecord, order_buffer_id); +field_offset!(PATCH_OPCODE, PatchRecord, opcode); +field_offset!(PATCH_FLAGS, PatchRecord, flags); +field_offset!(PATCH_BUFFER_ID, PatchRecord, buffer_id); +field_offset!(PATCH_BUFFER_GENERATION, PatchRecord, buffer_generation); +field_offset!(PATCH_DESTINATION_OFFSET, PatchRecord, destination_offset); +field_offset!(PATCH_BYTE_LENGTH, PatchRecord, byte_length); +field_offset!(PATCH_PAYLOAD_OFFSET, PatchRecord, payload_start); +field_offset!(PATCH_SOURCE_BUFFER_ID, PatchRecord, source_buffer_id); +field_offset!(PATCH_SOURCE_OFFSET, PatchRecord, source_offset); +field_offset!(PATCH_FILL_VALUE, PatchRecord, fill_value); +field_offset!(PRIMITIVE_ID, PrimitiveRecord, id); +field_offset!(PRIMITIVE_KIND, PrimitiveRecord, kind); +field_offset!(PRIMITIVE_FLAGS, PrimitiveRecord, flags); +field_offset!(PRIMITIVE_TECHNIQUE_ID, PrimitiveRecord, technique_id); +field_offset!(PRIMITIVE_RESOURCE_ID, PrimitiveRecord, resource_id); +field_offset!( + PRIMITIVE_RESOURCE_GENERATION, + PrimitiveRecord, + resource_generation +); +field_offset!(PRIMITIVE_PROGRAM_ID, PrimitiveRecord, program_id); +field_offset!(PRIMITIVE_VARIANT, PrimitiveRecord, variant); +field_offset!(PRIMITIVE_RESERVED, PrimitiveRecord, reserved); +field_offset!(PRIMITIVE_BUFFER_ID, PrimitiveRecord, buffer_id); +field_offset!(PRIMITIVE_RECORD_INDEX, PrimitiveRecord, record_index); +field_offset!(PRIMITIVE_LOGICAL_ORDER, PrimitiveRecord, logical_order); +field_offset!(PRIMITIVE_CLIP_ID, PrimitiveRecord, clip_id); +field_offset!(PRIMITIVE_SEMANTIC_ID, PrimitiveRecord, semantic_id); +field_offset!(PRIMITIVE_INLINE_START, PrimitiveRecord, inline_start); +field_offset!(PRIMITIVE_BLOCK_START, PrimitiveRecord, block_start); +field_offset!(PRIMITIVE_INLINE_EXTENT, PrimitiveRecord, inline_extent); +field_offset!(PRIMITIVE_BLOCK_EXTENT, PrimitiveRecord, block_extent); +field_offset!(DRAW_ID, DrawRecord, id); +field_offset!(DRAW_PROGRAM_ID, DrawRecord, program_id); +field_offset!(DRAW_VARIANT, DrawRecord, variant); +field_offset!(DRAW_FLAGS, DrawRecord, flags); +field_offset!(DRAW_PRIMITIVE_START, DrawRecord, primitive_start); +field_offset!(DRAW_PRIMITIVE_COUNT, DrawRecord, primitive_count); +field_offset!(DRAW_BUFFER_START, DrawRecord, buffer_start); +field_offset!(DRAW_BUFFER_COUNT, DrawRecord, buffer_count); +field_offset!(DRAW_RESOURCE_START, DrawRecord, resource_start); +field_offset!(DRAW_RESOURCE_COUNT, DrawRecord, resource_count); +field_offset!(DRAW_ORDER_TOKEN, DrawRecord, order_token); +field_offset!(DRAW_INDIRECT_BUFFER_ID, DrawRecord, indirect_buffer_id); +field_offset!(DRAW_INDIRECT_OFFSET, DrawRecord, indirect_offset); +field_offset!(RETIREMENT_KIND, RetirementRecord, kind); +field_offset!(RETIREMENT_FLAGS, RetirementRecord, flags); +field_offset!(RETIREMENT_ID, RetirementRecord, id); +field_offset!(RETIREMENT_GENERATION, RetirementRecord, generation); +field_offset!( + RETIREMENT_AFTER_PUBLICATION_GENERATION, + RetirementRecord, + after_publication_generation +); +field_offset!(RETIREMENT_BYTE_OFFSET, RetirementRecord, byte_offset); +field_offset!(RETIREMENT_BYTE_LENGTH, RetirementRecord, byte_length); +field_offset!(DIAGNOSTIC_CODE, DiagnosticRecord, code); +field_offset!(DIAGNOSTIC_SEVERITY, DiagnosticRecord, severity); +field_offset!(DIAGNOSTIC_PHASE, DiagnosticRecord, phase); +field_offset!(DIAGNOSTIC_SUBJECT_ID, DiagnosticRecord, subject_id); +field_offset!(DIAGNOSTIC_VALUE0, DiagnosticRecord, value0); +field_offset!(DIAGNOSTIC_VALUE1, DiagnosticRecord, value1); +field_offset!( + DIAGNOSTIC_DURATION_NANOS_LOW, + DiagnosticRecord, + duration_nanos_low +); +field_offset!( + DIAGNOSTIC_DURATION_NANOS_HIGH, + DiagnosticRecord, + duration_nanos_high +); field_offset!(FEATURE_TAG, FeatureRecord, tag); field_offset!(FEATURE_VALUE, FeatureRecord, value); field_offset!(FEATURE_START, FeatureRecord, start); @@ -879,6 +1047,10 @@ pub fn json() -> String { "requiredRequestCapacity": ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, "resultCapacity": ENGINE_RESULT_RESULT_CAPACITY, "requiredResultCapacity": ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, + "policyHandle": ENGINE_RESULT_POLICY_HANDLE, + "capabilitySet": ENGINE_RESULT_CAPABILITY_SET, + "policyFingerprintLow": ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + "policyFingerprintHigh": ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, "semanticsOffset": ENGINE_RESULT_SEMANTICS_OFFSET, "semanticsCount": ENGINE_RESULT_SEMANTICS_COUNT, "resourcesOffset": ENGINE_RESULT_RESOURCES_OFFSET, @@ -896,6 +1068,129 @@ pub fn json() -> String { "diagnosticsOffset": ENGINE_RESULT_DIAGNOSTICS_OFFSET, "diagnosticCount": ENGINE_RESULT_DIAGNOSTIC_COUNT }, + "engineSemantic": { + "size": SEMANTIC_RECORD_SIZE, + "alignment": SEMANTIC_RECORD_ALIGNMENT, + "id": SEMANTIC_ID, + "kind": SEMANTIC_KIND, + "flags": SEMANTIC_FLAGS, + "parentId": SEMANTIC_PARENT_ID, + "textStart": SEMANTIC_TEXT_START, + "textEnd": SEMANTIC_TEXT_END, + "itemStart": SEMANTIC_ITEM_START, + "itemCount": SEMANTIC_ITEM_COUNT, + "inlineStart": SEMANTIC_INLINE_START, + "blockStart": SEMANTIC_BLOCK_START, + "inlineExtent": SEMANTIC_INLINE_EXTENT, + "blockExtent": SEMANTIC_BLOCK_EXTENT + }, + "engineResource": { + "size": RESOURCE_RECORD_SIZE, + "alignment": RESOURCE_RECORD_ALIGNMENT, + "id": RESOURCE_ID, + "generation": RESOURCE_GENERATION, + "techniqueId": RESOURCE_TECHNIQUE_ID, + "resourceKind": RESOURCE_KIND, + "action": RESOURCE_ACTION, + "flags": RESOURCE_FLAGS, + "referenceId": RESOURCE_REFERENCE_ID, + "lowerBound": RESOURCE_LOWER_BOUND, + "upperBound": RESOURCE_UPPER_BOUND, + "auxiliary0": RESOURCE_AUXILIARY0, + "auxiliary1": RESOURCE_AUXILIARY1 + }, + "engineBuffer": { + "size": BUFFER_RECORD_SIZE, + "alignment": BUFFER_RECORD_ALIGNMENT, + "id": BUFFER_ID, + "generation": BUFFER_GENERATION, + "programId": BUFFER_PROGRAM_ID, + "policyBufferId": BUFFER_POLICY_BUFFER_ID, + "scalarType": BUFFER_SCALAR_TYPE, + "vectorWidth": BUFFER_VECTOR_WIDTH, + "strategy": BUFFER_STRATEGY, + "flags": BUFFER_FLAGS, + "liveRecords": BUFFER_LIVE_RECORDS, + "capacityRecords": BUFFER_CAPACITY_RECORDS, + "byteLength": BUFFER_BYTE_LENGTH, + "orderBufferId": BUFFER_ORDER_BUFFER_ID + }, + "enginePatch": { + "size": PATCH_RECORD_SIZE, + "alignment": PATCH_RECORD_ALIGNMENT, + "opcode": PATCH_OPCODE, + "flags": PATCH_FLAGS, + "bufferId": PATCH_BUFFER_ID, + "bufferGeneration": PATCH_BUFFER_GENERATION, + "destinationOffset": PATCH_DESTINATION_OFFSET, + "byteLength": PATCH_BYTE_LENGTH, + "payloadOffset": PATCH_PAYLOAD_OFFSET, + "sourceBufferId": PATCH_SOURCE_BUFFER_ID, + "sourceOffset": PATCH_SOURCE_OFFSET, + "fillValue": PATCH_FILL_VALUE + }, + "enginePrimitive": { + "size": PRIMITIVE_RECORD_SIZE, + "alignment": PRIMITIVE_RECORD_ALIGNMENT, + "id": PRIMITIVE_ID, + "kind": PRIMITIVE_KIND, + "flags": PRIMITIVE_FLAGS, + "techniqueId": PRIMITIVE_TECHNIQUE_ID, + "resourceId": PRIMITIVE_RESOURCE_ID, + "resourceGeneration": PRIMITIVE_RESOURCE_GENERATION, + "programId": PRIMITIVE_PROGRAM_ID, + "variant": PRIMITIVE_VARIANT, + "reserved": PRIMITIVE_RESERVED, + "bufferId": PRIMITIVE_BUFFER_ID, + "recordIndex": PRIMITIVE_RECORD_INDEX, + "logicalOrder": PRIMITIVE_LOGICAL_ORDER, + "clipId": PRIMITIVE_CLIP_ID, + "semanticId": PRIMITIVE_SEMANTIC_ID, + "inlineStart": PRIMITIVE_INLINE_START, + "blockStart": PRIMITIVE_BLOCK_START, + "inlineExtent": PRIMITIVE_INLINE_EXTENT, + "blockExtent": PRIMITIVE_BLOCK_EXTENT + }, + "engineDraw": { + "size": DRAW_RECORD_SIZE, + "alignment": DRAW_RECORD_ALIGNMENT, + "id": DRAW_ID, + "programId": DRAW_PROGRAM_ID, + "variant": DRAW_VARIANT, + "flags": DRAW_FLAGS, + "primitiveStart": DRAW_PRIMITIVE_START, + "primitiveCount": DRAW_PRIMITIVE_COUNT, + "bufferStart": DRAW_BUFFER_START, + "bufferCount": DRAW_BUFFER_COUNT, + "resourceStart": DRAW_RESOURCE_START, + "resourceCount": DRAW_RESOURCE_COUNT, + "orderToken": DRAW_ORDER_TOKEN, + "indirectBufferId": DRAW_INDIRECT_BUFFER_ID, + "indirectOffset": DRAW_INDIRECT_OFFSET + }, + "engineRetirement": { + "size": RETIREMENT_RECORD_SIZE, + "alignment": RETIREMENT_RECORD_ALIGNMENT, + "kind": RETIREMENT_KIND, + "flags": RETIREMENT_FLAGS, + "id": RETIREMENT_ID, + "generation": RETIREMENT_GENERATION, + "afterPublicationGeneration": RETIREMENT_AFTER_PUBLICATION_GENERATION, + "byteOffset": RETIREMENT_BYTE_OFFSET, + "byteLength": RETIREMENT_BYTE_LENGTH + }, + "engineDiagnostic": { + "size": DIAGNOSTIC_RECORD_SIZE, + "alignment": DIAGNOSTIC_RECORD_ALIGNMENT, + "code": DIAGNOSTIC_CODE, + "severity": DIAGNOSTIC_SEVERITY, + "phase": DIAGNOSTIC_PHASE, + "subjectId": DIAGNOSTIC_SUBJECT_ID, + "value0": DIAGNOSTIC_VALUE0, + "value1": DIAGNOSTIC_VALUE1, + "durationNanosLow": DIAGNOSTIC_DURATION_NANOS_LOW, + "durationNanosHigh": DIAGNOSTIC_DURATION_NANOS_HIGH + }, "feature": { "size": FEATURE_RECORD_SIZE, "alignment": FEATURE_RECORD_ALIGNMENT, @@ -995,6 +1290,44 @@ pub fn json() -> String { "engine": { "resultFlags": { "checkpoint": RESULT_FLAG_CHECKPOINT + }, + "semanticKinds": { + "line": SEMANTIC_LINE, + "fragment": SEMANTIC_FRAGMENT, + "run": SEMANTIC_RUN, + "cluster": SEMANTIC_CLUSTER, + "caret": SEMANTIC_CARET, + "selection": SEMANTIC_SELECTION, + "insertedGlyph": SEMANTIC_INSERTED_GLYPH + }, + "resourceActions": { + "create": RESOURCE_ACTION_CREATE, + "update": RESOURCE_ACTION_UPDATE, + "retain": RESOURCE_ACTION_RETAIN + }, + "bufferStrategies": { + "orderedDirect": BUFFER_ORDERED_DIRECT, + "stableIndirect": BUFFER_STABLE_INDIRECT + }, + "patchOpcodes": { + "allocateOrResize": PATCH_ALLOCATE_OR_RESIZE, + "write": PATCH_WRITE, + "fill": PATCH_FILL, + "copy": PATCH_COPY, + "retire": PATCH_RETIRE + }, + "primitiveKinds": { + "glyph": PRIMITIVE_GLYPH, + "decoration": PRIMITIVE_DECORATION, + "inlineObject": PRIMITIVE_INLINE_OBJECT, + "clip": PRIMITIVE_CLIP, + "policy": PRIMITIVE_POLICY + }, + "retirementKinds": { + "resource": RETIRE_RESOURCE, + "buffer": RETIRE_BUFFER, + "slotRange": RETIRE_SLOT_RANGE, + "outputBytes": RETIRE_OUTPUT_BYTES } }, "status": { diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index a28857ab..a5b01628 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -16,6 +16,8 @@ mod state; pub(crate) mod transport; pub mod policy; +pub mod render_plan; +pub(crate) mod render_plan_wire; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod wire; diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 962e681d..d828a5f2 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -165,6 +165,7 @@ pub struct PolicyDescriptor { pub struct ValidatedPolicy { programs: Vec, execution: Vec, + fingerprint: u64, } impl ValidatedPolicy { @@ -178,6 +179,7 @@ impl ValidatedPolicy { execution.push(ExecutableProgram::new(program)?); } Ok(Self { + fingerprint: policy_fingerprint(&descriptor), programs: descriptor.programs, execution, }) @@ -187,6 +189,10 @@ impl ValidatedPolicy { &self.programs } + pub fn fingerprint(&self) -> u64 { + self.fingerprint + } + pub fn program(&self, technique: TechniqueId, variant: u16) -> Option<&ProgramDescriptor> { self.programs .iter() @@ -218,6 +224,107 @@ impl ValidatedPolicy { } } +fn policy_fingerprint(descriptor: &PolicyDescriptor) -> u64 { + let mut fingerprint = 0xcbf2_9ce4_8422_2325_u64; + mix_u32(&mut fingerprint, descriptor.programs.len() as u32); + for program in &descriptor.programs { + mix_u32(&mut fingerprint, program.technique.0); + mix_u32(&mut fingerprint, program.id.0); + mix_u32(&mut fingerprint, u32::from(program.variant)); + mix_u32(&mut fingerprint, u32::from(program.f32_input_count)); + mix_u32(&mut fingerprint, u32::from(program.u32_input_count)); + mix_u32(&mut fingerprint, program.capabilities.paint); + mix_u32(&mut fingerprint, program.capabilities.compositing); + mix_u32(&mut fingerprint, program.buffers.len() as u32); + for buffer in &program.buffers { + mix_u32(&mut fingerprint, u32::from(buffer.id.0)); + mix_u32(&mut fingerprint, buffer.scalar as u32); + mix_u32(&mut fingerprint, u32::from(buffer.vector_width)); + } + mix_u32(&mut fingerprint, program.operations.len() as u32); + for operation in &program.operations { + fingerprint_operation(&mut fingerprint, operation); + } + } + fingerprint +} + +fn fingerprint_operation(fingerprint: &mut u64, operation: &Operation) { + let (opcode, target, operand0, operand1, immediate0, immediate1, immediate2) = match *operation + { + Operation::LoadF32 { target, field } => (OP_LOAD_F32, target, field, 0, 0, 0, 0), + Operation::LoadU32 { target, field } => (OP_LOAD_U32, target, field, 0, 0, 0, 0), + Operation::ConstantF32 { target, bits } => (OP_CONSTANT_F32, target, 0, 0, bits, 0, 0), + Operation::ConstantU32 { target, value } => (OP_CONSTANT_U32, target, 0, 0, value, 0, 0), + Operation::AddF32 { + target, + left, + right, + } => (OP_ADD_F32, target, left, right, 0, 0, 0), + Operation::SubtractF32 { + target, + left, + right, + } => (OP_SUBTRACT_F32, target, left, right, 0, 0, 0), + Operation::MultiplyF32 { + target, + left, + right, + } => (OP_MULTIPLY_F32, target, left, right, 0, 0, 0), + Operation::LessThanF32 { + target, + left, + right, + } => (OP_LESS_THAN_F32, target, left, right, 0, 0, 0), + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => ( + OP_SELECT_F32, + target, + condition, + when_true, + u32::from(when_false), + 0, + 0, + ), + Operation::ConvertU32ToF32 { target, source } => { + (OP_CONVERT_U32_TO_F32, target, source, 0, 0, 0, 0) + } + Operation::StoreF32 { + source, + buffer, + lane, + } => (OP_STORE_F32, 0, source, lane, u32::from(buffer.0), 0, 0), + Operation::StoreU32 { + source, + buffer, + lane, + } => (OP_STORE_U32, 0, source, lane, u32::from(buffer.0), 0, 0), + Operation::StoreU16 { + source, + buffer, + lane, + } => (OP_STORE_U16, 0, source, lane, u32::from(buffer.0), 0, 0), + }; + mix_u32( + fingerprint, + u32::from_le_bytes([opcode, target, operand0, operand1]), + ); + mix_u32(fingerprint, immediate0); + mix_u32(fingerprint, immediate1); + mix_u32(fingerprint, immediate2); +} + +fn mix_u32(fingerprint: &mut u64, value: u32) { + for byte in value.to_le_bytes() { + *fingerprint ^= u64::from(byte); + *fingerprint = fingerprint.wrapping_mul(0x0000_0100_0000_01b3); + } +} + #[derive(Clone, Debug, PartialEq, Eq)] struct ExecutableProgram { store_buffer_indices: Vec, @@ -926,6 +1033,26 @@ mod tests { assert_eq!(policy.program(BITMAP, 1), None); } + #[test] + fn fingerprints_exact_validated_policy_content() { + let first = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![valid_program()], + }) + .unwrap(); + let same = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![valid_program()], + }) + .unwrap(); + let mut changed_program = valid_program(); + changed_program.technique = TechniqueId(2); + let changed = ValidatedPolicy::new(PolicyDescriptor { + programs: vec![changed_program], + }) + .unwrap(); + assert_eq!(first.fingerprint(), same.fingerprint()); + assert_ne!(first.fingerprint(), changed.fingerprint()); + } + #[test] fn rejects_uninitialized_and_wrong_type_registers() { let mut uninitialized = valid_program(); diff --git a/packages/text/rust/shaper/src/engine/render_plan.rs b/packages/text/rust/shaper/src/engine/render_plan.rs new file mode 100644 index 00000000..61a96902 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/render_plan.rs @@ -0,0 +1,193 @@ +//! Renderer-neutral retained display-list records. +//! +//! These types describe rendering intent and revision-relative resource changes. They deliberately +//! contain no backend command, JavaScript callback, GPU object, or host pointer. + +pub const SEMANTIC_LINE: u16 = 1; +pub const SEMANTIC_FRAGMENT: u16 = 2; +pub const SEMANTIC_RUN: u16 = 3; +pub const SEMANTIC_CLUSTER: u16 = 4; +pub const SEMANTIC_CARET: u16 = 5; +pub const SEMANTIC_SELECTION: u16 = 6; +pub const SEMANTIC_INSERTED_GLYPH: u16 = 7; + +pub const RESOURCE_ACTION_CREATE: u16 = 1; +pub const RESOURCE_ACTION_UPDATE: u16 = 2; +pub const RESOURCE_ACTION_RETAIN: u16 = 3; + +pub const BUFFER_ORDERED_DIRECT: u16 = 1; +pub const BUFFER_STABLE_INDIRECT: u16 = 2; + +pub const PATCH_ALLOCATE_OR_RESIZE: u16 = 1; +pub const PATCH_WRITE: u16 = 2; +pub const PATCH_FILL: u16 = 3; +pub const PATCH_COPY: u16 = 4; +pub const PATCH_RETIRE: u16 = 5; + +pub const PRIMITIVE_GLYPH: u16 = 1; +pub const PRIMITIVE_DECORATION: u16 = 2; +pub const PRIMITIVE_INLINE_OBJECT: u16 = 3; +pub const PRIMITIVE_CLIP: u16 = 4; +pub const PRIMITIVE_POLICY: u16 = 5; + +pub const RETIRE_RESOURCE: u16 = 1; +pub const RETIRE_BUFFER: u16 = 2; +pub const RETIRE_SLOT_RANGE: u16 = 3; +pub const RETIRE_OUTPUT_BYTES: u16 = 4; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SemanticRecord { + pub id: u32, + pub kind: u16, + pub flags: u16, + pub parent_id: u32, + pub text_start: u32, + pub text_end: u32, + pub item_start: u32, + pub item_count: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ResourceRecord { + pub id: u32, + pub generation: u32, + pub technique_id: u32, + pub resource_kind: u16, + pub action: u16, + pub flags: u32, + pub reference_id: u32, + pub lower_bound: u32, + pub upper_bound: u32, + pub auxiliary0: u32, + pub auxiliary1: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BufferRecord { + pub id: u32, + pub generation: u32, + pub program_id: u32, + pub policy_buffer_id: u16, + pub scalar_type: u8, + pub vector_width: u8, + pub strategy: u16, + pub flags: u16, + pub live_records: u32, + pub capacity_records: u32, + pub byte_length: u32, + pub order_buffer_id: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PatchRecord { + pub opcode: u16, + pub flags: u16, + pub buffer_id: u32, + pub buffer_generation: u32, + pub destination_offset: u32, + pub byte_length: u32, + /// Offset into `RenderPlanView::payload`; serialization rebases it to the publication start. + pub payload_start: u32, + pub source_buffer_id: u32, + pub source_offset: u32, + pub fill_value: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PrimitiveRecord { + pub id: u32, + pub kind: u16, + pub flags: u16, + pub technique_id: u32, + pub resource_id: u32, + pub resource_generation: u32, + pub program_id: u32, + pub variant: u16, + pub reserved: u16, + pub buffer_id: u32, + pub record_index: u32, + pub logical_order: u32, + pub clip_id: u32, + pub semantic_id: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DrawRecord { + pub id: u32, + pub program_id: u32, + pub variant: u16, + pub flags: u16, + pub primitive_start: u32, + pub primitive_count: u32, + pub buffer_start: u32, + pub buffer_count: u32, + pub resource_start: u32, + pub resource_count: u32, + pub order_token: u32, + pub indirect_buffer_id: u32, + pub indirect_offset: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RetirementRecord { + pub kind: u16, + pub flags: u16, + pub id: u32, + pub generation: u32, + pub after_publication_generation: u32, + pub byte_offset: u32, + pub byte_length: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DiagnosticRecord { + pub code: u16, + pub severity: u8, + pub phase: u8, + pub subject_id: u32, + pub value0: u32, + pub value1: u32, + pub duration_nanos_low: u32, + pub duration_nanos_high: u32, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct RenderPlanView<'a> { + pub policy_handle: u32, + pub capability_set: u32, + pub policy_fingerprint: u64, + pub semantics: &'a [SemanticRecord], + pub resources: &'a [ResourceRecord], + pub buffers: &'a [BufferRecord], + pub patches: &'a [PatchRecord], + pub primitives: &'a [PrimitiveRecord], + pub draws: &'a [DrawRecord], + pub retirements: &'a [RetirementRecord], + pub diagnostics: &'a [DiagnosticRecord], + pub payload: &'a [u8], +} + +const _: () = assert!(core::mem::size_of::() == 44); +const _: () = assert!(core::mem::size_of::() == 40); +const _: () = assert!(core::mem::size_of::() == 36); +const _: () = assert!(core::mem::size_of::() == 36); +const _: () = assert!(core::mem::size_of::() == 64); +const _: () = assert!(core::mem::size_of::() == 48); +const _: () = assert!(core::mem::size_of::() == 24); +const _: () = assert!(core::mem::size_of::() == 24); diff --git a/packages/text/rust/shaper/src/engine/render_plan_wire.rs b/packages/text/rust/shaper/src/engine/render_plan_wire.rs new file mode 100644 index 00000000..244af920 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/render_plan_wire.rs @@ -0,0 +1,699 @@ +//! Little-endian serialization for immutable render-plan publications. + +use crate::{ + STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, + abi_contract::*, + engine::render_plan::{ + BufferRecord, DiagnosticRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_COPY, + PATCH_FILL, PATCH_RETIRE, PATCH_WRITE, PRIMITIVE_CLIP, PRIMITIVE_DECORATION, + PRIMITIVE_GLYPH, PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, PrimitiveRecord, + RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_BUFFER, + RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, + RetirementRecord, SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, + SEMANTIC_INSERTED_GLYPH, SEMANTIC_LINE, SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, + }, +}; + +const PAYLOAD_ALIGNMENT: u32 = 16; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct TableSpan { + pub offset: u32, + pub count: u32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct EncodedPlanLayout { + pub byte_length: u32, + pub payload_offset: u32, + pub semantics: TableSpan, + pub resources: TableSpan, + pub buffers: TableSpan, + pub patches: TableSpan, + pub primitives: TableSpan, + pub draws: TableSpan, + pub retirements: TableSpan, + pub diagnostics: TableSpan, +} + +pub(crate) fn encode_plan( + plan: RenderPlanView<'_>, + output: &mut [u8], +) -> Result { + let layout = plan_layout(plan)?; + let byte_length = usize::try_from(layout.byte_length).map_err(|_| STATUS_RESULT_TOO_LARGE)?; + let bytes = output + .get_mut(..byte_length) + .ok_or(STATUS_RESULT_TOO_LARGE)?; + bytes.fill(0); + if !plan.payload.is_empty() { + let start = usize::try_from(layout.payload_offset).map_err(|_| STATUS_RESULT_TOO_LARGE)?; + bytes[start..start + plan.payload.len()].copy_from_slice(plan.payload); + } + write_records( + bytes, + layout.semantics, + SEMANTIC_RECORD_SIZE, + plan.semantics, + write_semantic, + ); + write_records( + bytes, + layout.resources, + RESOURCE_RECORD_SIZE, + plan.resources, + write_resource, + ); + write_records( + bytes, + layout.buffers, + BUFFER_RECORD_SIZE, + plan.buffers, + write_buffer, + ); + write_patch_records(bytes, layout.patches, layout.payload_offset, plan.patches); + write_records( + bytes, + layout.primitives, + PRIMITIVE_RECORD_SIZE, + plan.primitives, + write_primitive, + ); + write_records( + bytes, + layout.draws, + DRAW_RECORD_SIZE, + plan.draws, + write_draw, + ); + write_records( + bytes, + layout.retirements, + RETIREMENT_RECORD_SIZE, + plan.retirements, + write_retirement, + ); + write_records( + bytes, + layout.diagnostics, + DIAGNOSTIC_RECORD_SIZE, + plan.diagnostics, + write_diagnostic, + ); + Ok(layout) +} + +pub(crate) fn plan_layout(plan: RenderPlanView<'_>) -> Result { + validate_plan(plan)?; + let mut cursor = ENGINE_RESULT_HEADER_SIZE; + let payload_offset = if plan.payload.is_empty() { + 0 + } else { + cursor = align(cursor, PAYLOAD_ALIGNMENT)?; + let offset = cursor; + cursor = add_bytes(cursor, plan.payload.len(), 1)?; + offset + }; + let semantics = add_table( + &mut cursor, + plan.semantics.len(), + SEMANTIC_RECORD_SIZE, + SEMANTIC_RECORD_ALIGNMENT, + )?; + let resources = add_table( + &mut cursor, + plan.resources.len(), + RESOURCE_RECORD_SIZE, + RESOURCE_RECORD_ALIGNMENT, + )?; + let buffers = add_table( + &mut cursor, + plan.buffers.len(), + BUFFER_RECORD_SIZE, + BUFFER_RECORD_ALIGNMENT, + )?; + let patches = add_table( + &mut cursor, + plan.patches.len(), + PATCH_RECORD_SIZE, + PATCH_RECORD_ALIGNMENT, + )?; + let primitives = add_table( + &mut cursor, + plan.primitives.len(), + PRIMITIVE_RECORD_SIZE, + PRIMITIVE_RECORD_ALIGNMENT, + )?; + let draws = add_table( + &mut cursor, + plan.draws.len(), + DRAW_RECORD_SIZE, + DRAW_RECORD_ALIGNMENT, + )?; + let retirements = add_table( + &mut cursor, + plan.retirements.len(), + RETIREMENT_RECORD_SIZE, + RETIREMENT_RECORD_ALIGNMENT, + )?; + let diagnostics = add_table( + &mut cursor, + plan.diagnostics.len(), + DIAGNOSTIC_RECORD_SIZE, + DIAGNOSTIC_RECORD_ALIGNMENT, + )?; + Ok(EncodedPlanLayout { + byte_length: cursor, + payload_offset, + semantics, + resources, + buffers, + patches, + primitives, + draws, + retirements, + diagnostics, + }) +} + +fn validate_plan(plan: RenderPlanView<'_>) -> Result<(), u32> { + if plan.policy_handle == 0 { + return Err(STATUS_INVALID_REQUEST); + } + for record in plan.semantics { + if record.id == 0 + || !matches!( + record.kind, + SEMANTIC_LINE + | SEMANTIC_FRAGMENT + | SEMANTIC_RUN + | SEMANTIC_CLUSTER + | SEMANTIC_CARET + | SEMANTIC_SELECTION + | SEMANTIC_INSERTED_GLYPH + ) + || record.text_start > record.text_end + || !finite4( + record.inline_start, + record.block_start, + record.inline_extent, + record.block_extent, + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.resources { + if record.id == 0 + || record.generation == 0 + || record.technique_id == 0 + || record.resource_kind == 0 + || !matches!( + record.action, + RESOURCE_ACTION_CREATE | RESOURCE_ACTION_UPDATE | RESOURCE_ACTION_RETAIN + ) + || record.lower_bound > record.upper_bound + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.buffers { + if record.id == 0 + || record.generation == 0 + || record.program_id == 0 + || record.policy_buffer_id == 0 + || !matches!(record.scalar_type, 1..=3) + || !matches!(record.vector_width, 1..=4) + || !matches!(record.strategy, 1..=2) + || record.live_records > record.capacity_records + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.patches { + if record.buffer_id == 0 + || record.buffer_generation == 0 + || !matches!( + record.opcode, + PATCH_ALLOCATE_OR_RESIZE | PATCH_WRITE | PATCH_FILL | PATCH_COPY | PATCH_RETIRE + ) + { + return Err(STATUS_INVALID_REQUEST); + } + let has_payload = record.opcode == PATCH_WRITE; + if has_payload { + let start = + usize::try_from(record.payload_start).map_err(|_| STATUS_INVALID_REQUEST)?; + let length = usize::try_from(record.byte_length).map_err(|_| STATUS_INVALID_REQUEST)?; + if start + .checked_add(length) + .filter(|end| *end <= plan.payload.len()) + .is_none() + { + return Err(STATUS_INVALID_REQUEST); + } + } else if record.payload_start != 0 { + return Err(STATUS_INVALID_REQUEST); + } + if record.opcode == PATCH_COPY && record.source_buffer_id == 0 { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.primitives { + if record.id == 0 + || !matches!( + record.kind, + PRIMITIVE_GLYPH + | PRIMITIVE_DECORATION + | PRIMITIVE_INLINE_OBJECT + | PRIMITIVE_CLIP + | PRIMITIVE_POLICY + ) + || record.reserved != 0 + || !finite4( + record.inline_start, + record.block_start, + record.inline_extent, + record.block_extent, + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.draws { + if record.id == 0 + || record.program_id == 0 + || !range_in( + record.primitive_start, + record.primitive_count, + plan.primitives.len(), + ) + || !range_in(record.buffer_start, record.buffer_count, plan.buffers.len()) + || !range_in( + record.resource_start, + record.resource_count, + plan.resources.len(), + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in plan.retirements { + if record.id == 0 + || record.generation == 0 + || !matches!( + record.kind, + RETIRE_RESOURCE | RETIRE_BUFFER | RETIRE_SLOT_RANGE | RETIRE_OUTPUT_BYTES + ) + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn add_table( + cursor: &mut u32, + count: usize, + stride: u32, + alignment: u32, +) -> Result { + if count == 0 { + return Ok(TableSpan::default()); + } + *cursor = align(*cursor, alignment)?; + let offset = *cursor; + *cursor = add_bytes(*cursor, count, stride)?; + Ok(TableSpan { + offset, + count: u32::try_from(count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, + }) +} + +fn add_bytes(cursor: u32, count: usize, stride: u32) -> Result { + let count = u32::try_from(count).map_err(|_| STATUS_RESULT_TOO_LARGE)?; + count + .checked_mul(stride) + .and_then(|length| cursor.checked_add(length)) + .ok_or(STATUS_RESULT_TOO_LARGE) +} + +fn align(value: u32, alignment: u32) -> Result { + debug_assert!(alignment.is_power_of_two()); + value + .checked_add(alignment - 1) + .map(|aligned| aligned & !(alignment - 1)) + .ok_or(STATUS_RESULT_TOO_LARGE) +} + +fn range_in(start: u32, count: u32, length: usize) -> bool { + if count == 0 { + return start == 0; + } + start + .checked_add(count) + .and_then(|end| usize::try_from(end).ok()) + .is_some_and(|end| end <= length) +} + +fn finite4(first: f32, second: f32, third: f32, fourth: f32) -> bool { + first.is_finite() && second.is_finite() && third.is_finite() && fourth.is_finite() +} + +fn write_records( + bytes: &mut [u8], + span: TableSpan, + stride: u32, + records: &[Record], + write: fn(&mut [u8], usize, Record), +) { + let start = span.offset as usize; + let stride = stride as usize; + for (index, record) in records.iter().copied().enumerate() { + write(bytes, start + index * stride, record); + } +} + +fn write_patch_records( + bytes: &mut [u8], + span: TableSpan, + payload_offset: u32, + records: &[PatchRecord], +) { + let start = span.offset as usize; + for (index, record) in records.iter().copied().enumerate() { + write_patch( + bytes, + start + index * PATCH_RECORD_SIZE as usize, + record, + payload_offset, + ); + } +} + +fn write_semantic(bytes: &mut [u8], at: usize, value: SemanticRecord) { + u32_at(bytes, at, SEMANTIC_ID, value.id); + u16_at(bytes, at, SEMANTIC_KIND, value.kind); + u16_at(bytes, at, SEMANTIC_FLAGS, value.flags); + u32_at(bytes, at, SEMANTIC_PARENT_ID, value.parent_id); + u32_at(bytes, at, SEMANTIC_TEXT_START, value.text_start); + u32_at(bytes, at, SEMANTIC_TEXT_END, value.text_end); + u32_at(bytes, at, SEMANTIC_ITEM_START, value.item_start); + u32_at(bytes, at, SEMANTIC_ITEM_COUNT, value.item_count); + f32_at(bytes, at, SEMANTIC_INLINE_START, value.inline_start); + f32_at(bytes, at, SEMANTIC_BLOCK_START, value.block_start); + f32_at(bytes, at, SEMANTIC_INLINE_EXTENT, value.inline_extent); + f32_at(bytes, at, SEMANTIC_BLOCK_EXTENT, value.block_extent); +} + +fn write_resource(bytes: &mut [u8], at: usize, value: ResourceRecord) { + u32_at(bytes, at, RESOURCE_ID, value.id); + u32_at(bytes, at, RESOURCE_GENERATION, value.generation); + u32_at(bytes, at, RESOURCE_TECHNIQUE_ID, value.technique_id); + u16_at(bytes, at, RESOURCE_KIND, value.resource_kind); + u16_at(bytes, at, RESOURCE_ACTION, value.action); + u32_at(bytes, at, RESOURCE_FLAGS, value.flags); + u32_at(bytes, at, RESOURCE_REFERENCE_ID, value.reference_id); + u32_at(bytes, at, RESOURCE_LOWER_BOUND, value.lower_bound); + u32_at(bytes, at, RESOURCE_UPPER_BOUND, value.upper_bound); + u32_at(bytes, at, RESOURCE_AUXILIARY0, value.auxiliary0); + u32_at(bytes, at, RESOURCE_AUXILIARY1, value.auxiliary1); +} + +fn write_buffer(bytes: &mut [u8], at: usize, value: BufferRecord) { + u32_at(bytes, at, BUFFER_ID, value.id); + u32_at(bytes, at, BUFFER_GENERATION, value.generation); + u32_at(bytes, at, BUFFER_PROGRAM_ID, value.program_id); + u16_at(bytes, at, BUFFER_POLICY_BUFFER_ID, value.policy_buffer_id); + u8_at(bytes, at, BUFFER_SCALAR_TYPE, value.scalar_type); + u8_at(bytes, at, BUFFER_VECTOR_WIDTH, value.vector_width); + u16_at(bytes, at, BUFFER_STRATEGY, value.strategy); + u16_at(bytes, at, BUFFER_FLAGS, value.flags); + u32_at(bytes, at, BUFFER_LIVE_RECORDS, value.live_records); + u32_at(bytes, at, BUFFER_CAPACITY_RECORDS, value.capacity_records); + u32_at(bytes, at, BUFFER_BYTE_LENGTH, value.byte_length); + u32_at(bytes, at, BUFFER_ORDER_BUFFER_ID, value.order_buffer_id); +} + +fn write_patch(bytes: &mut [u8], at: usize, value: PatchRecord, payload_offset: u32) { + u16_at(bytes, at, PATCH_OPCODE, value.opcode); + u16_at(bytes, at, PATCH_FLAGS, value.flags); + u32_at(bytes, at, PATCH_BUFFER_ID, value.buffer_id); + u32_at(bytes, at, PATCH_BUFFER_GENERATION, value.buffer_generation); + u32_at( + bytes, + at, + PATCH_DESTINATION_OFFSET, + value.destination_offset, + ); + u32_at(bytes, at, PATCH_BYTE_LENGTH, value.byte_length); + let rebased = if value.opcode == PATCH_WRITE { + payload_offset + value.payload_start + } else { + 0 + }; + u32_at(bytes, at, PATCH_PAYLOAD_OFFSET, rebased); + u32_at(bytes, at, PATCH_SOURCE_BUFFER_ID, value.source_buffer_id); + u32_at(bytes, at, PATCH_SOURCE_OFFSET, value.source_offset); + u32_at(bytes, at, PATCH_FILL_VALUE, value.fill_value); +} + +fn write_primitive(bytes: &mut [u8], at: usize, value: PrimitiveRecord) { + u32_at(bytes, at, PRIMITIVE_ID, value.id); + u16_at(bytes, at, PRIMITIVE_KIND, value.kind); + u16_at(bytes, at, PRIMITIVE_FLAGS, value.flags); + u32_at(bytes, at, PRIMITIVE_TECHNIQUE_ID, value.technique_id); + u32_at(bytes, at, PRIMITIVE_RESOURCE_ID, value.resource_id); + u32_at( + bytes, + at, + PRIMITIVE_RESOURCE_GENERATION, + value.resource_generation, + ); + u32_at(bytes, at, PRIMITIVE_PROGRAM_ID, value.program_id); + u16_at(bytes, at, PRIMITIVE_VARIANT, value.variant); + u16_at(bytes, at, PRIMITIVE_RESERVED, value.reserved); + u32_at(bytes, at, PRIMITIVE_BUFFER_ID, value.buffer_id); + u32_at(bytes, at, PRIMITIVE_RECORD_INDEX, value.record_index); + u32_at(bytes, at, PRIMITIVE_LOGICAL_ORDER, value.logical_order); + u32_at(bytes, at, PRIMITIVE_CLIP_ID, value.clip_id); + u32_at(bytes, at, PRIMITIVE_SEMANTIC_ID, value.semantic_id); + f32_at(bytes, at, PRIMITIVE_INLINE_START, value.inline_start); + f32_at(bytes, at, PRIMITIVE_BLOCK_START, value.block_start); + f32_at(bytes, at, PRIMITIVE_INLINE_EXTENT, value.inline_extent); + f32_at(bytes, at, PRIMITIVE_BLOCK_EXTENT, value.block_extent); +} + +fn write_draw(bytes: &mut [u8], at: usize, value: DrawRecord) { + u32_at(bytes, at, DRAW_ID, value.id); + u32_at(bytes, at, DRAW_PROGRAM_ID, value.program_id); + u16_at(bytes, at, DRAW_VARIANT, value.variant); + u16_at(bytes, at, DRAW_FLAGS, value.flags); + u32_at(bytes, at, DRAW_PRIMITIVE_START, value.primitive_start); + u32_at(bytes, at, DRAW_PRIMITIVE_COUNT, value.primitive_count); + u32_at(bytes, at, DRAW_BUFFER_START, value.buffer_start); + u32_at(bytes, at, DRAW_BUFFER_COUNT, value.buffer_count); + u32_at(bytes, at, DRAW_RESOURCE_START, value.resource_start); + u32_at(bytes, at, DRAW_RESOURCE_COUNT, value.resource_count); + u32_at(bytes, at, DRAW_ORDER_TOKEN, value.order_token); + u32_at(bytes, at, DRAW_INDIRECT_BUFFER_ID, value.indirect_buffer_id); + u32_at(bytes, at, DRAW_INDIRECT_OFFSET, value.indirect_offset); +} + +fn write_retirement(bytes: &mut [u8], at: usize, value: RetirementRecord) { + u16_at(bytes, at, RETIREMENT_KIND, value.kind); + u16_at(bytes, at, RETIREMENT_FLAGS, value.flags); + u32_at(bytes, at, RETIREMENT_ID, value.id); + u32_at(bytes, at, RETIREMENT_GENERATION, value.generation); + u32_at( + bytes, + at, + RETIREMENT_AFTER_PUBLICATION_GENERATION, + value.after_publication_generation, + ); + u32_at(bytes, at, RETIREMENT_BYTE_OFFSET, value.byte_offset); + u32_at(bytes, at, RETIREMENT_BYTE_LENGTH, value.byte_length); +} + +fn write_diagnostic(bytes: &mut [u8], at: usize, value: DiagnosticRecord) { + u16_at(bytes, at, DIAGNOSTIC_CODE, value.code); + u8_at(bytes, at, DIAGNOSTIC_SEVERITY, value.severity); + u8_at(bytes, at, DIAGNOSTIC_PHASE, value.phase); + u32_at(bytes, at, DIAGNOSTIC_SUBJECT_ID, value.subject_id); + u32_at(bytes, at, DIAGNOSTIC_VALUE0, value.value0); + u32_at(bytes, at, DIAGNOSTIC_VALUE1, value.value1); + u32_at( + bytes, + at, + DIAGNOSTIC_DURATION_NANOS_LOW, + value.duration_nanos_low, + ); + u32_at( + bytes, + at, + DIAGNOSTIC_DURATION_NANOS_HIGH, + value.duration_nanos_high, + ); +} + +fn u8_at(bytes: &mut [u8], record: usize, field: usize, value: u8) { + bytes[record + field] = value; +} + +fn u16_at(bytes: &mut [u8], record: usize, field: usize, value: u16) { + bytes[record + field..record + field + 2].copy_from_slice(&value.to_le_bytes()); +} + +fn u32_at(bytes: &mut [u8], record: usize, field: usize, value: u32) { + bytes[record + field..record + field + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn f32_at(bytes: &mut [u8], record: usize, field: usize, value: f32) { + u32_at(bytes, record, field, value.to_bits()); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{engine::render_plan::*, wire::read_u32}; + use alloc::vec; + + #[test] + fn serializes_every_table_with_compiler_offsets_and_rebased_payloads() { + let semantic = [SemanticRecord { + id: 1, + kind: SEMANTIC_LINE, + text_end: 4, + item_count: 2, + inline_start: 1.5, + block_start: 2.5, + inline_extent: 80.0, + block_extent: 12.0, + ..SemanticRecord::default() + }]; + let resource = [ResourceRecord { + id: 2, + generation: 3, + technique_id: 4, + resource_kind: 1, + action: RESOURCE_ACTION_CREATE, + reference_id: 5, + upper_bound: 9, + ..ResourceRecord::default() + }]; + let buffer = [BufferRecord { + id: 6, + generation: 7, + program_id: 8, + policy_buffer_id: 9, + scalar_type: 1, + vector_width: 4, + strategy: BUFFER_ORDERED_DIRECT, + live_records: 2, + capacity_records: 4, + byte_length: 64, + ..BufferRecord::default() + }]; + let patch = [PatchRecord { + opcode: PATCH_WRITE, + buffer_id: 6, + buffer_generation: 7, + destination_offset: 16, + byte_length: 4, + payload_start: 1, + ..PatchRecord::default() + }]; + let primitive = [PrimitiveRecord { + id: 10, + kind: PRIMITIVE_GLYPH, + technique_id: 4, + resource_id: 2, + resource_generation: 3, + program_id: 8, + buffer_id: 6, + semantic_id: 1, + inline_extent: 8.0, + block_extent: 12.0, + ..PrimitiveRecord::default() + }]; + let draw = [DrawRecord { + id: 11, + program_id: 8, + primitive_count: 1, + buffer_count: 1, + resource_count: 1, + ..DrawRecord::default() + }]; + let retirement = [RetirementRecord { + kind: RETIRE_BUFFER, + id: 12, + generation: 2, + after_publication_generation: 4, + ..RetirementRecord::default() + }]; + let diagnostic = [DiagnosticRecord { + code: 13, + severity: 2, + phase: 3, + value0: 14, + ..DiagnosticRecord::default() + }]; + let plan = RenderPlanView { + policy_handle: 15, + capability_set: 16, + policy_fingerprint: 17, + semantics: &semantic, + resources: &resource, + buffers: &buffer, + patches: &patch, + primitives: &primitive, + draws: &draw, + retirements: &retirement, + diagnostics: &diagnostic, + payload: &[0xaa, 0xbb, 0xcc, 0xdd, 0xee], + }; + let expected = plan_layout(plan).unwrap(); + let mut bytes = vec![0x7f; expected.byte_length as usize + 16]; + let layout = encode_plan(plan, &mut bytes).unwrap(); + assert_eq!(layout, expected); + assert_eq!(layout.payload_offset % PAYLOAD_ALIGNMENT, 0); + assert_eq!( + read_u32( + &bytes, + layout.patches.offset as usize + PATCH_PAYLOAD_OFFSET + ) + .unwrap(), + layout.payload_offset + 1 + ); + assert_eq!( + &bytes[layout.payload_offset as usize..layout.payload_offset as usize + 5], + plan.payload + ); + assert_eq!( + read_u32(&bytes, layout.primitives.offset as usize + PRIMITIVE_ID).unwrap(), + 10 + ); + assert_eq!(bytes[layout.byte_length as usize..], [0x7f; 16]); + } + + #[test] + fn validation_fails_before_touching_destination_bytes() { + let patch = [PatchRecord { + opcode: PATCH_WRITE, + buffer_id: 1, + buffer_generation: 1, + byte_length: 4, + payload_start: 2, + ..PatchRecord::default() + }]; + let plan = RenderPlanView { + policy_handle: 1, + patches: &patch, + payload: &[1, 2, 3], + ..RenderPlanView::default() + }; + let mut bytes = [0xa5; 512]; + assert_eq!(encode_plan(plan, &mut bytes), Err(STATUS_INVALID_REQUEST)); + assert_eq!(bytes, [0xa5; 512]); + } +} diff --git a/packages/text/rust/shaper/src/engine/transport.rs b/packages/text/rust/shaper/src/engine/transport.rs index c2096076..61116a1f 100644 --- a/packages/text/rust/shaper/src/engine/transport.rs +++ b/packages/text/rust/shaper/src/engine/transport.rs @@ -5,11 +5,13 @@ use crate::{ STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, abi_contract::{ ABI_VERSION, ENGINE_RESULT_ABI_VERSION, ENGINE_RESULT_BUFFER_COUNT, - ENGINE_RESULT_BUFFERS_OFFSET, ENGINE_RESULT_BYTE_LENGTH, ENGINE_RESULT_DIAGNOSTIC_COUNT, - ENGINE_RESULT_DIAGNOSTICS_OFFSET, ENGINE_RESULT_DRAW_COUNT, ENGINE_RESULT_DRAWS_OFFSET, - ENGINE_RESULT_ENGINE_REVISION, ENGINE_RESULT_FLAGS, ENGINE_RESULT_HEADER_ALIGNMENT, - ENGINE_RESULT_HEADER_SIZE, ENGINE_RESULT_OUTPUT_SLOT, ENGINE_RESULT_PATCH_COUNT, - ENGINE_RESULT_PATCHES_OFFSET, ENGINE_RESULT_PLAN_REVISION, ENGINE_RESULT_PRIMITIVE_COUNT, + ENGINE_RESULT_BUFFERS_OFFSET, ENGINE_RESULT_BYTE_LENGTH, ENGINE_RESULT_CAPABILITY_SET, + ENGINE_RESULT_DIAGNOSTIC_COUNT, ENGINE_RESULT_DIAGNOSTICS_OFFSET, ENGINE_RESULT_DRAW_COUNT, + ENGINE_RESULT_DRAWS_OFFSET, ENGINE_RESULT_ENGINE_REVISION, ENGINE_RESULT_FLAGS, + ENGINE_RESULT_HEADER_ALIGNMENT, ENGINE_RESULT_HEADER_SIZE, ENGINE_RESULT_OUTPUT_SLOT, + ENGINE_RESULT_PATCH_COUNT, ENGINE_RESULT_PATCHES_OFFSET, ENGINE_RESULT_PLAN_REVISION, + ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + ENGINE_RESULT_POLICY_HANDLE, ENGINE_RESULT_PRIMITIVE_COUNT, ENGINE_RESULT_PRIMITIVES_OFFSET, ENGINE_RESULT_PUBLICATION_GENERATION, ENGINE_RESULT_REQUEST_CAPACITY, ENGINE_RESULT_REQUIRED_BASE_REVISION, ENGINE_RESULT_REQUIRED_REQUEST_CAPACITY, ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, @@ -19,7 +21,11 @@ use crate::{ ENGINE_RESULT_SEMANTICS_OFFSET, ENGINE_RESULT_SESSION_ID, ENGINE_RESULT_STATUS, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, - engine::frame::{CommittedUpdate, RESULT_FLAG_CHECKPOINT, SessionRevision}, + engine::{ + frame::{CommittedUpdate, RESULT_FLAG_CHECKPOINT, SessionRevision}, + render_plan::RenderPlanView, + render_plan_wire::{EncodedPlanLayout, encode_plan}, + }, wire::write_u32, }; @@ -102,11 +108,23 @@ impl FrameTransport { .ok_or(STATUS_RESULT_TOO_LARGE) } - pub fn publish_success(&mut self, commit: CommittedUpdate) -> usize { + pub fn stage_plan(&mut self, plan: RenderPlanView<'_>) -> Result { let slot = self.inactive_slot(); + let layout = encode_plan(plan, self.outputs[slot].bytes_mut())?; + Ok(StagedPlan { + slot, + policy_handle: plan.policy_handle, + capability_set: plan.capability_set, + policy_fingerprint: plan.policy_fingerprint, + layout, + }) + } + + pub fn publish_success(&mut self, commit: CommittedUpdate, staged: StagedPlan) -> usize { + debug_assert_eq!(staged.slot, self.inactive_slot()); let generation = self.publication_generation + 1; self.write_header( - slot, + staged.slot, HeaderValues { status: 0, flags: if commit.checkpoint { @@ -120,11 +138,15 @@ impl FrameTransport { publication_generation: generation, required_request_capacity: 0, required_result_capacity: 0, + policy_handle: staged.policy_handle, + capability_set: staged.capability_set, + policy_fingerprint: staged.policy_fingerprint, + layout: staged.layout, }, ); - self.active_slot = Some(slot); + self.active_slot = Some(staged.slot); self.publication_generation = generation; - self.outputs[slot].pointer() + self.outputs[staged.slot].pointer() } pub fn publish_failure( @@ -147,6 +169,13 @@ impl FrameTransport { publication_generation: self.publication_generation, required_request_capacity, required_result_capacity, + policy_handle: 0, + capability_set: 0, + policy_fingerprint: 0, + layout: EncodedPlanLayout { + byte_length: ENGINE_RESULT_HEADER_SIZE, + ..EncodedPlanLayout::default() + }, }, ); self.outputs[slot].pointer() @@ -162,7 +191,7 @@ impl FrameTransport { let bytes = self.outputs[slot].bytes_mut(); bytes[..ENGINE_RESULT_HEADER_SIZE as usize].fill(0); write_u32(bytes, ENGINE_RESULT_ABI_VERSION, ABI_VERSION); - write_u32(bytes, ENGINE_RESULT_BYTE_LENGTH, ENGINE_RESULT_HEADER_SIZE); + write_u32(bytes, ENGINE_RESULT_BYTE_LENGTH, values.layout.byte_length); write_u32(bytes, ENGINE_RESULT_STATUS, values.status); write_u32(bytes, ENGINE_RESULT_FLAGS, values.flags); write_u32(bytes, ENGINE_RESULT_SESSION_ID, values.session_id); @@ -191,26 +220,66 @@ impl FrameTransport { ENGINE_RESULT_REQUIRED_RESULT_CAPACITY, values.required_result_capacity, ); - for offset in [ + write_u32(bytes, ENGINE_RESULT_POLICY_HANDLE, values.policy_handle); + write_u32(bytes, ENGINE_RESULT_CAPABILITY_SET, values.capability_set); + write_u32( + bytes, + ENGINE_RESULT_POLICY_FINGERPRINT_LOW, + values.policy_fingerprint as u32, + ); + write_u32( + bytes, + ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, + (values.policy_fingerprint >> 32) as u32, + ); + write_span( + bytes, ENGINE_RESULT_SEMANTICS_OFFSET, ENGINE_RESULT_SEMANTICS_COUNT, + values.layout.semantics, + ); + write_span( + bytes, ENGINE_RESULT_RESOURCES_OFFSET, ENGINE_RESULT_RESOURCE_COUNT, + values.layout.resources, + ); + write_span( + bytes, ENGINE_RESULT_BUFFERS_OFFSET, ENGINE_RESULT_BUFFER_COUNT, + values.layout.buffers, + ); + write_span( + bytes, ENGINE_RESULT_PATCHES_OFFSET, ENGINE_RESULT_PATCH_COUNT, + values.layout.patches, + ); + write_span( + bytes, ENGINE_RESULT_PRIMITIVES_OFFSET, ENGINE_RESULT_PRIMITIVE_COUNT, + values.layout.primitives, + ); + write_span( + bytes, ENGINE_RESULT_DRAWS_OFFSET, ENGINE_RESULT_DRAW_COUNT, + values.layout.draws, + ); + write_span( + bytes, ENGINE_RESULT_RETIREMENTS_OFFSET, ENGINE_RESULT_RETIREMENT_COUNT, + values.layout.retirements, + ); + write_span( + bytes, ENGINE_RESULT_DIAGNOSTICS_OFFSET, ENGINE_RESULT_DIAGNOSTIC_COUNT, - ] { - write_u32(bytes, offset, 0); - } + values.layout.diagnostics, + ); } } @@ -223,6 +292,28 @@ struct HeaderValues { publication_generation: u32, required_request_capacity: u32, required_result_capacity: u32, + policy_handle: u32, + capability_set: u32, + policy_fingerprint: u64, + layout: EncodedPlanLayout, +} + +pub(crate) struct StagedPlan { + slot: usize, + policy_handle: u32, + capability_set: u32, + policy_fingerprint: u64, + layout: EncodedPlanLayout, +} + +fn write_span( + bytes: &mut [u8], + offset_field: usize, + count_field: usize, + span: super::render_plan_wire::TableSpan, +) { + write_u32(bytes, offset_field, span.offset); + write_u32(bytes, count_field, span.count); } struct AlignedArena { @@ -307,6 +398,10 @@ const _: () = assert!(ENGINE_RESULT_HEADER_ALIGNMENT as usize == ARENA_ALIGNMENT mod tests { use super::*; use crate::wire::read_u32; + use crate::{ + abi_contract::{ENGINE_RESULT_POLICY_HANDLE, PATCH_PAYLOAD_OFFSET}, + engine::render_plan::{BUFFER_ORDERED_DIRECT, BufferRecord, PATCH_WRITE, PatchRecord}, + }; #[test] fn arenas_are_aligned_and_double_without_losing_request_bytes() { @@ -326,7 +421,8 @@ mod tests { #[test] fn successful_publications_alternate_and_failures_preserve_the_active_slot() { let mut transport = FrameTransport::new(256, 256).unwrap(); - let first = transport.publish_success(commit(1)); + let first_plan = transport.stage_plan(plan()).unwrap(); + let first = transport.publish_success(commit(1), first_plan); let first_bytes = transport.outputs[0].bytes(); assert_eq!(read_u32(first_bytes, ENGINE_RESULT_OUTPUT_SLOT).unwrap(), 0); assert_eq!( @@ -345,7 +441,8 @@ mod tests { assert_eq!(transport.active_slot, Some(0)); assert_eq!(transport.publication_generation, 1); - let second = transport.publish_success(commit(2)); + let second_plan = transport.stage_plan(plan()).unwrap(); + let second = transport.publish_success(commit(2), second_plan); assert_eq!(second, failure); let second_bytes = transport.outputs[1].bytes(); assert_eq!( @@ -358,6 +455,49 @@ mod tests { ); } + #[test] + fn publication_header_addresses_the_exact_plan_tables_and_payload() { + let buffers = [BufferRecord { + id: 1, + generation: 2, + program_id: 3, + policy_buffer_id: 4, + scalar_type: 1, + vector_width: 4, + strategy: BUFFER_ORDERED_DIRECT, + live_records: 1, + capacity_records: 8, + byte_length: 128, + ..BufferRecord::default() + }]; + let patches = [PatchRecord { + opcode: PATCH_WRITE, + buffer_id: 1, + buffer_generation: 2, + byte_length: 4, + ..PatchRecord::default() + }]; + let plan = RenderPlanView { + policy_handle: 9, + capability_set: 10, + policy_fingerprint: 0x1122_3344_5566_7788, + buffers: &buffers, + patches: &patches, + payload: &[1, 2, 3, 4], + ..RenderPlanView::default() + }; + let mut transport = FrameTransport::new(256, 1024).unwrap(); + let staged = transport.stage_plan(plan).unwrap(); + transport.publish_success(commit(1), staged); + let bytes = transport.outputs[0].bytes(); + let patch_offset = read_u32(bytes, ENGINE_RESULT_PATCHES_OFFSET).unwrap() as usize; + let payload_offset = read_u32(bytes, patch_offset + PATCH_PAYLOAD_OFFSET).unwrap() as usize; + assert_eq!(read_u32(bytes, ENGINE_RESULT_POLICY_HANDLE).unwrap(), 9); + assert_eq!(read_u32(bytes, ENGINE_RESULT_BUFFER_COUNT).unwrap(), 1); + assert_eq!(read_u32(bytes, ENGINE_RESULT_PATCH_COUNT).unwrap(), 1); + assert_eq!(&bytes[payload_offset..payload_offset + 4], &[1, 2, 3, 4]); + } + fn commit(revision: u32) -> CommittedUpdate { CommittedUpdate { session_id: 3, @@ -369,4 +509,11 @@ mod tests { checkpoint: revision == 1, } } + + fn plan() -> RenderPlanView<'static> { + RenderPlanView { + policy_handle: 1, + ..RenderPlanView::default() + } + } } diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index ee9fff6f..9900643e 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -4,12 +4,11 @@ use core::sync::atomic::{AtomicUsize, Ordering}; use crate::{ STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, STATUS_POLICY_CONFLICT, STATUS_POLICY_MISSING, STATUS_RESULT_TOO_LARGE, STATUS_REVISION_CONFLICT, STATUS_SESSION_CONFLICT, - STATUS_SESSION_MISSING, ShaperRegistry, - abi_contract::ENGINE_RESULT_HEADER_SIZE, - bidi, + STATUS_SESSION_MISSING, ShaperRegistry, bidi, engine::{ EngineError, TextEngine, frame::SessionRevision, frame_wire::parse_update_request, - transport::FrameTransport, wire::parse_policy, + render_plan::RenderPlanView, render_plan_wire::plan_layout, transport::FrameTransport, + wire::parse_policy, }, wire::{ pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, @@ -400,22 +399,58 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( return publish_failure(state, session_id, revision, engine_status(error), 0, 0); } }; - let Some(transport) = state.frames.get(&session_id) else { - return 0; + let policy_fingerprint = match state.engine.policy(request.policy_handle) { + Ok(policy) => policy.fingerprint(), + Err(error) => { + return publish_failure(state, session_id, revision, engine_status(error), 0, 0); + } + }; + let plan = RenderPlanView { + policy_handle: request.policy_handle, + capability_set: 0, + policy_fingerprint, + ..RenderPlanView::default() }; - if let Err(status) = transport.ensure_publish_capacity(ENGINE_RESULT_HEADER_SIZE) { + let required_output = match plan_layout(plan) { + Ok(layout) => layout.byte_length, + Err(status) => return publish_failure(state, session_id, revision, status, 0, 0), + }; + if required_output > request.limits.max_output_bytes { return publish_failure( state, session_id, revision, - status, + STATUS_RESULT_TOO_LARGE, 0, - ENGINE_RESULT_HEADER_SIZE, + required_output, ); } + let Some(transport) = state.frames.get(&session_id) else { + return 0; + }; + if let Err(status) = transport.ensure_publish_capacity(required_output) { + return publish_failure(state, session_id, revision, status, 0, required_output); + } if let Err(status) = transport.next_publication_generation() { return publish_failure(state, session_id, revision, status, 0, 0); } + let staged = match state + .frames + .get_mut(&session_id) + .and_then(|transport| transport.stage_plan(plan).ok()) + { + Some(staged) => staged, + None => { + return publish_failure( + state, + session_id, + revision, + STATUS_RESULT_TOO_LARGE, + 0, + required_output, + ); + } + }; let commit = match state.engine.commit_update(prepared) { Ok(commit) => commit, Err(error) => { @@ -425,7 +460,7 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( let Some(transport) = state.frames.get_mut(&session_id) else { return 0; }; - u32::try_from(transport.publish_success(commit)).unwrap_or(0) + u32::try_from(transport.publish_success(commit, staged)).unwrap_or(0) }) } diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 3186d679..451a4055 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -34,8 +34,46 @@ export const textShaperAbi = { }, "endianness": "little", "engine": { + "bufferStrategies": { + "orderedDirect": 1, + "stableIndirect": 2 + }, + "patchOpcodes": { + "allocateOrResize": 1, + "copy": 4, + "fill": 3, + "retire": 5, + "write": 2 + }, + "primitiveKinds": { + "clip": 4, + "decoration": 2, + "glyph": 1, + "inlineObject": 3, + "policy": 5 + }, + "resourceActions": { + "create": 1, + "retain": 3, + "update": 2 + }, "resultFlags": { "checkpoint": 1 + }, + "retirementKinds": { + "buffer": 2, + "outputBytes": 4, + "resource": 1, + "slotRange": 3 + }, + "semanticKinds": { + "caret": 5, + "cluster": 4, + "fragment": 2, + "insertedGlyph": 7, + "line": 1, + "run": 3, + "selection": 6 } }, "functions": { @@ -82,40 +120,167 @@ export const textShaperAbi = { "size": 32, "textLength": 12 }, + "engineBuffer": { + "alignment": 4, + "byteLength": 28, + "capacityRecords": 24, + "flags": 18, + "generation": 4, + "id": 0, + "liveRecords": 20, + "orderBufferId": 32, + "policyBufferId": 12, + "programId": 8, + "scalarType": 14, + "size": 36, + "strategy": 16, + "vectorWidth": 15 + }, + "engineDiagnostic": { + "alignment": 4, + "code": 0, + "durationNanosHigh": 20, + "durationNanosLow": 16, + "phase": 3, + "severity": 2, + "size": 24, + "subjectId": 4, + "value0": 8, + "value1": 12 + }, + "engineDraw": { + "alignment": 4, + "bufferCount": 24, + "bufferStart": 20, + "flags": 10, + "id": 0, + "indirectBufferId": 40, + "indirectOffset": 44, + "orderToken": 36, + "primitiveCount": 16, + "primitiveStart": 12, + "programId": 4, + "resourceCount": 32, + "resourceStart": 28, + "size": 48, + "variant": 8 + }, + "enginePatch": { + "alignment": 4, + "bufferGeneration": 8, + "bufferId": 4, + "byteLength": 16, + "destinationOffset": 12, + "fillValue": 32, + "flags": 2, + "opcode": 0, + "payloadOffset": 20, + "size": 36, + "sourceBufferId": 24, + "sourceOffset": 28 + }, + "enginePrimitive": { + "alignment": 4, + "blockExtent": 60, + "blockStart": 52, + "bufferId": 28, + "clipId": 40, + "flags": 6, + "id": 0, + "inlineExtent": 56, + "inlineStart": 48, + "kind": 4, + "logicalOrder": 36, + "programId": 20, + "recordIndex": 32, + "reserved": 26, + "resourceGeneration": 16, + "resourceId": 12, + "semanticId": 44, + "size": 64, + "techniqueId": 8, + "variant": 24 + }, + "engineResource": { + "action": 14, + "alignment": 4, + "auxiliary0": 32, + "auxiliary1": 36, + "flags": 16, + "generation": 4, + "id": 0, + "lowerBound": 24, + "referenceId": 20, + "resourceKind": 12, + "size": 40, + "techniqueId": 8, + "upperBound": 28 + }, "engineResult": { "abiVersion": 0, "alignment": 16, - "bufferCount": 76, - "buffersOffset": 72, + "bufferCount": 92, + "buffersOffset": 88, "byteLength": 4, - "diagnosticCount": 116, - "diagnosticsOffset": 112, - "drawCount": 100, - "drawsOffset": 96, + "capabilitySet": 60, + "diagnosticCount": 132, + "diagnosticsOffset": 128, + "drawCount": 116, + "drawsOffset": 112, "engineRevision": 20, "flags": 12, "outputSlot": 36, - "patchCount": 84, - "patchesOffset": 80, + "patchCount": 100, + "patchesOffset": 96, "planRevision": 24, - "primitiveCount": 92, - "primitivesOffset": 88, + "policyFingerprintHigh": 68, + "policyFingerprintLow": 64, + "policyHandle": 56, + "primitiveCount": 108, + "primitivesOffset": 104, "publicationGeneration": 32, "requestCapacity": 40, "requiredBaseRevision": 28, "requiredRequestCapacity": 44, "requiredResultCapacity": 52, - "resourceCount": 68, - "resourcesOffset": 64, + "resourceCount": 84, + "resourcesOffset": 80, "resultCapacity": 48, - "retirementCount": 108, - "retirementsOffset": 104, - "semanticsCount": 60, - "semanticsOffset": 56, + "retirementCount": 124, + "retirementsOffset": 120, + "semanticsCount": 76, + "semanticsOffset": 72, "sessionId": 16, - "size": 128, + "size": 144, "status": 8 }, + "engineRetirement": { + "afterPublicationGeneration": 12, + "alignment": 4, + "byteLength": 20, + "byteOffset": 16, + "flags": 2, + "generation": 8, + "id": 4, + "kind": 0, + "size": 24 + }, + "engineSemantic": { + "alignment": 4, + "blockExtent": 40, + "blockStart": 32, + "flags": 6, + "id": 0, + "inlineExtent": 36, + "inlineStart": 28, + "itemCount": 24, + "itemStart": 20, + "kind": 4, + "parentId": 8, + "size": 44, + "textEnd": 16, + "textStart": 12 + }, "engineUpdateRequest": { "abiVersion": 0, "alignment": 4, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 545a77ec..bbdcb700 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -25,8 +25,11 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as const requestLayout = abi.layouts.engineUpdateRequest; const resultLayout = abi.layouts.engineResult; - assert.equal(resultLayout.size, 128); + assert.equal(resultLayout.size, 144); assert.equal(resultLayout.alignment, 16); + assert.equal(abi.layouts.engineBuffer.size, 36); + assert.equal(abi.layouts.enginePatch.size, 36); + assert.equal(abi.layouts.enginePrimitive.size, 64); assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size), abi.status.ok); assert.equal(fn.sessionCount(), 1); let requestPointer = fn.requestPointer(sessionId); @@ -142,6 +145,19 @@ function assertResult(memory, pointer, abi, expected) { for (const [field, value] of Object.entries(expected)) { assert.equal(view.getUint32(layout[field], true), value, field); } + if (expected.status === abi.status.ok) { + assert.equal(view.getUint32(layout.policyHandle, true), policyHandle); + assert.equal(view.getUint32(layout.capabilitySet, true), 0); + assert.notEqual( + view.getUint32(layout.policyFingerprintLow, true) | view.getUint32(layout.policyFingerprintHigh, true), + 0, + 'a successful plan identifies its validated policy bytes', + ); + } else { + assert.equal(view.getUint32(layout.policyHandle, true), 0); + assert.equal(view.getUint32(layout.policyFingerprintLow, true), 0); + assert.equal(view.getUint32(layout.policyFingerprintHigh, true), 0); + } for (const field of [ 'semanticsCount', 'resourceCount', From 48c7312ef09317937a9c9bac321eb16de2b8e258 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 07:35:57 -0400 Subject: [PATCH 009/128] feat(text): describe capability-shaped render policies --- docs/log.md | 8 + docs/packages/text.md | 13 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 16 +- packages/text/rust/shaper/src/abi_contract.rs | 211 +++++- packages/text/rust/shaper/src/engine/frame.rs | 1 + .../text/rust/shaper/src/engine/frame_wire.rs | 3 +- .../text/rust/shaper/src/engine/kernel_lab.rs | 41 +- .../text/rust/shaper/src/engine/policy.rs | 692 +++++++++++++----- packages/text/rust/shaper/src/engine/state.rs | 66 +- packages/text/rust/shaper/src/engine/wire.rs | 174 ++++- packages/text/rust/shaper/src/wasm.rs | 2 +- .../text/src/generated/text-shaper-abi.ts | 95 ++- .../render-plan-frame-abi.test.mjs | 2 +- packages/text/tests/support/engine-abi.mjs | 71 +- 15 files changed, 1145 insertions(+), 251 deletions(-) diff --git a/docs/log.md b/docs/log.md index 0a0f364a..5f797925 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Completed the capability-shaped policy ABI** — Extended the compiler-mapped registration transaction with exact + capability-set, program-planning, and physical-buffer metadata: backend limits and upload costs, capability-specific + program selection, technique/resource and batch-key masks, ordered-direct versus stable-indirect allocation, and + aligned padded strides. Unknown capabilities and unsupported combinations fail before revision change; the executor + proves padding-safe writes. V0 keeps independently bindable vector streams and uses policy bytecode to pack `vec2`/ + `vec4` records instead of adding aliased mutable interleaving. The focused Rust and Node gates pass; the optimized SIMD + artifact measures 739,647 raw / 272,532 gzip / 214,186 Brotli bytes. Retained diff compilation remains the next proof. + - **Fixed the compiler-mapped render-plan wire grammar** — Extended the aligned result header from 128 to 144 bytes to carry policy handle, capability set, and a deterministic validated-policy fingerprint. Added exact semantic, resource, buffer, patch, primitive, draw, retirement, and diagnostic records plus tagged actions and allocation strategies. diff --git a/docs/packages/text.md b/docs/packages/text.md index 69e813b4..4dcd9ad6 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:5f930e6e6458e184cc932a772b91113765a245c0c139ed771bd902d594076d3d' +source_digest: 'sha256:750181db8d897681d0bbfb490bb164f20ecd209d4ee3d16a88c9b26d14728715' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -416,6 +416,17 @@ registered policy fingerprint with the plan, while failure headers expose neithe The current shipping update still emits an empty plan until retained semantic compilation lands; these records prove the wire and publication contract, not incremental-layout performance. +Policy registration now supplies the missing inputs to that compiler through the same compiler-mapped direct-memory +contract. Its 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte buffer, and 16-byte +operation tables; registration decodes those bytes once into retained typed Rust state. Capability-set-specific lookup +validates backend flags, binding and draw limits, integer upload costs, resource-kind and batch-key masks, allocation +strategy, and aligned padded buffer strides before a session revision can advance. The executor honors declared stride +without touching padding. Physical outputs remain disjoint independently bindable vector streams; policy operations +pack wider records instead of introducing aliased mutable interleaved fields. Thirty-six Rust unit tests and the focused +Node registration/frame tests pass. The optimized SIMD artifact is 739,647 raw / 272,532 gzip / 214,186 Brotli bytes, +14,075 / 3,272 / 3,173 bytes above the preceding executor artifact; this is registration/planner metadata, not a warm +layout performance result. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 6575df1f..870c718e 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -229,6 +229,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | | D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | | D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not a backend command buffer or raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (48), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | +| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, batch-key fields, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 9d1a83c1..f6be3364 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -599,7 +599,7 @@ program cannot implement fails preparation before publication. The descriptor includes: -- named physical buffer schemas: scalar type, vector width, alignment, stride, interleaving/SoA layout, capacity class, +- named independently bindable physical vector streams: scalar type, vector width, alignment, stride, capacity class, and usage intent; - a technique capability table mapping stable technique IDs to program IDs, accepted resource kinds, supported paint and compositing features, and physical schemas; @@ -619,6 +619,20 @@ engine iterates the validated program over four-record SIMD lanes and executes s Slug policies use the same bytecode and verifier as external policies; a native builder emits that bytecode rather than bypassing it with a second Rust policy trait. +The compiler-mapped V0 registration ABI uses a 36-byte request header followed by 40-byte capability-set, 56-byte +program, 16-byte physical-buffer, and 16-byte operation records. Capability-set selection is part of program lookup: +an exact set-specific program wins over a set-agnostic program, and an update naming an undeclared set fails before its +revision changes. Capability sets own storage/indirect/aliasing flags, maximum binding and draw limits, update alignment, +and the integer upload-cost model. Programs own the resource-kind mask, semantic-view request, batch-key mask, and one +of the two allocation strategies. Physical streams own explicit alignment, padded stride, usage, and capacity class. +All reserved bits and fields are zero and unknown flags fail registration. + +V0 does not alias several logical stores into one mutable interleaved byte span. Augmentation instead combines semantic +fields into independently bindable `vec2`/`vec4` or integer-vector records, including the existing MSDF and Slug +WebGL-compatible packing. This keeps executor borrows disjoint, avoids another aliasing grammar in the native ABI, and +still lets a policy trade buffer count against record width. Adding interleaved field offsets would require a new ABI +version and measured binding-pressure evidence; it is not a latent V0 implementation choice. + Augmentation examples include packing `origin + size` into `vec4`, adding atlas/material indices, emitting selection or object IDs, quantizing fields, or requesting per-glyph bounds. It may not choose line breaks, mutate cluster order, or change semantic positions. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index a38e79b6..aacc6d37 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -4,9 +4,13 @@ use serde_json::json; use crate::engine::frame::RESULT_FLAG_CHECKPOINT; use crate::engine::policy::{ - OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, - OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, - OP_STORE_U32, OP_SUBTRACT_F32, ScalarType, + ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, + BUFFER_USAGE_STORAGE, BUFFER_USAGE_VERTEX, CAP_ALIAS_VEC2, CAP_ALIAS_VEC4, CAP_INDIRECT_DRAWS, + CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, CAP_STORAGE_BUFFERS, OP_ADD_F32, OP_CONSTANT_F32, + OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, + OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, + ScalarType, }; use crate::engine::render_plan::{ BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT, BufferRecord, DiagnosticRecord, DrawRecord, @@ -54,6 +58,8 @@ struct BidiRequestHeader { #[repr(C)] struct PolicyRequestHeader { byte_length: u32, + capability_sets_offset: u32, + capability_set_count: u32, programs_offset: u32, program_count: u32, buffers_offset: u32, @@ -62,21 +68,42 @@ struct PolicyRequestHeader { operation_count: u32, } +#[repr(C)] +struct PolicyCapabilitySetRecord { + id: u32, + flags: u32, + max_buffer_bytes: u32, + update_alignment: u32, + coalesce_gap_bytes: u32, + range_call_penalty_bytes: u32, + max_buffers_per_draw: u16, + max_resources_per_draw: u16, + max_indirect_draws: u16, + fragmentation_budget: u16, + whole_buffer_threshold_basis_points: u16, + reserved: [u16; 3], +} + #[repr(C)] struct PolicyProgramRecord { technique_id: u32, program_id: u32, - variant: u16, - f32_input_count: u8, - u32_input_count: u8, + capability_set_id: u32, + resource_kind_mask: u32, + semantic_view_mask: u32, + batch_key_mask: u32, paint_capabilities: u32, compositing_capabilities: u32, buffer_start: u32, - buffer_count: u16, - reserved0: u16, operation_start: u32, + variant: u16, + buffer_count: u16, operation_count: u16, - reserved1: u16, + allocation_strategy: u16, + f32_input_count: u8, + u32_input_count: u8, + reserved0: u16, + reserved1: u32, } #[repr(C)] @@ -84,6 +111,11 @@ struct PolicyBufferRecord { id: u16, scalar: u8, vector_width: u8, + alignment: u16, + stride: u16, + usage: u32, + capacity_class: u16, + reserved0: u16, } #[repr(C)] @@ -259,6 +291,11 @@ layout!( POLICY_REQUEST_HEADER_ALIGNMENT, PolicyRequestHeader ); +layout!( + POLICY_CAPABILITY_SET_RECORD_SIZE, + POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + PolicyCapabilitySetRecord +); layout!( POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RECORD_ALIGNMENT, @@ -346,6 +383,16 @@ field_offset!(BIDI_TEXT_OFFSET, BidiRequestHeader, text_offset); field_offset!(BIDI_TEXT_LENGTH, BidiRequestHeader, text_length); field_offset!(BIDI_DIRECTION, BidiRequestHeader, direction); field_offset!(POLICY_BYTE_LENGTH, PolicyRequestHeader, byte_length); +field_offset!( + POLICY_CAPABILITY_SETS_OFFSET, + PolicyRequestHeader, + capability_sets_offset +); +field_offset!( + POLICY_CAPABILITY_SET_COUNT, + PolicyRequestHeader, + capability_set_count +); field_offset!(POLICY_PROGRAMS_OFFSET, PolicyRequestHeader, programs_offset); field_offset!(POLICY_PROGRAM_COUNT, PolicyRequestHeader, program_count); field_offset!(POLICY_BUFFERS_OFFSET, PolicyRequestHeader, buffers_offset); @@ -356,12 +403,88 @@ field_offset!( operations_offset ); field_offset!(POLICY_OPERATION_COUNT, PolicyRequestHeader, operation_count); +field_offset!(POLICY_CAPABILITY_SET_ID, PolicyCapabilitySetRecord, id); +field_offset!( + POLICY_CAPABILITY_SET_FLAGS, + PolicyCapabilitySetRecord, + flags +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, + PolicyCapabilitySetRecord, + max_buffer_bytes +); +field_offset!( + POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, + PolicyCapabilitySetRecord, + update_alignment +); +field_offset!( + POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES, + PolicyCapabilitySetRecord, + coalesce_gap_bytes +); +field_offset!( + POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, + PolicyCapabilitySetRecord, + range_call_penalty_bytes +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, + PolicyCapabilitySetRecord, + max_buffers_per_draw +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, + PolicyCapabilitySetRecord, + max_resources_per_draw +); +field_offset!( + POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS, + PolicyCapabilitySetRecord, + max_indirect_draws +); +field_offset!( + POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, + PolicyCapabilitySetRecord, + fragmentation_budget +); +field_offset!( + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + PolicyCapabilitySetRecord, + whole_buffer_threshold_basis_points +); +field_offset!( + POLICY_CAPABILITY_SET_RESERVED, + PolicyCapabilitySetRecord, + reserved +); field_offset!( POLICY_PROGRAM_TECHNIQUE_ID, PolicyProgramRecord, technique_id ); field_offset!(POLICY_PROGRAM_ID, PolicyProgramRecord, program_id); +field_offset!( + POLICY_PROGRAM_CAPABILITY_SET_ID, + PolicyProgramRecord, + capability_set_id +); +field_offset!( + POLICY_PROGRAM_RESOURCE_KIND_MASK, + PolicyProgramRecord, + resource_kind_mask +); +field_offset!( + POLICY_PROGRAM_SEMANTIC_VIEW_MASK, + PolicyProgramRecord, + semantic_view_mask +); +field_offset!( + POLICY_PROGRAM_BATCH_KEY_MASK, + PolicyProgramRecord, + batch_key_mask +); field_offset!(POLICY_PROGRAM_VARIANT, PolicyProgramRecord, variant); field_offset!( POLICY_PROGRAM_F32_INPUT_COUNT, @@ -404,10 +527,24 @@ field_offset!( PolicyProgramRecord, operation_count ); +field_offset!( + POLICY_PROGRAM_ALLOCATION_STRATEGY, + PolicyProgramRecord, + allocation_strategy +); field_offset!(POLICY_PROGRAM_RESERVED1, PolicyProgramRecord, reserved1); field_offset!(POLICY_BUFFER_ID, PolicyBufferRecord, id); field_offset!(POLICY_BUFFER_SCALAR, PolicyBufferRecord, scalar); field_offset!(POLICY_BUFFER_VECTOR_WIDTH, PolicyBufferRecord, vector_width); +field_offset!(POLICY_BUFFER_ALIGNMENT, PolicyBufferRecord, alignment); +field_offset!(POLICY_BUFFER_STRIDE, PolicyBufferRecord, stride); +field_offset!(POLICY_BUFFER_USAGE, PolicyBufferRecord, usage); +field_offset!( + POLICY_BUFFER_CAPACITY_CLASS, + PolicyBufferRecord, + capacity_class +); +field_offset!(POLICY_BUFFER_RESERVED0, PolicyBufferRecord, reserved0); field_offset!(POLICY_OPERATION_OPCODE, PolicyOperationRecord, opcode); field_offset!(POLICY_OPERATION_TARGET, PolicyOperationRecord, target); field_offset!(POLICY_OPERATION_OPERAND0, PolicyOperationRecord, operand0); @@ -954,6 +1091,8 @@ pub fn json() -> String { "size": POLICY_REQUEST_HEADER_SIZE, "alignment": POLICY_REQUEST_HEADER_ALIGNMENT, "byteLength": POLICY_BYTE_LENGTH, + "capabilitySetsOffset": POLICY_CAPABILITY_SETS_OFFSET, + "capabilitySetCount": POLICY_CAPABILITY_SET_COUNT, "programsOffset": POLICY_PROGRAMS_OFFSET, "programCount": POLICY_PROGRAM_COUNT, "buffersOffset": POLICY_BUFFERS_OFFSET, @@ -961,11 +1100,31 @@ pub fn json() -> String { "operationsOffset": POLICY_OPERATIONS_OFFSET, "operationCount": POLICY_OPERATION_COUNT }, + "policyCapabilitySet": { + "size": POLICY_CAPABILITY_SET_RECORD_SIZE, + "alignment": POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + "id": POLICY_CAPABILITY_SET_ID, + "flags": POLICY_CAPABILITY_SET_FLAGS, + "maxBufferBytes": POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, + "updateAlignment": POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, + "coalesceGapBytes": POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES, + "rangeCallPenaltyBytes": POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, + "maxBuffersPerDraw": POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, + "maxResourcesPerDraw": POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, + "maxIndirectDraws": POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS, + "fragmentationBudget": POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, + "wholeBufferThresholdBasisPoints": POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + "reserved": POLICY_CAPABILITY_SET_RESERVED + }, "policyProgram": { "size": POLICY_PROGRAM_RECORD_SIZE, "alignment": POLICY_PROGRAM_RECORD_ALIGNMENT, "techniqueId": POLICY_PROGRAM_TECHNIQUE_ID, "programId": POLICY_PROGRAM_ID, + "capabilitySetId": POLICY_PROGRAM_CAPABILITY_SET_ID, + "resourceKindMask": POLICY_PROGRAM_RESOURCE_KIND_MASK, + "semanticViewMask": POLICY_PROGRAM_SEMANTIC_VIEW_MASK, + "batchKeyMask": POLICY_PROGRAM_BATCH_KEY_MASK, "variant": POLICY_PROGRAM_VARIANT, "f32InputCount": POLICY_PROGRAM_F32_INPUT_COUNT, "u32InputCount": POLICY_PROGRAM_U32_INPUT_COUNT, @@ -976,6 +1135,7 @@ pub fn json() -> String { "reserved0": POLICY_PROGRAM_RESERVED0, "operationStart": POLICY_PROGRAM_OPERATION_START, "operationCount": POLICY_PROGRAM_OPERATION_COUNT, + "allocationStrategy": POLICY_PROGRAM_ALLOCATION_STRATEGY, "reserved1": POLICY_PROGRAM_RESERVED1 }, "policyBuffer": { @@ -983,7 +1143,12 @@ pub fn json() -> String { "alignment": POLICY_BUFFER_RECORD_ALIGNMENT, "id": POLICY_BUFFER_ID, "scalar": POLICY_BUFFER_SCALAR, - "vectorWidth": POLICY_BUFFER_VECTOR_WIDTH + "vectorWidth": POLICY_BUFFER_VECTOR_WIDTH, + "alignment": POLICY_BUFFER_ALIGNMENT, + "stride": POLICY_BUFFER_STRIDE, + "usage": POLICY_BUFFER_USAGE, + "capacityClass": POLICY_BUFFER_CAPACITY_CLASS, + "reserved0": POLICY_BUFFER_RESERVED0 }, "policyOperation": { "size": POLICY_OPERATION_RECORD_SIZE, @@ -1266,6 +1431,32 @@ pub fn json() -> String { } }, "policy": { + "capabilityFlags": { + "storageBuffers": CAP_STORAGE_BUFFERS, + "indirectDraws": CAP_INDIRECT_DRAWS, + "aliasVec2": CAP_ALIAS_VEC2, + "aliasVec4": CAP_ALIAS_VEC4, + "orderedDirect": CAP_ORDERED_DIRECT, + "stableIndirect": CAP_STABLE_INDIRECT + }, + "batchFields": { + "technique": BATCH_TECHNIQUE, + "resource": BATCH_RESOURCE, + "program": BATCH_PROGRAM, + "material": BATCH_MATERIAL, + "clip": BATCH_CLIP, + "depth": BATCH_DEPTH, + "order": BATCH_ORDER + }, + "bufferUsage": { + "vertex": BUFFER_USAGE_VERTEX, + "storage": BUFFER_USAGE_STORAGE, + "copyDst": BUFFER_USAGE_COPY_DST + }, + "allocationStrategies": { + "orderedDirect": ALLOCATION_ORDERED_DIRECT, + "stableIndirect": ALLOCATION_STABLE_INDIRECT + }, "scalarTypes": { "f32": ScalarType::F32 as u8, "u32": ScalarType::U32 as u8, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index f7efa68e..900b3a7a 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -6,6 +6,7 @@ pub(crate) struct UpdateRequest { pub expected_engine_revision: u32, pub consumed_plan_revision: u32, pub policy_handle: u32, + pub capability_set: u32, pub limits: UpdateLimits, } diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 4f7ce80f..75f333fc 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -35,7 +35,6 @@ pub(crate) fn parse_update_request(bytes: &[u8], session_id: u32) -> Result Result()), }, PhysicalBufferMut { - schema: BufferSchema { - id: BufferId(2), - scalar: ScalarType::U32, - vector_width: 1, - }, + schema: BufferSchema::packed( + BufferId(2), + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), bytes: byte_slice_mut(u32_output_pointer, count * mem::size_of::()), }, PhysicalBufferMut { - schema: BufferSchema { - id: BufferId(3), - scalar: ScalarType::U16, - vector_width: 1, - }, + schema: BufferSchema::packed( + BufferId(3), + ScalarType::U16, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), bytes: byte_slice_mut(u16_output_pointer, count * mem::size_of::()), }, ]; policy .execute( + CapabilitySetId(1), TechniqueId(technique), variant, SemanticInputBatch { diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index d828a5f2..65d912ce 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -6,6 +6,7 @@ use alloc::vec::Vec; pub const MAX_PROGRAMS: usize = 32; +pub const MAX_CAPABILITY_SETS: usize = 8; pub const MAX_BUFFERS_PER_PROGRAM: usize = 16; pub const MAX_OPERATIONS_PER_PROGRAM: usize = 128; pub const MAX_REGISTERS: usize = 32; @@ -13,6 +14,42 @@ pub const MAX_VECTOR_WIDTH: u8 = 4; const MAX_OUTPUT_LANES: usize = MAX_BUFFERS_PER_PROGRAM * MAX_VECTOR_WIDTH as usize; const NOT_A_STORE: u8 = u8::MAX; +pub const CAP_STORAGE_BUFFERS: u32 = 1 << 0; +pub const CAP_INDIRECT_DRAWS: u32 = 1 << 1; +pub const CAP_ALIAS_VEC2: u32 = 1 << 2; +pub const CAP_ALIAS_VEC4: u32 = 1 << 3; +pub const CAP_ORDERED_DIRECT: u32 = 1 << 4; +pub const CAP_STABLE_INDIRECT: u32 = 1 << 5; +const CAPABILITY_FLAGS: u32 = CAP_STORAGE_BUFFERS + | CAP_INDIRECT_DRAWS + | CAP_ALIAS_VEC2 + | CAP_ALIAS_VEC4 + | CAP_ORDERED_DIRECT + | CAP_STABLE_INDIRECT; + +pub const BATCH_TECHNIQUE: u32 = 1 << 0; +pub const BATCH_RESOURCE: u32 = 1 << 1; +pub const BATCH_PROGRAM: u32 = 1 << 2; +pub const BATCH_MATERIAL: u32 = 1 << 3; +pub const BATCH_CLIP: u32 = 1 << 4; +pub const BATCH_DEPTH: u32 = 1 << 5; +pub const BATCH_ORDER: u32 = 1 << 6; +const BATCH_FIELDS: u32 = BATCH_TECHNIQUE + | BATCH_RESOURCE + | BATCH_PROGRAM + | BATCH_MATERIAL + | BATCH_CLIP + | BATCH_DEPTH + | BATCH_ORDER; + +pub const BUFFER_USAGE_VERTEX: u32 = 1 << 0; +pub const BUFFER_USAGE_STORAGE: u32 = 1 << 1; +pub const BUFFER_USAGE_COPY_DST: u32 = 1 << 2; +const BUFFER_USAGE_FLAGS: u32 = BUFFER_USAGE_VERTEX | BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST; + +pub const ALLOCATION_ORDERED_DIRECT: u16 = 1; +pub const ALLOCATION_STABLE_INDIRECT: u16 = 2; + pub const OP_LOAD_F32: u8 = 1; pub const OP_LOAD_U32: u8 = 2; pub const OP_CONSTANT_F32: u8 = 3; @@ -39,6 +76,10 @@ pub struct TechniqueId(pub u32); #[repr(transparent)] pub struct ProgramId(pub u32); +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[repr(transparent)] +pub struct CapabilitySetId(pub u32); + #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] #[repr(transparent)] pub struct BufferId(pub u16); @@ -65,14 +106,52 @@ pub struct BufferSchema { pub id: BufferId, pub scalar: ScalarType, pub vector_width: u8, + pub alignment: u16, + pub stride: u16, + pub usage: u32, + pub capacity_class: u16, } impl BufferSchema { pub fn stride(self) -> usize { - self.scalar.byte_width() * usize::from(self.vector_width) + usize::from(self.stride) + } + + pub const fn packed( + id: BufferId, + scalar: ScalarType, + vector_width: u8, + usage: u32, + capacity_class: u16, + ) -> Self { + let byte_width = scalar.byte_width() as u16 * vector_width as u16; + Self { + id, + scalar, + vector_width, + alignment: scalar.byte_width() as u16, + stride: byte_width, + usage, + capacity_class, + } } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CapabilitySet { + pub id: CapabilitySetId, + pub flags: u32, + pub max_buffer_bytes: u32, + pub update_alignment: u32, + pub coalesce_gap_bytes: u32, + pub range_call_penalty_bytes: u32, + pub max_buffers_per_draw: u16, + pub max_resources_per_draw: u16, + pub max_indirect_draws: u16, + pub fragmentation_budget: u16, + pub whole_buffer_threshold_basis_points: u16, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ProgramCapabilities { pub paint: u32, @@ -149,6 +228,12 @@ pub struct ProgramDescriptor { pub technique: TechniqueId, pub variant: u16, pub id: ProgramId, + /// Zero makes a program valid for every capability set in this policy. + pub capability_set: CapabilitySetId, + pub resource_kind_mask: u32, + pub semantic_view_mask: u32, + pub batch_key_mask: u32, + pub allocation_strategy: u16, pub f32_input_count: u8, pub u32_input_count: u8, pub capabilities: ProgramCapabilities, @@ -158,11 +243,13 @@ pub struct ProgramDescriptor { #[derive(Clone, Debug, PartialEq)] pub struct PolicyDescriptor { + pub capability_sets: Vec, pub programs: Vec, } #[derive(Clone, Debug, PartialEq)] pub struct ValidatedPolicy { + capability_sets: Vec, programs: Vec, execution: Vec, fingerprint: u64, @@ -180,6 +267,7 @@ impl ValidatedPolicy { } Ok(Self { fingerprint: policy_fingerprint(&descriptor), + capability_sets: descriptor.capability_sets, programs: descriptor.programs, execution, }) @@ -189,28 +277,67 @@ impl ValidatedPolicy { &self.programs } + pub fn capability_sets(&self) -> &[CapabilitySet] { + &self.capability_sets + } + pub fn fingerprint(&self) -> u64 { self.fingerprint } - pub fn program(&self, technique: TechniqueId, variant: u16) -> Option<&ProgramDescriptor> { + pub fn capability_set(&self, id: CapabilitySetId) -> Option<&CapabilitySet> { + self.capability_sets.iter().find(|set| set.id == id) + } + + pub fn program( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + ) -> Option<&ProgramDescriptor> { self.programs .iter() - .find(|program| program.technique == technique && program.variant == variant) + .find(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().find(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + }) } pub fn execute( &self, + capability_set: CapabilitySetId, technique: TechniqueId, variant: u16, inputs: SemanticInputBatch<'_>, output_start: usize, outputs: &mut [PhysicalBufferMut<'_>], ) -> Result<(), PolicyExecutionError> { + if self.capability_set(capability_set).is_none() { + return Err(PolicyExecutionError::CapabilitySetMissing); + } let program_index = self .programs .iter() - .position(|program| program.technique == technique && program.variant == variant) + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + }) .ok_or(PolicyExecutionError::ProgramMissing)?; let program = self .programs @@ -226,10 +353,32 @@ impl ValidatedPolicy { fn policy_fingerprint(descriptor: &PolicyDescriptor) -> u64 { let mut fingerprint = 0xcbf2_9ce4_8422_2325_u64; + mix_u32(&mut fingerprint, descriptor.capability_sets.len() as u32); + for set in &descriptor.capability_sets { + mix_u32(&mut fingerprint, set.id.0); + mix_u32(&mut fingerprint, set.flags); + mix_u32(&mut fingerprint, set.max_buffer_bytes); + mix_u32(&mut fingerprint, set.update_alignment); + mix_u32(&mut fingerprint, set.coalesce_gap_bytes); + mix_u32(&mut fingerprint, set.range_call_penalty_bytes); + mix_u32(&mut fingerprint, u32::from(set.max_buffers_per_draw)); + mix_u32(&mut fingerprint, u32::from(set.max_resources_per_draw)); + mix_u32(&mut fingerprint, u32::from(set.max_indirect_draws)); + mix_u32(&mut fingerprint, u32::from(set.fragmentation_budget)); + mix_u32( + &mut fingerprint, + u32::from(set.whole_buffer_threshold_basis_points), + ); + } mix_u32(&mut fingerprint, descriptor.programs.len() as u32); for program in &descriptor.programs { mix_u32(&mut fingerprint, program.technique.0); mix_u32(&mut fingerprint, program.id.0); + mix_u32(&mut fingerprint, program.capability_set.0); + mix_u32(&mut fingerprint, program.resource_kind_mask); + mix_u32(&mut fingerprint, program.semantic_view_mask); + mix_u32(&mut fingerprint, program.batch_key_mask); + mix_u32(&mut fingerprint, u32::from(program.allocation_strategy)); mix_u32(&mut fingerprint, u32::from(program.variant)); mix_u32(&mut fingerprint, u32::from(program.f32_input_count)); mix_u32(&mut fingerprint, u32::from(program.u32_input_count)); @@ -240,6 +389,10 @@ fn policy_fingerprint(descriptor: &PolicyDescriptor) -> u64 { mix_u32(&mut fingerprint, u32::from(buffer.id.0)); mix_u32(&mut fingerprint, buffer.scalar as u32); mix_u32(&mut fingerprint, u32::from(buffer.vector_width)); + mix_u32(&mut fingerprint, u32::from(buffer.alignment)); + mix_u32(&mut fingerprint, u32::from(buffer.stride)); + mix_u32(&mut fingerprint, buffer.usage); + mix_u32(&mut fingerprint, u32::from(buffer.capacity_class)); } mix_u32(&mut fingerprint, program.operations.len() as u32); for operation in &program.operations { @@ -369,6 +522,7 @@ pub struct PhysicalBufferMut<'a> { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PolicyExecutionError { + CapabilitySetMissing, ProgramMissing, InputFieldCount, InputLength, @@ -381,17 +535,33 @@ pub enum PolicyExecutionError { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PolicyError { AllocationFailed, + EmptyCapabilitySets, + TooManyCapabilitySets, + InvalidCapabilitySetId, + DuplicateCapabilitySetId, + InvalidCapabilityFlags, + InvalidCapabilityLimits, + InvalidUpdateAlignment, + InvalidUploadCostModel, EmptyPolicy, TooManyPrograms, InvalidTechniqueId, InvalidProgramId, DuplicateTechniqueVariant, DuplicateProgramId, + UnknownCapabilitySet, + InvalidResourceKinds, + InvalidBatchKey, + UnsupportedAllocationStrategy, EmptyBuffers, TooManyBuffers, InvalidBufferId, DuplicateBufferId, InvalidVectorWidth, + InvalidBufferAlignment, + InvalidBufferStride, + InvalidBufferUsage, + InvalidCapacityClass, EmptyOperations, TooManyOperations, InvalidRegister, @@ -755,6 +925,7 @@ fn store_buffer(operation: &Operation) -> Option { } fn validate_policy(descriptor: &PolicyDescriptor) -> Result<(), PolicyError> { + validate_capability_sets(&descriptor.capability_sets)?; if descriptor.programs.is_empty() { return Err(PolicyError::EmptyPolicy); } @@ -768,8 +939,44 @@ fn validate_policy(descriptor: &PolicyDescriptor) -> Result<(), PolicyError> { if program.id.0 == 0 { return Err(PolicyError::InvalidProgramId); } + if program.capability_set.0 != 0 + && !descriptor + .capability_sets + .iter() + .any(|set| set.id == program.capability_set) + { + return Err(PolicyError::UnknownCapabilitySet); + } + if program.resource_kind_mask == 0 { + return Err(PolicyError::InvalidResourceKinds); + } + if program.batch_key_mask & !BATCH_FIELDS != 0 + || program.batch_key_mask & BATCH_PROGRAM == 0 + { + return Err(PolicyError::InvalidBatchKey); + } + if !matches!( + program.allocation_strategy, + ALLOCATION_ORDERED_DIRECT | ALLOCATION_STABLE_INDIRECT + ) { + return Err(PolicyError::UnsupportedAllocationStrategy); + } + let required_capability = if program.allocation_strategy == ALLOCATION_ORDERED_DIRECT { + CAP_ORDERED_DIRECT + } else { + CAP_STABLE_INDIRECT + }; + if descriptor.capability_sets.iter().any(|set| { + (program.capability_set.0 == 0 || program.capability_set == set.id) + && set.flags & required_capability == 0 + }) { + return Err(PolicyError::UnsupportedAllocationStrategy); + } for previous in &descriptor.programs[..index] { - if previous.technique == program.technique && previous.variant == program.variant { + if previous.capability_set == program.capability_set + && previous.technique == program.technique + && previous.variant == program.variant + { return Err(PolicyError::DuplicateTechniqueVariant); } if previous.id == program.id { @@ -778,6 +985,60 @@ fn validate_policy(descriptor: &PolicyDescriptor) -> Result<(), PolicyError> { } validate_program(program)?; } + if descriptor.capability_sets.iter().any(|set| { + !descriptor + .programs + .iter() + .any(|program| program.capability_set.0 == 0 || program.capability_set == set.id) + }) { + return Err(PolicyError::UnknownCapabilitySet); + } + Ok(()) +} + +fn validate_capability_sets(capability_sets: &[CapabilitySet]) -> Result<(), PolicyError> { + if capability_sets.is_empty() { + return Err(PolicyError::EmptyCapabilitySets); + } + if capability_sets.len() > MAX_CAPABILITY_SETS { + return Err(PolicyError::TooManyCapabilitySets); + } + for (index, set) in capability_sets.iter().enumerate() { + if set.id.0 == 0 { + return Err(PolicyError::InvalidCapabilitySetId); + } + if capability_sets[..index] + .iter() + .any(|previous| previous.id == set.id) + { + return Err(PolicyError::DuplicateCapabilitySetId); + } + if set.flags & !CAPABILITY_FLAGS != 0 + || set.flags & (CAP_ORDERED_DIRECT | CAP_STABLE_INDIRECT) == 0 + { + return Err(PolicyError::InvalidCapabilityFlags); + } + if set.max_buffer_bytes == 0 + || set.max_buffers_per_draw == 0 + || usize::from(set.max_buffers_per_draw) > MAX_BUFFERS_PER_PROGRAM + || set.max_resources_per_draw == 0 + || set.fragmentation_budget == 0 + { + return Err(PolicyError::InvalidCapabilityLimits); + } + if !set.update_alignment.is_power_of_two() || set.update_alignment > 256 { + return Err(PolicyError::InvalidUpdateAlignment); + } + if set.coalesce_gap_bytes > set.max_buffer_bytes + || set.range_call_penalty_bytes > set.max_buffer_bytes + || !(1..=10_000).contains(&set.whole_buffer_threshold_basis_points) + { + return Err(PolicyError::InvalidUploadCostModel); + } + if (set.flags & CAP_INDIRECT_DRAWS == 0) != (set.max_indirect_draws == 0) { + return Err(PolicyError::InvalidCapabilityLimits); + } + } Ok(()) } @@ -795,6 +1056,28 @@ fn validate_program(program: &ProgramDescriptor) -> Result<(), PolicyError> { if buffer.vector_width == 0 || buffer.vector_width > MAX_VECTOR_WIDTH { return Err(PolicyError::InvalidVectorWidth); } + if buffer.alignment == 0 || !buffer.alignment.is_power_of_two() || buffer.alignment > 256 { + return Err(PolicyError::InvalidBufferAlignment); + } + let packed_width = buffer + .scalar + .byte_width() + .checked_mul(usize::from(buffer.vector_width)) + .ok_or(PolicyError::InvalidBufferStride)?; + if usize::from(buffer.stride) < packed_width + || usize::from(buffer.stride) % usize::from(buffer.alignment) != 0 + { + return Err(PolicyError::InvalidBufferStride); + } + if buffer.usage == 0 + || buffer.usage & !BUFFER_USAGE_FLAGS != 0 + || buffer.usage & BUFFER_USAGE_COPY_DST == 0 + { + return Err(PolicyError::InvalidBufferUsage); + } + if buffer.capacity_class == 0 { + return Err(PolicyError::InvalidCapacityClass); + } if program.buffers[..index] .iter() .any(|previous| previous.id == buffer.id) @@ -983,20 +1266,51 @@ mod tests { const BITMAP: TechniqueId = TechniqueId(1); const PROGRAM: ProgramId = ProgramId(1); const ORIGINS: BufferId = BufferId(1); + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + + fn valid_capability_set() -> CapabilitySet { + CapabilitySet { + id: CAPABILITY, + flags: CAP_STORAGE_BUFFERS | CAP_ORDERED_DIRECT | CAP_STABLE_INDIRECT, + max_buffer_bytes: 64 * 1024 * 1024, + update_alignment: 4, + coalesce_gap_bytes: 128, + range_call_penalty_bytes: 256, + max_buffers_per_draw: MAX_BUFFERS_PER_PROGRAM as u16, + max_resources_per_draw: 16, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 7_500, + } + } + + fn descriptor(programs: Vec) -> PolicyDescriptor { + PolicyDescriptor { + capability_sets: vec![valid_capability_set()], + programs, + } + } fn valid_program() -> ProgramDescriptor { ProgramDescriptor { technique: BITMAP, variant: 0, id: PROGRAM, + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 2, u32_input_count: 0, capabilities: ProgramCapabilities::default(), - buffers: vec![BufferSchema { - id: ORIGINS, - scalar: ScalarType::F32, - vector_width: 2, - }], + buffers: vec![BufferSchema::packed( + ORIGINS, + ScalarType::F32, + 2, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], operations: vec![ Operation::LoadF32 { target: 0, @@ -1022,33 +1336,21 @@ mod tests { #[test] fn accepts_complete_straight_line_program() { - let policy = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![valid_program()], - }) - .unwrap(); + let policy = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); assert_eq!( - policy.program(BITMAP, 0).map(|value| value.id), + policy.program(CAPABILITY, BITMAP, 0).map(|value| value.id), Some(PROGRAM) ); - assert_eq!(policy.program(BITMAP, 1), None); + assert_eq!(policy.program(CAPABILITY, BITMAP, 1), None); } #[test] fn fingerprints_exact_validated_policy_content() { - let first = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![valid_program()], - }) - .unwrap(); - let same = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![valid_program()], - }) - .unwrap(); + let first = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); + let same = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); let mut changed_program = valid_program(); changed_program.technique = TechniqueId(2); - let changed = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![changed_program], - }) - .unwrap(); + let changed = ValidatedPolicy::new(descriptor(vec![changed_program])).unwrap(); assert_eq!(first.fingerprint(), same.fingerprint()); assert_ne!(first.fingerprint(), changed.fingerprint()); } @@ -1062,9 +1364,7 @@ mod tests { lane: 0, }; assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![uninitialized], - }), + ValidatedPolicy::new(descriptor(vec![uninitialized])), Err(PolicyError::UninitializedRegister) ); @@ -1074,9 +1374,7 @@ mod tests { value: 1, }; assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![wrong_type], - }), + ValidatedPolicy::new(descriptor(vec![wrong_type])), Err(PolicyError::RegisterTypeMismatch) ); } @@ -1086,9 +1384,7 @@ mod tests { let mut partial = valid_program(); partial.operations.pop(); assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![partial], - }), + ValidatedPolicy::new(descriptor(vec![partial])), Err(PolicyError::IncompleteBuffer) ); @@ -1099,9 +1395,7 @@ mod tests { lane: 0, }; assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![duplicate], - }), + ValidatedPolicy::new(descriptor(vec![duplicate])), Err(PolicyError::DuplicateStore) ); @@ -1112,9 +1406,7 @@ mod tests { lane: 2, }; assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![out_of_range], - }), + ValidatedPolicy::new(descriptor(vec![out_of_range])), Err(PolicyError::InvalidStoreLane) ); } @@ -1125,18 +1417,14 @@ mod tests { let mut same_variant = valid_program(); same_variant.id = ProgramId(2); assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![first.clone(), same_variant], - }), + ValidatedPolicy::new(descriptor(vec![first.clone(), same_variant])), Err(PolicyError::DuplicateTechniqueVariant) ); let mut duplicate_id = valid_program(); duplicate_id.technique = TechniqueId(2); assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![first, duplicate_id], - }), + ValidatedPolicy::new(descriptor(vec![first, duplicate_id])), Err(PolicyError::DuplicateProgramId) ); } @@ -1147,23 +1435,94 @@ mod tests { let mut second = valid_program(); second.variant = 1; second.id = ProgramId(2); - let policy = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![first, second], - }) - .unwrap(); + let policy = ValidatedPolicy::new(descriptor(vec![first, second])).unwrap(); assert_eq!(policy.programs().len(), 2); } #[test] - fn scalar_executor_writes_a_bounded_record_range_without_touching_spares() { + fn capability_sets_select_exact_programs_and_reject_invalid_costs() { + let mut webgpu = valid_capability_set(); + webgpu.id = CapabilitySetId(1); + webgpu.flags = CAP_ORDERED_DIRECT; + let mut webgl = valid_capability_set(); + webgl.id = CapabilitySetId(2); + webgl.flags = CAP_STABLE_INDIRECT; + + let mut direct = valid_program(); + direct.capability_set = webgpu.id; + let mut indirect = valid_program(); + indirect.id = ProgramId(2); + indirect.capability_set = webgl.id; + indirect.allocation_strategy = ALLOCATION_STABLE_INDIRECT; let policy = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![valid_program()], + capability_sets: vec![webgpu, webgl], + programs: vec![direct, indirect], }) .unwrap(); + assert_eq!( + policy.program(CapabilitySetId(1), BITMAP, 0).unwrap().id, + ProgramId(1) + ); + assert_eq!( + policy.program(CapabilitySetId(2), BITMAP, 0).unwrap().id, + ProgramId(2) + ); + assert_eq!(policy.program(CapabilitySetId(3), BITMAP, 0), None); + + let mut invalid_cost = valid_capability_set(); + invalid_cost.whole_buffer_threshold_basis_points = 10_001; + assert_eq!( + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![invalid_cost], + programs: vec![valid_program()], + }), + Err(PolicyError::InvalidUploadCostModel) + ); + } + + #[test] + fn executor_honors_policy_stride_without_touching_padding() { + let mut program = valid_program(); + program.buffers[0].alignment = 16; + program.buffers[0].stride = 16; + let policy = ValidatedPolicy::new(descriptor(vec![program])).unwrap(); + let x = [1.0, 2.0]; + let y = [3.0, 4.0]; + let fields: [&[f32]; 2] = [&x, &y]; + let mut bytes = [0xa5_u8; 32]; + let mut outputs = [PhysicalBufferMut { + schema: policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0], + bytes: &mut bytes, + }]; + policy + .execute( + CAPABILITY, + BITMAP, + 0, + SemanticInputBatch { + f32_fields: &fields, + u32_fields: &[], + record_count: 2, + }, + 0, + &mut outputs, + ) + .unwrap(); + assert_eq!(read_f32(&bytes, 0), 1.0); + assert_eq!(read_f32(&bytes, 4), 3.0); + assert_eq!(&bytes[8..16], &[0xa5; 8]); + assert_eq!(read_f32(&bytes, 16), 2.0); + assert_eq!(read_f32(&bytes, 20), 4.0); + assert_eq!(&bytes[24..32], &[0xa5; 8]); + } + + #[test] + fn scalar_executor_writes_a_bounded_record_range_without_touching_spares() { + let policy = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); let x = [1.25, -2.5, 8.0]; let y = [4.0, 6.5, -9.0]; let fields: [&[f32]; 2] = [&x, &y]; - let schema = policy.program(BITMAP, 0).unwrap().buffers[0]; + let schema = policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0]; let mut bytes = [0x7f_u8; 5 * 8]; { let mut outputs = [PhysicalBufferMut { @@ -1172,6 +1531,7 @@ mod tests { }]; policy .execute( + CAPABILITY, BITMAP, 0, SemanticInputBatch { @@ -1198,100 +1558,109 @@ mod tests { let color = BufferId(1); let object = BufferId(2); let page = BufferId(3); - let policy = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![ProgramDescriptor { - technique: BITMAP, - variant: 0, - id: PROGRAM, - f32_input_count: 1, - u32_input_count: 1, - capabilities: ProgramCapabilities::default(), - buffers: vec![ - BufferSchema { - id: color, - scalar: ScalarType::F32, - vector_width: 2, - }, - BufferSchema { - id: object, - scalar: ScalarType::U32, - vector_width: 1, - }, - BufferSchema { - id: page, - scalar: ScalarType::U16, - vector_width: 1, - }, - ], - operations: vec![ - Operation::LoadF32 { - target: 0, - field: 0, - }, - Operation::ConstantF32 { - target: 1, - bits: 0.0_f32.to_bits(), - }, - Operation::LessThanF32 { - target: 2, - left: 0, - right: 1, - }, - Operation::ConstantF32 { - target: 3, - bits: (-1.0_f32).to_bits(), - }, - Operation::SelectF32 { - target: 4, - condition: 2, - when_true: 3, - when_false: 0, - }, - Operation::LoadU32 { - target: 5, - field: 0, - }, - Operation::ConvertU32ToF32 { - target: 6, - source: 5, - }, - Operation::AddF32 { - target: 7, - left: 4, - right: 6, - }, - Operation::ConstantF32 { - target: 8, - bits: 2.0_f32.to_bits(), - }, - Operation::MultiplyF32 { - target: 9, - left: 0, - right: 8, - }, - Operation::StoreF32 { - source: 7, - buffer: color, - lane: 0, - }, - Operation::StoreF32 { - source: 9, - buffer: color, - lane: 1, - }, - Operation::StoreU32 { - source: 5, - buffer: object, - lane: 0, - }, - Operation::StoreU16 { - source: 5, - buffer: page, - lane: 0, - }, - ], - }], - }) + let policy = ValidatedPolicy::new(descriptor(vec![ProgramDescriptor { + technique: BITMAP, + variant: 0, + id: PROGRAM, + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 1, + u32_input_count: 1, + capabilities: ProgramCapabilities::default(), + buffers: vec![ + BufferSchema::packed( + color, + ScalarType::F32, + 2, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + BufferSchema::packed( + object, + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + BufferSchema::packed( + page, + ScalarType::U16, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ), + ], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::ConstantF32 { + target: 1, + bits: 0.0_f32.to_bits(), + }, + Operation::LessThanF32 { + target: 2, + left: 0, + right: 1, + }, + Operation::ConstantF32 { + target: 3, + bits: (-1.0_f32).to_bits(), + }, + Operation::SelectF32 { + target: 4, + condition: 2, + when_true: 3, + when_false: 0, + }, + Operation::LoadU32 { + target: 5, + field: 0, + }, + Operation::ConvertU32ToF32 { + target: 6, + source: 5, + }, + Operation::AddF32 { + target: 7, + left: 4, + right: 6, + }, + Operation::ConstantF32 { + target: 8, + bits: 2.0_f32.to_bits(), + }, + Operation::MultiplyF32 { + target: 9, + left: 0, + right: 8, + }, + Operation::StoreF32 { + source: 7, + buffer: color, + lane: 0, + }, + Operation::StoreF32 { + source: 9, + buffer: color, + lane: 1, + }, + Operation::StoreU32 { + source: 5, + buffer: object, + lane: 0, + }, + Operation::StoreU16 { + source: 5, + buffer: page, + lane: 0, + }, + ], + }])) .unwrap(); let signed = [-2.0, 3.0]; let identifiers = [70_000, 42]; @@ -1300,7 +1669,7 @@ mod tests { let mut colors = [0_u8; 16]; let mut objects = [0_u8; 8]; let mut pages = [0_u8; 4]; - let program = policy.program(BITMAP, 0).unwrap(); + let program = policy.program(CAPABILITY, BITMAP, 0).unwrap(); let mut outputs = [ PhysicalBufferMut { schema: program.buffers[0], @@ -1317,6 +1686,7 @@ mod tests { ]; policy .execute( + CAPABILITY, BITMAP, 0, SemanticInputBatch { @@ -1340,20 +1710,18 @@ mod tests { #[test] fn scalar_executor_rejects_invalid_shapes_before_writing() { - let policy = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![valid_program()], - }) - .unwrap(); + let policy = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); let x = [1.0, 2.0]; let short_y = [3.0]; let fields: [&[f32]; 2] = [&x, &short_y]; let mut bytes = [0xa5_u8; 16]; let mut outputs = [PhysicalBufferMut { - schema: policy.program(BITMAP, 0).unwrap().buffers[0], + schema: policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0], bytes: &mut bytes, }]; assert_eq!( policy.execute( + CAPABILITY, BITMAP, 0, SemanticInputBatch { @@ -1377,9 +1745,7 @@ mod tests { bits: f32::INFINITY.to_bits(), }; assert_eq!( - ValidatedPolicy::new(PolicyDescriptor { - programs: vec![constant], - }), + ValidatedPolicy::new(descriptor(vec![constant])), Err(PolicyError::NonFiniteConstant) ); @@ -1392,19 +1758,17 @@ mod tests { right: 1, }, ); - let policy = ValidatedPolicy::new(PolicyDescriptor { - programs: vec![overflow], - }) - .unwrap(); + let policy = ValidatedPolicy::new(descriptor(vec![overflow])).unwrap(); let values = [f32::MAX]; let fields: [&[f32]; 2] = [&values, &values]; let mut bytes = [0x5a_u8; 8]; let mut outputs = [PhysicalBufferMut { - schema: policy.program(BITMAP, 0).unwrap().buffers[0], + schema: policy.program(CAPABILITY, BITMAP, 0).unwrap().buffers[0], bytes: &mut bytes, }]; assert_eq!( policy.execute( + CAPABILITY, BITMAP, 0, SemanticInputBatch { diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index d27ea710..cbd068eb 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -2,7 +2,7 @@ use alloc::collections::BTreeMap; use super::{ frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, - policy::ValidatedPolicy, + policy::{CapabilitySetId, ValidatedPolicy}, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -103,7 +103,13 @@ impl TextEngine { .sessions .get(&request.session_id) .ok_or(EngineError::SessionMissing)?; - self.policy(request.policy_handle)?; + let policy = self.policy(request.policy_handle)?; + if policy + .capability_set(CapabilitySetId(request.capability_set)) + .is_none() + { + return Err(EngineError::InvalidRequest); + } if request.expected_engine_revision != session.revision.engine || request.consumed_plan_revision > session.revision.plan { @@ -157,8 +163,10 @@ impl TextEngine { mod tests { use super::*; use crate::engine::policy::{ - BufferId, BufferSchema, Operation, PolicyDescriptor, ProgramCapabilities, - ProgramDescriptor, ProgramId, ScalarType, TechniqueId, + ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CAP_ORDERED_DIRECT, + CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, + ProgramId, ScalarType, TechniqueId, }; use alloc::vec; @@ -226,20 +234,59 @@ mod tests { assert_eq!(engine.dispose_session(4), Err(EngineError::SessionMissing)); } + #[test] + fn update_rejects_a_capability_set_outside_the_registered_policy() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let mut request = update(0, 0); + request.capability_set = 2; + assert_eq!( + engine.prepare_update(request), + Err(EngineError::InvalidRequest) + ); + assert_eq!( + engine.session_revision(4).unwrap(), + SessionRevision::default() + ); + } + fn validated_policy(technique: TechniqueId) -> ValidatedPolicy { ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![CapabilitySet { + id: CapabilitySetId(1), + flags: CAP_ORDERED_DIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 1, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 1, + whole_buffer_threshold_basis_points: 10_000, + }], programs: vec![ProgramDescriptor { technique, variant: 0, id: ProgramId(1), + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, capabilities: ProgramCapabilities::default(), - buffers: vec![BufferSchema { - id: BufferId(1), - scalar: ScalarType::F32, - vector_width: 1, - }], + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], operations: vec![ Operation::LoadF32 { target: 0, @@ -262,6 +309,7 @@ mod tests { expected_engine_revision, consumed_plan_revision, policy_handle: 9, + capability_set: 1, limits: super::super::frame::UpdateLimits { max_clusters: 1, max_lines: 1, diff --git a/packages/text/rust/shaper/src/engine/wire.rs b/packages/text/rust/shaper/src/engine/wire.rs index 1a6eb76b..c5cf770c 100644 --- a/packages/text/rust/shaper/src/engine/wire.rs +++ b/packages/text/rust/shaper/src/engine/wire.rs @@ -9,26 +9,40 @@ use alloc::vec::Vec; use crate::{ STATUS_INVALID_REQUEST, abi_contract::{ - POLICY_BUFFER_COUNT, POLICY_BUFFER_ID, POLICY_BUFFER_RECORD_ALIGNMENT, - POLICY_BUFFER_RECORD_SIZE, POLICY_BUFFER_SCALAR, POLICY_BUFFER_VECTOR_WIDTH, - POLICY_BUFFERS_OFFSET, POLICY_BYTE_LENGTH, POLICY_OPERATION_COUNT, - POLICY_OPERATION_IMMEDIATE0, POLICY_OPERATION_IMMEDIATE1, POLICY_OPERATION_IMMEDIATE2, - POLICY_OPERATION_OPCODE, POLICY_OPERATION_OPERAND0, POLICY_OPERATION_OPERAND1, - POLICY_OPERATION_RECORD_ALIGNMENT, POLICY_OPERATION_RECORD_SIZE, POLICY_OPERATION_TARGET, - POLICY_OPERATIONS_OFFSET, POLICY_PROGRAM_BUFFER_COUNT, POLICY_PROGRAM_BUFFER_START, - POLICY_PROGRAM_COMPOSITING_CAPABILITIES, POLICY_PROGRAM_COUNT, - POLICY_PROGRAM_F32_INPUT_COUNT, POLICY_PROGRAM_ID, POLICY_PROGRAM_OPERATION_COUNT, - POLICY_PROGRAM_OPERATION_START, POLICY_PROGRAM_PAINT_CAPABILITIES, - POLICY_PROGRAM_RECORD_ALIGNMENT, POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, - POLICY_PROGRAM_RESERVED1, POLICY_PROGRAM_TECHNIQUE_ID, POLICY_PROGRAM_U32_INPUT_COUNT, - POLICY_PROGRAM_VARIANT, POLICY_PROGRAMS_OFFSET, POLICY_REQUEST_HEADER_SIZE, + POLICY_BUFFER_ALIGNMENT, POLICY_BUFFER_CAPACITY_CLASS, POLICY_BUFFER_COUNT, + POLICY_BUFFER_ID, POLICY_BUFFER_RECORD_ALIGNMENT, POLICY_BUFFER_RECORD_SIZE, + POLICY_BUFFER_RESERVED0, POLICY_BUFFER_SCALAR, POLICY_BUFFER_STRIDE, POLICY_BUFFER_USAGE, + POLICY_BUFFER_VECTOR_WIDTH, POLICY_BUFFERS_OFFSET, POLICY_BYTE_LENGTH, + POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES, POLICY_CAPABILITY_SET_COUNT, + POLICY_CAPABILITY_SET_FLAGS, POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, + POLICY_CAPABILITY_SET_ID, POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, + POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS, + POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, + POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + POLICY_CAPABILITY_SET_RECORD_SIZE, POLICY_CAPABILITY_SET_RESERVED, + POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, POLICY_CAPABILITY_SETS_OFFSET, + POLICY_OPERATION_COUNT, POLICY_OPERATION_IMMEDIATE0, POLICY_OPERATION_IMMEDIATE1, + POLICY_OPERATION_IMMEDIATE2, POLICY_OPERATION_OPCODE, POLICY_OPERATION_OPERAND0, + POLICY_OPERATION_OPERAND1, POLICY_OPERATION_RECORD_ALIGNMENT, POLICY_OPERATION_RECORD_SIZE, + POLICY_OPERATION_TARGET, POLICY_OPERATIONS_OFFSET, POLICY_PROGRAM_ALLOCATION_STRATEGY, + POLICY_PROGRAM_BATCH_KEY_MASK, POLICY_PROGRAM_BUFFER_COUNT, POLICY_PROGRAM_BUFFER_START, + POLICY_PROGRAM_CAPABILITY_SET_ID, POLICY_PROGRAM_COMPOSITING_CAPABILITIES, + POLICY_PROGRAM_COUNT, POLICY_PROGRAM_F32_INPUT_COUNT, POLICY_PROGRAM_ID, + POLICY_PROGRAM_OPERATION_COUNT, POLICY_PROGRAM_OPERATION_START, + POLICY_PROGRAM_PAINT_CAPABILITIES, POLICY_PROGRAM_RECORD_ALIGNMENT, + POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, POLICY_PROGRAM_RESERVED1, + POLICY_PROGRAM_RESOURCE_KIND_MASK, POLICY_PROGRAM_SEMANTIC_VIEW_MASK, + POLICY_PROGRAM_TECHNIQUE_ID, POLICY_PROGRAM_U32_INPUT_COUNT, POLICY_PROGRAM_VARIANT, + POLICY_PROGRAMS_OFFSET, POLICY_REQUEST_HEADER_SIZE, }, engine::policy::{ - BufferId, BufferSchema, MAX_BUFFERS_PER_PROGRAM, MAX_OPERATIONS_PER_PROGRAM, MAX_PROGRAMS, - OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, - OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, - OP_STORE_U32, OP_SUBTRACT_F32, Operation, PolicyDescriptor, ProgramCapabilities, - ProgramDescriptor, ProgramId, ScalarType, TechniqueId, ValidatedPolicy, + BufferId, BufferSchema, CapabilitySet, CapabilitySetId, MAX_BUFFERS_PER_PROGRAM, + MAX_CAPABILITY_SETS, MAX_OPERATIONS_PER_PROGRAM, MAX_PROGRAMS, OP_ADD_F32, OP_CONSTANT_F32, + OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, + OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, + Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, + TechniqueId, ValidatedPolicy, }, wire::{array, read_u16, read_u32}, }; @@ -41,10 +55,13 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { return Err(STATUS_INVALID_REQUEST); } + let capability_set_count = read_u32(bytes, POLICY_CAPABILITY_SET_COUNT)?; let program_count = read_u32(bytes, POLICY_PROGRAM_COUNT)?; let buffer_count = read_u32(bytes, POLICY_BUFFER_COUNT)?; let operation_count = read_u32(bytes, POLICY_OPERATION_COUNT)?; - if usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_PROGRAMS + if usize::try_from(capability_set_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_CAPABILITY_SETS + || usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_PROGRAMS || usize::try_from(buffer_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_PROGRAMS * MAX_BUFFERS_PER_PROGRAM || usize::try_from(operation_count).map_err(|_| STATUS_INVALID_REQUEST)? @@ -53,6 +70,13 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { return Err(STATUS_INVALID_REQUEST); } + let capability_sets = table( + bytes, + read_u32(bytes, POLICY_CAPABILITY_SETS_OFFSET)?, + capability_set_count, + POLICY_CAPABILITY_SET_RECORD_SIZE, + POLICY_CAPABILITY_SET_RECORD_ALIGNMENT, + )?; let programs = table( bytes, read_u32(bytes, POLICY_PROGRAMS_OFFSET)?, @@ -76,7 +100,9 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { POLICY_OPERATION_RECORD_SIZE, POLICY_OPERATION_RECORD_ALIGNMENT, )?; - reject_overlaps(bytes, programs, buffers, operations)?; + reject_overlaps(bytes, capability_sets, programs, buffers, operations)?; + + let capability_sets = decode_capability_sets(capability_sets)?; let mut decoded = Vec::new(); decoded @@ -84,7 +110,7 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { .map_err(|_| STATUS_INVALID_REQUEST)?; for record in programs.chunks_exact(POLICY_PROGRAM_RECORD_SIZE as usize) { if read_u16(record, POLICY_PROGRAM_RESERVED0)? != 0 - || read_u16(record, POLICY_PROGRAM_RESERVED1)? != 0 + || read_u32(record, POLICY_PROGRAM_RESERVED1)? != 0 { return Err(STATUS_INVALID_REQUEST); } @@ -104,6 +130,11 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { technique: TechniqueId(read_u32(record, POLICY_PROGRAM_TECHNIQUE_ID)?), variant: read_u16(record, POLICY_PROGRAM_VARIANT)?, id: ProgramId(read_u32(record, POLICY_PROGRAM_ID)?), + capability_set: CapabilitySetId(read_u32(record, POLICY_PROGRAM_CAPABILITY_SET_ID)?), + resource_kind_mask: read_u32(record, POLICY_PROGRAM_RESOURCE_KIND_MASK)?, + semantic_view_mask: read_u32(record, POLICY_PROGRAM_SEMANTIC_VIEW_MASK)?, + batch_key_mask: read_u32(record, POLICY_PROGRAM_BATCH_KEY_MASK)?, + allocation_strategy: read_u16(record, POLICY_PROGRAM_ALLOCATION_STRATEGY)?, f32_input_count: byte(record, POLICY_PROGRAM_F32_INPUT_COUNT)?, u32_input_count: byte(record, POLICY_PROGRAM_U32_INPUT_COUNT)?, capabilities: ProgramCapabilities { @@ -114,7 +145,11 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { operations: decode_operations(selected_operations)?, }); } - ValidatedPolicy::new(PolicyDescriptor { programs: decoded }).map_err(|_| STATUS_INVALID_REQUEST) + ValidatedPolicy::new(PolicyDescriptor { + capability_sets, + programs: decoded, + }) + .map_err(|_| STATUS_INVALID_REQUEST) } fn table(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { @@ -126,11 +161,13 @@ fn table(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> fn reject_overlaps( bytes: &[u8], + capability_sets: &[u8], programs: &[u8], buffers: &[u8], operations: &[u8], ) -> Result<(), u32> { let ranges = [ + relative_range(bytes, capability_sets)?, relative_range(bytes, programs)?, relative_range(bytes, buffers)?, relative_range(bytes, operations)?, @@ -146,6 +183,41 @@ fn reject_overlaps( Ok(()) } +fn decode_capability_sets(records: &[u8]) -> Result, u32> { + let mut capability_sets = Vec::new(); + capability_sets + .try_reserve_exact(records.len() / POLICY_CAPABILITY_SET_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_CAPABILITY_SET_RECORD_SIZE as usize) { + if read_u16(record, POLICY_CAPABILITY_SET_RESERVED)? != 0 + || read_u16(record, POLICY_CAPABILITY_SET_RESERVED + 2)? != 0 + || read_u16(record, POLICY_CAPABILITY_SET_RESERVED + 4)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + capability_sets.push(CapabilitySet { + id: CapabilitySetId(read_u32(record, POLICY_CAPABILITY_SET_ID)?), + flags: read_u32(record, POLICY_CAPABILITY_SET_FLAGS)?, + max_buffer_bytes: read_u32(record, POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES)?, + update_alignment: read_u32(record, POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT)?, + coalesce_gap_bytes: read_u32(record, POLICY_CAPABILITY_SET_COALESCE_GAP_BYTES)?, + range_call_penalty_bytes: read_u32( + record, + POLICY_CAPABILITY_SET_RANGE_CALL_PENALTY_BYTES, + )?, + max_buffers_per_draw: read_u16(record, POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW)?, + max_resources_per_draw: read_u16(record, POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW)?, + max_indirect_draws: read_u16(record, POLICY_CAPABILITY_SET_MAX_INDIRECT_DRAWS)?, + fragmentation_budget: read_u16(record, POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET)?, + whole_buffer_threshold_basis_points: read_u16( + record, + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + )?, + }); + } + Ok(capability_sets) +} + #[derive(Clone, Copy)] struct ByteRange { start: usize, @@ -175,6 +247,9 @@ fn decode_buffers(records: &[u8]) -> Result, u32> { .try_reserve_exact(records.len() / POLICY_BUFFER_RECORD_SIZE as usize) .map_err(|_| STATUS_INVALID_REQUEST)?; for record in records.chunks_exact(POLICY_BUFFER_RECORD_SIZE as usize) { + if read_u16(record, POLICY_BUFFER_RESERVED0)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } let scalar = match byte(record, POLICY_BUFFER_SCALAR)? { value if value == ScalarType::F32 as u8 => ScalarType::F32, value if value == ScalarType::U32 as u8 => ScalarType::U32, @@ -185,6 +260,10 @@ fn decode_buffers(records: &[u8]) -> Result, u32> { id: BufferId(read_u16(record, POLICY_BUFFER_ID)?), scalar, vector_width: byte(record, POLICY_BUFFER_VECTOR_WIDTH)?, + alignment: read_u16(record, POLICY_BUFFER_ALIGNMENT)?, + stride: read_u16(record, POLICY_BUFFER_STRIDE)?, + usage: read_u32(record, POLICY_BUFFER_USAGE)?, + capacity_class: read_u16(record, POLICY_BUFFER_CAPACITY_CLASS)?, }); } Ok(buffers) @@ -330,7 +409,9 @@ mod tests { use super::*; use alloc::vec; - const PROGRAMS_OFFSET: usize = POLICY_REQUEST_HEADER_SIZE as usize; + const CAPABILITY_SETS_OFFSET: usize = POLICY_REQUEST_HEADER_SIZE as usize; + const PROGRAMS_OFFSET: usize = + CAPABILITY_SETS_OFFSET + POLICY_CAPABILITY_SET_RECORD_SIZE as usize; const BUFFERS_OFFSET: usize = PROGRAMS_OFFSET + POLICY_PROGRAM_RECORD_SIZE as usize; const OPERATIONS_OFFSET: usize = BUFFERS_OFFSET + POLICY_BUFFER_RECORD_SIZE as usize; const OPERATION_COUNT: usize = 4; @@ -341,7 +422,9 @@ mod tests { fn decodes_compiler_mapped_policy_records() { let bytes = valid_policy_bytes(); let policy = parse_policy(&bytes).unwrap(); - let program = policy.program(TechniqueId(1), 0).unwrap(); + let program = policy + .program(CapabilitySetId(1), TechniqueId(1), 0) + .unwrap(); assert_eq!(program.id, ProgramId(2)); assert_eq!(program.buffers[0].stride(), 8); assert_eq!(program.operations.len(), OPERATION_COUNT); @@ -384,6 +467,12 @@ mod tests { fn valid_policy_bytes() -> Vec { let mut bytes = vec![0; BYTE_LENGTH]; put_u32(&mut bytes, POLICY_BYTE_LENGTH, BYTE_LENGTH as u32); + put_u32( + &mut bytes, + POLICY_CAPABILITY_SETS_OFFSET, + CAPABILITY_SETS_OFFSET as u32, + ); + put_u32(&mut bytes, POLICY_CAPABILITY_SET_COUNT, 1); put_u32(&mut bytes, POLICY_PROGRAMS_OFFSET, PROGRAMS_OFFSET as u32); put_u32(&mut bytes, POLICY_PROGRAM_COUNT, 1); put_u32(&mut bytes, POLICY_BUFFERS_OFFSET, BUFFERS_OFFSET as u32); @@ -395,9 +484,38 @@ mod tests { ); put_u32(&mut bytes, POLICY_OPERATION_COUNT, OPERATION_COUNT as u32); + let capability = &mut bytes[CAPABILITY_SETS_OFFSET..PROGRAMS_OFFSET]; + put_u32(capability, POLICY_CAPABILITY_SET_ID, 1); + put_u32( + capability, + POLICY_CAPABILITY_SET_FLAGS, + crate::engine::policy::CAP_ORDERED_DIRECT, + ); + put_u32(capability, POLICY_CAPABILITY_SET_MAX_BUFFER_BYTES, 1024); + put_u32(capability, POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, 4); + put_u16(capability, POLICY_CAPABILITY_SET_MAX_BUFFERS_PER_DRAW, 1); + put_u16(capability, POLICY_CAPABILITY_SET_MAX_RESOURCES_PER_DRAW, 1); + put_u16(capability, POLICY_CAPABILITY_SET_FRAGMENTATION_BUDGET, 1); + put_u16( + capability, + POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, + 10_000, + ); + let program = &mut bytes[PROGRAMS_OFFSET..BUFFERS_OFFSET]; put_u32(program, POLICY_PROGRAM_TECHNIQUE_ID, 1); put_u32(program, POLICY_PROGRAM_ID, 2); + put_u32(program, POLICY_PROGRAM_RESOURCE_KIND_MASK, 1); + put_u32( + program, + POLICY_PROGRAM_BATCH_KEY_MASK, + crate::engine::policy::BATCH_PROGRAM, + ); + put_u16( + program, + POLICY_PROGRAM_ALLOCATION_STRATEGY, + crate::engine::policy::ALLOCATION_ORDERED_DIRECT, + ); program[POLICY_PROGRAM_F32_INPUT_COUNT] = 2; put_u16(program, POLICY_PROGRAM_BUFFER_COUNT, 1); put_u16( @@ -410,6 +528,14 @@ mod tests { put_u16(buffer, POLICY_BUFFER_ID, 1); buffer[POLICY_BUFFER_SCALAR] = ScalarType::F32 as u8; buffer[POLICY_BUFFER_VECTOR_WIDTH] = 2; + put_u16(buffer, POLICY_BUFFER_ALIGNMENT, 4); + put_u16(buffer, POLICY_BUFFER_STRIDE, 8); + put_u32( + buffer, + POLICY_BUFFER_USAGE, + crate::engine::policy::BUFFER_USAGE_COPY_DST, + ); + put_u16(buffer, POLICY_BUFFER_CAPACITY_CLASS, 1); write_operation(&mut bytes, 0, OP_LOAD_F32, 0, 0, 0, 0); write_operation(&mut bytes, 1, OP_LOAD_F32, 1, 1, 0, 0); diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 9900643e..58bbe817 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -407,7 +407,7 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( }; let plan = RenderPlanView { policy_handle: request.policy_handle, - capability_set: 0, + capability_set: request.capability_set, policy_fingerprint, ..RenderPlanView::default() }; diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 451a4055..e2eaf8aa 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -324,12 +324,32 @@ export const textShaperAbi = { "value": 4 }, "policyBuffer": { - "alignment": 2, + "alignment": 4, + "capacityClass": 12, "id": 0, + "reserved0": 14, "scalar": 2, - "size": 4, + "size": 16, + "stride": 6, + "usage": 8, "vectorWidth": 3 }, + "policyCapabilitySet": { + "alignment": 4, + "coalesceGapBytes": 16, + "flags": 4, + "fragmentationBudget": 30, + "id": 0, + "maxBufferBytes": 8, + "maxBuffersPerDraw": 24, + "maxIndirectDraws": 28, + "maxResourcesPerDraw": 26, + "rangeCallPenaltyBytes": 20, + "reserved": 34, + "size": 40, + "updateAlignment": 12, + "wholeBufferThresholdBasisPoints": 32 + }, "policyOperation": { "alignment": 4, "immediate0": 4, @@ -343,31 +363,38 @@ export const textShaperAbi = { }, "policyProgram": { "alignment": 4, - "bufferCount": 24, - "bufferStart": 20, - "compositingCapabilities": 16, - "f32InputCount": 10, - "operationCount": 32, - "operationStart": 28, - "paintCapabilities": 12, + "allocationStrategy": 46, + "batchKeyMask": 20, + "bufferCount": 42, + "bufferStart": 32, + "capabilitySetId": 8, + "compositingCapabilities": 28, + "f32InputCount": 48, + "operationCount": 44, + "operationStart": 36, + "paintCapabilities": 24, "programId": 4, - "reserved0": 26, - "reserved1": 34, - "size": 36, + "reserved0": 50, + "reserved1": 52, + "resourceKindMask": 12, + "semanticViewMask": 16, + "size": 56, "techniqueId": 0, - "u32InputCount": 11, - "variant": 8 + "u32InputCount": 49, + "variant": 40 }, "policyRequest": { "alignment": 4, - "bufferCount": 16, - "buffersOffset": 12, + "bufferCount": 24, + "buffersOffset": 20, "byteLength": 0, - "operationCount": 24, - "operationsOffset": 20, - "programCount": 8, - "programsOffset": 4, - "size": 28 + "capabilitySetCount": 8, + "capabilitySetsOffset": 4, + "operationCount": 32, + "operationsOffset": 28, + "programCount": 16, + "programsOffset": 12, + "size": 36 }, "reshapeRange": { "alignment": 4, @@ -435,6 +462,32 @@ export const textShaperAbi = { "name": "pmndrs-text-shaper", "pointerWidth": 32, "policy": { + "allocationStrategies": { + "orderedDirect": 1, + "stableIndirect": 2 + }, + "batchFields": { + "clip": 16, + "depth": 32, + "material": 8, + "order": 64, + "program": 4, + "resource": 2, + "technique": 1 + }, + "bufferUsage": { + "copyDst": 4, + "storage": 2, + "vertex": 1 + }, + "capabilityFlags": { + "aliasVec2": 4, + "aliasVec4": 8, + "indirectDraws": 2, + "orderedDirect": 16, + "stableIndirect": 32, + "storageBuffers": 1 + }, "opcodes": { "addF32": 5, "constantF32": 3, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index bbdcb700..7a4c5a2a 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -147,7 +147,7 @@ function assertResult(memory, pointer, abi, expected) { } if (expected.status === abi.status.ok) { assert.equal(view.getUint32(layout.policyHandle, true), policyHandle); - assert.equal(view.getUint32(layout.capabilitySet, true), 0); + assert.equal(view.getUint32(layout.capabilitySet, true), 1); assert.notEqual( view.getUint32(layout.policyFingerprintLow, true) | view.getUint32(layout.policyFingerprintHigh, true), 0, diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index ee8242ed..718fab65 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -60,6 +60,7 @@ export function engineUpdateBytes(abi, { sessionId, policyHandle, expectedEngine view.setUint32(layout.expectedEngineRevision, expectedEngineRevision, true); view.setUint32(layout.consumedPlanRevision, consumedPlanRevision, true); view.setUint32(layout.policyHandle, policyHandle, true); + view.setUint32(layout.capabilitySet, 1, true); for (const field of [ 'maxClusters', 'maxLines', @@ -87,17 +88,42 @@ function align(value, alignment) { function policyBytes(abi, programs) { const requestLayout = abi.layouts.policyRequest; + const capabilityLayout = abi.layouts.policyCapabilitySet; const programLayout = abi.layouts.policyProgram; const bufferLayout = abi.layouts.policyBuffer; const operationLayout = abi.layouts.policyOperation; const bufferCount = programs.reduce((total, program) => total + program.buffers.length, 0); const operationCount = programs.reduce((total, program) => total + program.operations.length, 0); - const programsOffset = align(requestLayout.size, programLayout.alignment); + const capabilities = [ + { + id: 1, + flags: + abi.policy.capabilityFlags.storageBuffers | + abi.policy.capabilityFlags.orderedDirect | + abi.policy.capabilityFlags.stableIndirect, + maxBufferBytes: 64 * 1024 * 1024, + updateAlignment: 4, + coalesceGapBytes: 128, + rangeCallPenaltyBytes: 256, + maxBuffersPerDraw: 16, + maxResourcesPerDraw: 16, + maxIndirectDraws: 0, + fragmentationBudget: 8, + wholeBufferThresholdBasisPoints: 7_500, + }, + ]; + const capabilitiesOffset = align(requestLayout.size, capabilityLayout.alignment); + const programsOffset = align( + capabilitiesOffset + capabilityLayout.size * capabilities.length, + programLayout.alignment, + ); const buffersOffset = align(programsOffset + programLayout.size * programs.length, bufferLayout.alignment); const operationsOffset = align(buffersOffset + bufferLayout.size * bufferCount, operationLayout.alignment); const bytes = new Uint8Array(operationsOffset + operationLayout.size * operationCount); const view = new DataView(bytes.buffer); view.setUint32(requestLayout.byteLength, bytes.byteLength, true); + view.setUint32(requestLayout.capabilitySetsOffset, capabilitiesOffset, true); + view.setUint32(requestLayout.capabilitySetCount, capabilities.length, true); view.setUint32(requestLayout.programsOffset, programsOffset, true); view.setUint32(requestLayout.programCount, programs.length, true); view.setUint32(requestLayout.buffersOffset, buffersOffset, true); @@ -105,6 +131,26 @@ function policyBytes(abi, programs) { view.setUint32(requestLayout.operationsOffset, operationsOffset, true); view.setUint32(requestLayout.operationCount, operationCount, true); + for (let index = 0; index < capabilities.length; index += 1) { + const descriptor = capabilities[index]; + const offset = capabilitiesOffset + index * capabilityLayout.size; + view.setUint32(offset + capabilityLayout.id, descriptor.id, true); + view.setUint32(offset + capabilityLayout.flags, descriptor.flags, true); + view.setUint32(offset + capabilityLayout.maxBufferBytes, descriptor.maxBufferBytes, true); + view.setUint32(offset + capabilityLayout.updateAlignment, descriptor.updateAlignment, true); + view.setUint32(offset + capabilityLayout.coalesceGapBytes, descriptor.coalesceGapBytes, true); + view.setUint32(offset + capabilityLayout.rangeCallPenaltyBytes, descriptor.rangeCallPenaltyBytes, true); + view.setUint16(offset + capabilityLayout.maxBuffersPerDraw, descriptor.maxBuffersPerDraw, true); + view.setUint16(offset + capabilityLayout.maxResourcesPerDraw, descriptor.maxResourcesPerDraw, true); + view.setUint16(offset + capabilityLayout.maxIndirectDraws, descriptor.maxIndirectDraws, true); + view.setUint16(offset + capabilityLayout.fragmentationBudget, descriptor.fragmentationBudget, true); + view.setUint16( + offset + capabilityLayout.wholeBufferThresholdBasisPoints, + descriptor.wholeBufferThresholdBasisPoints, + true, + ); + } + let bufferStart = 0; let operationStart = 0; for (let index = 0; index < programs.length; index += 1) { @@ -112,6 +158,15 @@ function policyBytes(abi, programs) { const offset = programsOffset + index * programLayout.size; view.setUint32(offset + programLayout.techniqueId, descriptor.techniqueId, true); view.setUint32(offset + programLayout.programId, descriptor.programId, true); + view.setUint32(offset + programLayout.capabilitySetId, descriptor.capabilitySetId ?? 0, true); + view.setUint32(offset + programLayout.resourceKindMask, descriptor.resourceKindMask ?? 1, true); + view.setUint32(offset + programLayout.semanticViewMask, descriptor.semanticViewMask ?? 0, true); + view.setUint32( + offset + programLayout.batchKeyMask, + descriptor.batchKeyMask ?? + (abi.policy.batchFields.program | abi.policy.batchFields.resource | abi.policy.batchFields.order), + true, + ); view.setUint16(offset + programLayout.variant, descriptor.variant ?? 0, true); view.setUint8(offset + programLayout.f32InputCount, descriptor.f32InputCount); view.setUint8(offset + programLayout.u32InputCount, descriptor.u32InputCount); @@ -121,6 +176,11 @@ function policyBytes(abi, programs) { view.setUint16(offset + programLayout.bufferCount, descriptor.buffers.length, true); view.setUint32(offset + programLayout.operationStart, operationStart, true); view.setUint16(offset + programLayout.operationCount, descriptor.operations.length, true); + view.setUint16( + offset + programLayout.allocationStrategy, + descriptor.allocationStrategy ?? abi.policy.allocationStrategies.orderedDirect, + true, + ); bufferStart += descriptor.buffers.length; operationStart += descriptor.operations.length; } @@ -132,6 +192,15 @@ function policyBytes(abi, programs) { view.setUint16(offset + bufferLayout.id, buffer.id, true); view.setUint8(offset + bufferLayout.scalar, buffer.scalar); view.setUint8(offset + bufferLayout.vectorWidth, buffer.vectorWidth); + const scalarBytes = buffer.scalar === abi.policy.scalarTypes.u16 ? 2 : 4; + view.setUint16(offset + bufferLayout.alignment, buffer.alignment ?? scalarBytes, true); + view.setUint16(offset + bufferLayout.stride, buffer.stride ?? scalarBytes * buffer.vectorWidth, true); + view.setUint32( + offset + bufferLayout.usage, + buffer.usage ?? (abi.policy.bufferUsage.storage | abi.policy.bufferUsage.copyDst), + true, + ); + view.setUint16(offset + bufferLayout.capacityClass, buffer.capacityClass ?? 1, true); bufferIndex += 1; } for (const operation of descriptor.operations) { From 1c6578989eab9fe3ff3fbe845a3ffffe301af56a Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 08:00:58 -0400 Subject: [PATCH 010/128] feat(text): retain ordered render buffers --- docs/log.md | 8 + docs/packages/text.md | 15 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 13 + packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/ordered_plan.rs | 1417 +++++++++++++++++ .../text/rust/shaper/src/engine/policy.rs | 6 + 7 files changed, 1460 insertions(+), 1 deletion(-) create mode 100644 packages/text/rust/shaper/src/engine/ordered_plan.rs diff --git a/docs/log.md b/docs/log.md index 5f797925..c6ac1c3d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Implemented retained ordered-direct physical patches** — Added an abortable native planner that groups glyphs by + policy program/resource and uses stable instance IDs plus semantic revisions, never full-buffer byte comparison, to + select writes. Capability alignment and upload costs coalesce ranges; consecutive changed records retain SIMD policy + execution. Tests prove zero-output no-ops, one-record writes, ordered suffix movement, metadata-only tail deletion, + checkpoint/growth, retirement generations, abort preservation, wire validity, and stable warm scratch capacities. + Primitive/draw compilation, stable-indirect storage, Wasm session wiring, and latency evidence remain open. The + unreachable native slice is LTO-stripped; optimized Wasm is 739,643 raw / 272,537 gzip / 214,149 Brotli bytes. + - **Completed the capability-shaped policy ABI** — Extended the compiler-mapped registration transaction with exact capability-set, program-planning, and physical-buffer metadata: backend limits and upload costs, capability-specific program selection, technique/resource and batch-key masks, ordered-direct versus stable-indirect allocation, and diff --git a/docs/packages/text.md b/docs/packages/text.md index 4dcd9ad6..c69df8a9 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:750181db8d897681d0bbfb490bb164f20ecd209d4ee3d16a88c9b26d14728715' +source_digest: 'sha256:78ef2b495017f313318d824f1f946df614d65a778799ed91aac444441d962e90' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -427,6 +427,19 @@ Node registration/frame tests pass. The optimized SIMD artifact is 739,647 raw / 14,075 / 3,272 / 3,173 bytes above the preceding executor artifact; this is registration/planner metadata, not a warm layout performance result. +The native renderer-neutral core now contains the first retained ordered-direct storage planner. It groups glyphs by +validated technique/program/resource, assigns stable physical buffer identities, and uses stable instance IDs plus +semantic content revisions to select aligned dirty records without comparing buffer bytes. The registered gap/call +cost, fragmentation budget, and whole-buffer threshold coalesce writes; consecutive changed records execute as SIMD +runs, while resource interleaving produces smaller scalar tails only where contiguity is genuinely absent. Preparation +is separately viewable, committable, and abortable: no committed CPU mirror changes before immutable plan serialization. +Tests cover exact first publication, a one-record update, ordered suffix movement, no-op zero output, metadata-only tail +deletion, retirement generations, abort preservation, compiler-wire validation, and unchanged warm scratch capacities. +The slice emits only resource/buffer/patch/retirement tables and is not called by the shipping Wasm update yet; LTO +therefore removes it. The rebuilt optimized SIMD artifact is 739,643 raw / 272,537 gzip / 214,149 Brotli bytes, a delta +of -4 / +5 / -37 bytes from the preceding policy checkpoint. No planner latency claim is attached until the Wasm lab +and primitive/draw compilation make that code reachable. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 870c718e..ac38780c 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -230,6 +230,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | | D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not a backend command buffer or raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (48), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | | D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, batch-key fields, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | +| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. The current slice emits resource/buffer/patch/retirement tables; primitive/draw tables, stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index f6be3364..aff436fe 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -698,6 +698,19 @@ replacement, style edits, and flow changes at the start, middle, and end of larg If `required_base_revision` does not match the consumer, the engine returns a checkpoint containing complete live state. Skipped render revisions can never be repaired by applying an adjacent delta blindly. +The first retained compiler slice implements ordered-direct physical storage behind an explicit prepare/view/commit-or- +abort lifecycle. Stable instance IDs and semantic content revisions—not physical byte comparison—select dirty records. +Capability alignment expands ranges at record granularity; gap/call costs, fragmentation budget, and the whole-buffer +threshold coalesce them. Consecutive changed inputs stay batched through the four-record SIMD policy executor. A no-op +produces no resource, buffer, patch, retirement, or payload records; a tail deletion changes live metadata without an +upload; a middle insertion rewrites only that resource/program batch's suffix. Checkpoint/growth allocates and writes +complete aligned storage. CPU mirrors change only on commit, so failed A/B serialization can abort preparation. + +This slice deliberately does not yet claim a complete display list: primitive/draw compilation, stable-indirect order +storage, session integration, and target-hardware timing remain open in Stage 2. Its production Wasm code is currently +unreachable from `text_update` and is removed by LTO; that keeps the shipping path unchanged while the missing tables +land, rather than treating native unit behavior as end-to-end evidence. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index a5b01628..600facf0 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -15,6 +15,7 @@ mod state; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod transport; +pub mod ordered_plan; pub mod policy; pub mod render_plan; pub(crate) mod render_plan_wire; diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs new file mode 100644 index 00000000..1c87e85a --- /dev/null +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -0,0 +1,1417 @@ +//! Retained ordered-direct physical storage and invalidation-directed patch planning. +//! +//! The planner compares stable semantic identities and caller-owned content revisions. It never +//! scans physical buffers to discover changes. Preparation writes only scratch state and can be +//! aborted; committed CPU mirrors change only after the immutable plan has been serialized. + +use alloc::vec::Vec; +use core::{mem, slice}; + +use super::{ + policy::{ + ALLOCATION_ORDERED_DIRECT, BufferSchema, CapabilitySetId, PhysicalBufferMut, + PolicyExecutionError, SemanticInputBatch, TechniqueId, ValidatedPolicy, + }, + render_plan::{ + BUFFER_ORDERED_DIRECT, BufferRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, PatchRecord, + RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RETIRE_BUFFER, RETIRE_RESOURCE, + RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, RetirementRecord, + }, +}; + +const MAX_PHYSICAL_BUFFERS: usize = 16; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct OrderedGlyph { + pub stable_id: u32, + pub content_revision: u32, + pub technique: TechniqueId, + pub variant: u16, + pub resource_id: u32, + pub resource_generation: u32, + pub resource_kind: u16, + pub resource_reference: u32, +} + +#[derive(Clone, Copy)] +pub struct OrderedPlanInput<'a> { + pub glyphs: &'a [OrderedGlyph], + pub f32_fields: &'a [&'a [f32]], + pub u32_fields: &'a [&'a [u32]], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OrderedPlanError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + CapabilitySetMissing, + ProgramMissing, + UnsupportedStrategy, + InvalidInputShape, + InvalidIdentity, + DuplicateIdentity, + InvalidResource, + CapacityExceeded, + IdentifierExhausted, + ArithmeticOverflow, + PolicyExecution(PolicyExecutionError), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BatchKey { + technique: TechniqueId, + variant: u16, + program_id: u32, + resource_id: u32, + resource_generation: u32, + resource_kind: u16, + resource_reference: u32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct InstanceState { + stable_id: u32, + content_revision: u32, + input_index: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BatchState { + key: BatchKey, + instance_start: u32, + instance_count: u32, + buffer_start: u32, + buffer_count: u16, +} + +struct PhysicalBufferState { + id: u32, + generation: u32, + program_id: u32, + schema: BufferSchema, + capacity: u32, + bytes: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PendingBatch { + state: BatchState, + prior_index: Option, + changed: bool, + buffer_ids: [u32; MAX_PHYSICAL_BUFFERS], + buffer_generations: [u32; MAX_PHYSICAL_BUFFERS], +} + +struct PendingAllocation { + state: PhysicalBufferState, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct RecordRange { + start: u32, + end: u32, +} + +#[derive(Clone, Copy)] +struct PrepareContext<'a> { + policy: &'a ValidatedPolicy, + capability_set: CapabilitySetId, + capability: &'a super::policy::CapabilitySet, + input: OrderedPlanInput<'a>, + checkpoint: bool, + publication_generation: u32, +} + +#[derive(Default)] +pub struct OrderedPlanCompiler { + batches: Vec, + instances: Vec, + buffers: Vec, + spare_batches: Vec, + spare_buffers: Vec, + pending_batches: Vec, + pending_instances: Vec, + pending_allocations: Vec, + input_batches: Vec, + identity_keys: Vec, + identity_epochs: Vec, + identity_epoch: u32, + batch_cursors: Vec, + changed_ranges: Vec, + resources: Vec, + plan_buffers: Vec, + patches: Vec, + retirements: Vec, + payload: Vec, + next_buffer_id: u32, + pending_next_buffer_id: u32, + prepared: bool, +} + +impl OrderedPlanCompiler { + pub fn prepare( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + ) -> Result<(), OrderedPlanError> { + if self.prepared { + return Err(OrderedPlanError::AlreadyPrepared); + } + if publication_generation == 0 { + return Err(OrderedPlanError::InvalidIdentity); + } + let capability = policy + .capability_set(capability_set) + .ok_or(OrderedPlanError::CapabilitySetMissing)?; + validate_input(input)?; + self.reset_pending(); + reserve(&mut self.input_batches, input.glyphs.len())?; + reserve(&mut self.pending_instances, input.glyphs.len())?; + self.input_batches.resize(input.glyphs.len(), 0); + self.prepare_identity_set(input.glyphs.len())?; + + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + validate_glyph(glyph)?; + if !self.insert_identity(glyph.stable_id) { + return Err(OrderedPlanError::DuplicateIdentity); + } + let program = policy + .program(capability_set, glyph.technique, glyph.variant) + .ok_or(OrderedPlanError::ProgramMissing)?; + if program.allocation_strategy != ALLOCATION_ORDERED_DIRECT { + return Err(OrderedPlanError::UnsupportedStrategy); + } + let resource_bit = 1_u32 + .checked_shl(u32::from(glyph.resource_kind - 1)) + .ok_or(OrderedPlanError::InvalidResource)?; + if program.resource_kind_mask & resource_bit == 0 { + return Err(OrderedPlanError::InvalidResource); + } + let key = BatchKey { + technique: glyph.technique, + variant: glyph.variant, + program_id: program.id.0, + resource_id: glyph.resource_id, + resource_generation: glyph.resource_generation, + resource_kind: glyph.resource_kind, + resource_reference: glyph.resource_reference, + }; + let batch_index = match self + .pending_batches + .iter() + .position(|batch| batch.state.key == key) + { + Some(index) => index, + None => { + reserve(&mut self.pending_batches, 1)?; + let prior_index = self + .batches + .iter() + .position(|batch| batch.key == key) + .map(|index| index as u32); + self.pending_batches.push(PendingBatch { + state: BatchState { + key, + instance_start: 0, + instance_count: 0, + buffer_start: 0, + buffer_count: 0, + }, + prior_index, + changed: checkpoint || prior_index.is_none(), + buffer_ids: [0; MAX_PHYSICAL_BUFFERS], + buffer_generations: [0; MAX_PHYSICAL_BUFFERS], + }); + self.pending_batches.len() - 1 + } + }; + self.pending_batches[batch_index].state.instance_count = self.pending_batches + [batch_index] + .state + .instance_count + .checked_add(1) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + self.input_batches[input_index] = + u32::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + } + + self.layout_pending_instances(input)?; + self.pending_next_buffer_id = self.next_buffer_id; + let context = PrepareContext { + policy, + capability_set, + capability, + input, + checkpoint, + publication_generation, + }; + for batch_index in 0..self.pending_batches.len() { + self.prepare_batch(context, batch_index)?; + } + self.prepare_removed_batches(publication_generation)?; + self.prepared = true; + Ok(()) + } + + pub fn plan_view( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, OrderedPlanError> { + if !self.prepared { + return Err(OrderedPlanError::NotPrepared); + } + Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + resources: &self.resources, + buffers: &self.plan_buffers, + patches: &self.patches, + retirements: &self.retirements, + payload: &self.payload, + ..RenderPlanView::default() + }) + } + + pub fn commit(&mut self) -> Result<(), OrderedPlanError> { + if !self.prepared { + return Err(OrderedPlanError::NotPrepared); + } + mem::swap(&mut self.buffers, &mut self.spare_buffers); + self.buffers.clear(); + for pending_index in 0..self.pending_batches.len() { + let pending = self.pending_batches[pending_index]; + for buffer_index in 0..usize::from(pending.state.buffer_count) { + let id = pending.buffer_ids[buffer_index]; + let generation = pending.buffer_generations[buffer_index]; + if let Some(allocation) = + take_allocation(&mut self.pending_allocations, id, generation) + { + self.buffers.push(allocation.state); + continue; + } + let position = self + .spare_buffers + .iter() + .position(|buffer| buffer.id == id && buffer.generation == generation) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let mut buffer = self.spare_buffers.swap_remove(position); + apply_writes(&mut buffer, &self.patches, &self.payload)?; + self.buffers.push(buffer); + } + } + self.spare_buffers.clear(); + + mem::swap(&mut self.batches, &mut self.spare_batches); + self.batches.clear(); + for pending in &self.pending_batches { + let mut state = pending.state; + state.buffer_start = u32::try_from(self.batches_buffer_start(self.batches.len())) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + self.batches.push(state); + } + self.spare_batches.clear(); + mem::swap(&mut self.instances, &mut self.pending_instances); + self.pending_instances.clear(); + self.next_buffer_id = self.pending_next_buffer_id; + self.prepared = false; + Ok(()) + } + + pub fn abort(&mut self) { + self.prepared = false; + self.pending_allocations.clear(); + } + + pub fn buffer_bytes(&self, id: u32) -> Option<&[u8]> { + self.buffers + .iter() + .find(|buffer| buffer.id == id) + .map(|buffer| buffer.bytes.as_slice()) + } + + pub fn buffer_schema(&self, id: u32) -> Option<(u32, BufferSchema)> { + self.buffers + .iter() + .find(|buffer| buffer.id == id) + .map(|buffer| (buffer.program_id, buffer.schema)) + } + + fn reset_pending(&mut self) { + self.pending_batches.clear(); + self.pending_instances.clear(); + self.pending_allocations.clear(); + self.batch_cursors.clear(); + self.changed_ranges.clear(); + self.resources.clear(); + self.plan_buffers.clear(); + self.patches.clear(); + self.retirements.clear(); + self.payload.clear(); + } + + fn prepare_identity_set(&mut self, count: usize) -> Result<(), OrderedPlanError> { + let required = count + .checked_mul(2) + .and_then(|value| value.checked_next_power_of_two()) + .unwrap_or(usize::MAX) + .max(8); + if required == usize::MAX { + return Err(OrderedPlanError::ArithmeticOverflow); + } + if self.identity_keys.len() < required { + let additional_keys = required - self.identity_keys.len(); + let additional_epochs = required - self.identity_epochs.len(); + reserve(&mut self.identity_keys, additional_keys)?; + reserve(&mut self.identity_epochs, additional_epochs)?; + self.identity_keys.resize(required, 0); + self.identity_epochs.resize(required, 0); + } + self.identity_epoch = match self.identity_epoch.checked_add(1) { + Some(epoch) => epoch, + None => { + self.identity_epochs.fill(0); + 1 + } + }; + Ok(()) + } + + fn insert_identity(&mut self, identity: u32) -> bool { + let mask = self.identity_keys.len() - 1; + let mut slot = (identity.wrapping_mul(0x9e37_79b1) as usize) & mask; + loop { + if self.identity_epochs[slot] != self.identity_epoch { + self.identity_epochs[slot] = self.identity_epoch; + self.identity_keys[slot] = identity; + return true; + } + if self.identity_keys[slot] == identity { + return false; + } + slot = (slot + 1) & mask; + } + } + + fn layout_pending_instances( + &mut self, + input: OrderedPlanInput<'_>, + ) -> Result<(), OrderedPlanError> { + reserve(&mut self.batch_cursors, self.pending_batches.len())?; + let mut cursor = 0_u32; + for batch in &mut self.pending_batches { + batch.state.instance_start = cursor; + cursor = cursor + .checked_add(batch.state.instance_count) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + self.batch_cursors.push(batch.state.instance_start); + } + self.pending_instances + .resize(input.glyphs.len(), InstanceState::default()); + for (input_index, glyph) in input.glyphs.iter().enumerate() { + let batch = self.input_batches[input_index] as usize; + let destination = self.batch_cursors[batch] as usize; + self.pending_instances[destination] = InstanceState { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + input_index: input_index as u32, + }; + self.batch_cursors[batch] = self.batch_cursors[batch] + .checked_add(1) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + } + Ok(()) + } + + fn prepare_batch( + &mut self, + context: PrepareContext<'_>, + batch_index: usize, + ) -> Result<(), OrderedPlanError> { + let PrepareContext { + policy, + capability_set, + capability, + input, + checkpoint, + publication_generation, + } = context; + let pending = self.pending_batches[batch_index]; + let key = pending.state.key; + let program = policy + .program(capability_set, key.technique, key.variant) + .ok_or(OrderedPlanError::ProgramMissing)?; + let prior = pending + .prior_index + .map(|index| self.batches[index as usize]); + let required = pending.state.instance_count; + let prior_capacity = prior + .and_then(|batch| self.buffers.get(batch.buffer_start as usize)) + .map_or(0, |buffer| buffer.capacity); + let capacity = if prior_capacity >= required { + prior_capacity + } else { + grown_capacity(prior_capacity.max(1), required)? + }; + let capacity = align_up( + capacity, + record_alignment(program, capability.update_alignment)?, + )?; + for schema in &program.buffers { + let byte_length = capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + if byte_length > capability.max_buffer_bytes { + return Err(OrderedPlanError::CapacityExceeded); + } + } + + let buffer_start = self.plan_buffers.len(); + let new_or_resized = prior.is_none() || capacity != prior_capacity; + let prior_instances = match prior { + Some(batch) => self + .instances + .get(range(batch.instance_start, batch.instance_count)?) + .ok_or(OrderedPlanError::InvalidIdentity)?, + None => &[], + }; + let next_instances = &self.pending_instances + [range(pending.state.instance_start, pending.state.instance_count)?]; + collect_changed_ranges( + &mut self.changed_ranges, + prior_instances, + next_instances, + checkpoint || new_or_resized, + )?; + coalesce_ranges(&mut self.changed_ranges, program, capability, required)?; + if !self.changed_ranges.is_empty() + || prior.is_some_and(|old| old.instance_count != required) + { + self.pending_batches[batch_index].changed = true; + } + + if self.pending_batches[batch_index].changed { + let existing_resource = self.batches.iter().any(|batch| { + batch.key.resource_id == key.resource_id + && batch.key.resource_generation == key.resource_generation + }); + if !self.resources.iter().any(|resource| { + resource.id == key.resource_id && resource.generation == key.resource_generation + }) { + reserve(&mut self.resources, 1)?; + self.resources.push(ResourceRecord { + id: key.resource_id, + generation: key.resource_generation, + technique_id: key.technique.0, + resource_kind: key.resource_kind, + action: if checkpoint || !existing_resource { + RESOURCE_ACTION_CREATE + } else { + RESOURCE_ACTION_RETAIN + }, + reference_id: key.resource_reference, + ..ResourceRecord::default() + }); + } + } + + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + let previous = prior + .and_then(|batch| self.buffers.get(batch.buffer_start as usize + schema_index)) + .map(|buffer| (buffer.id, buffer.generation, buffer.bytes.len())); + let (id, generation) = if let Some((previous_id, previous_generation, _)) = previous { + ( + previous_id, + if new_or_resized { + previous_generation + .checked_add(1) + .ok_or(OrderedPlanError::IdentifierExhausted)? + } else { + previous_generation + }, + ) + } else { + self.pending_next_buffer_id = self + .pending_next_buffer_id + .checked_add(1) + .ok_or(OrderedPlanError::IdentifierExhausted)?; + (self.pending_next_buffer_id, 1) + }; + if self.pending_batches[batch_index].changed { + reserve(&mut self.plan_buffers, 1)?; + self.plan_buffers.push(BufferRecord { + id, + generation, + program_id: key.program_id, + policy_buffer_id: schema.id.0, + scalar_type: schema.scalar as u8, + vector_width: schema.vector_width, + strategy: BUFFER_ORDERED_DIRECT, + flags: schema.usage as u16, + live_records: required, + capacity_records: capacity, + byte_length: capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + order_buffer_id: 0, + }); + } + if checkpoint || new_or_resized { + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_ALLOCATE_OR_RESIZE, + buffer_id: id, + buffer_generation: generation, + byte_length: capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + self.prepare_allocation(id, generation, key.program_id, schema, capacity)?; + if new_or_resized + && let Some((previous_id, previous_generation, previous_length)) = previous + { + reserve(&mut self.retirements, 1)?; + self.retirements.push(RetirementRecord { + kind: RETIRE_BUFFER, + id: previous_id, + generation: previous_generation, + after_publication_generation: publication_generation, + byte_length: previous_length as u32, + ..RetirementRecord::default() + }); + } + } + self.pending_batches[batch_index].buffer_ids[schema_index] = id; + self.pending_batches[batch_index].buffer_generations[schema_index] = generation; + } + + self.write_changed_ranges( + policy, + capability_set, + capability, + input, + program, + prior, + pending, + checkpoint || new_or_resized, + buffer_start, + )?; + if let Some(prior) = prior + && required < prior.instance_count + { + reserve(&mut self.retirements, usize::from(prior.buffer_count))?; + for buffer in &self.buffers[range(prior.buffer_start, u32::from(prior.buffer_count))?] { + self.retirements.push(RetirementRecord { + kind: RETIRE_SLOT_RANGE, + id: buffer.id, + generation: buffer.generation, + after_publication_generation: publication_generation, + byte_offset: required + .checked_mul(u32::from(buffer.schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + byte_length: (prior.instance_count - required) + .checked_mul(u32::from(buffer.schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + ..RetirementRecord::default() + }); + } + } + self.pending_batches[batch_index].state.buffer_start = + u32::try_from(buffer_start).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + self.pending_batches[batch_index].state.buffer_count = u16::try_from(program.buffers.len()) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn write_changed_ranges( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + capability: &super::policy::CapabilitySet, + input: OrderedPlanInput<'_>, + program: &super::policy::ProgramDescriptor, + prior: Option, + pending: PendingBatch, + replace: bool, + plan_buffer_start: usize, + ) -> Result<(), OrderedPlanError> { + let record_alignment = record_alignment(program, capability.update_alignment)?; + let next_instances = &self.pending_instances + [range(pending.state.instance_start, pending.state.instance_count)?]; + let prior_instances = match prior { + Some(batch) => self + .instances + .get(range(batch.instance_start, batch.instance_count)?) + .ok_or(OrderedPlanError::InvalidIdentity)?, + None => &[], + }; + for range_index in 0..self.changed_ranges.len() { + let changed = self.changed_ranges[range_index]; + let aligned = align_record_range(changed, record_alignment)?; + let count = aligned.end - aligned.start; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + for (schema_index, schema) in program.buffers.iter().enumerate() { + let byte_count = usize::try_from(count) + .ok() + .and_then(|value| value.checked_mul(schema.stride())) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + let payload_start = self.payload.len(); + reserve(&mut self.payload, byte_count)?; + self.payload.resize(payload_start + byte_count, 0); + payload_starts[schema_index] = payload_start; + if !replace && let Some(prior) = prior { + let old_buffer = self + .buffers + .get(prior.buffer_start as usize + schema_index) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let source_start = aligned.start as usize * schema.stride(); + let source_end = source_start + byte_count; + if source_end <= old_buffer.bytes.len() { + self.payload[payload_start..payload_start + byte_count] + .copy_from_slice(&old_buffer.bytes[source_start..source_end]); + } + } + } + + let mut slot = aligned.start; + while slot < aligned.end.min(pending.state.instance_count) { + if instance_unchanged(prior_instances, next_instances, slot, replace) { + slot += 1; + continue; + } + let input_start = next_instances[slot as usize].input_index; + let run_start = slot; + slot += 1; + while slot < aligned.end.min(pending.state.instance_count) + && !instance_unchanged(prior_instances, next_instances, slot, replace) + && next_instances[slot as usize].input_index == input_start + (slot - run_start) + { + slot += 1; + } + execute_run( + policy, + capability_set, + program, + input, + input_start as usize, + slot - run_start, + run_start - aligned.start, + &mut self.payload, + &payload_starts, + count, + )?; + } + + for (schema_index, schema) in program.buffers.iter().enumerate() { + let record = self + .plan_buffers + .get(plan_buffer_start + schema_index) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let byte_length = count + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + let destination_offset = aligned + .start + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_WRITE, + buffer_id: record.id, + buffer_generation: record.generation, + destination_offset, + byte_length, + payload_start: u32::try_from(payload_starts[schema_index]) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + if let Some(allocation) = self.pending_allocations.iter_mut().find(|allocation| { + allocation.state.id == record.id + && allocation.state.generation == record.generation + }) { + let destination = destination_offset as usize; + let source = payload_starts[schema_index]; + let destination = allocation + .state + .bytes + .get_mut(destination..destination + byte_length as usize) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let source = self + .payload + .get(source..source + byte_length as usize) + .ok_or(OrderedPlanError::InvalidIdentity)?; + destination.copy_from_slice(source); + } + } + } + Ok(()) + } + + fn prepare_allocation( + &mut self, + id: u32, + generation: u32, + program_id: u32, + schema: BufferSchema, + capacity: u32, + ) -> Result<(), OrderedPlanError> { + let length = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_mul(schema.stride())) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + let mut bytes = Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| OrderedPlanError::AllocationFailed)?; + bytes.resize(length, 0); + reserve(&mut self.pending_allocations, 1)?; + self.pending_allocations.push(PendingAllocation { + state: PhysicalBufferState { + id, + generation, + program_id, + schema, + capacity, + bytes, + }, + }); + Ok(()) + } + + fn prepare_removed_batches( + &mut self, + publication_generation: u32, + ) -> Result<(), OrderedPlanError> { + for batch in &self.batches { + if self + .pending_batches + .iter() + .any(|pending| pending.state.key == batch.key) + { + continue; + } + reserve(&mut self.retirements, usize::from(batch.buffer_count) + 1)?; + let resource_remains = self.pending_batches.iter().any(|pending| { + pending.state.key.resource_id == batch.key.resource_id + && pending.state.key.resource_generation == batch.key.resource_generation + }); + let resource_retired = self.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == batch.key.resource_id + && retirement.generation == batch.key.resource_generation + }); + if !resource_remains && !resource_retired { + self.retirements.push(RetirementRecord { + kind: RETIRE_RESOURCE, + id: batch.key.resource_id, + generation: batch.key.resource_generation, + after_publication_generation: publication_generation, + ..RetirementRecord::default() + }); + } + for buffer in &self.buffers[range(batch.buffer_start, u32::from(batch.buffer_count))?] { + self.retirements.push(RetirementRecord { + kind: RETIRE_BUFFER, + id: buffer.id, + generation: buffer.generation, + after_publication_generation: publication_generation, + byte_length: buffer.bytes.len() as u32, + ..RetirementRecord::default() + }); + } + } + Ok(()) + } + + fn batches_buffer_start(&self, batch_count: usize) -> usize { + self.pending_batches[..batch_count] + .iter() + .map(|batch| usize::from(batch.state.buffer_count)) + .sum() + } +} + +fn validate_input(input: OrderedPlanInput<'_>) -> Result<(), OrderedPlanError> { + if u32::try_from(input.glyphs.len()).is_err() { + return Err(OrderedPlanError::InvalidInputShape); + } + if input + .f32_fields + .iter() + .any(|field| field.len() != input.glyphs.len()) + || input + .u32_fields + .iter() + .any(|field| field.len() != input.glyphs.len()) + { + return Err(OrderedPlanError::InvalidInputShape); + } + Ok(()) +} + +fn validate_glyph(glyph: OrderedGlyph) -> Result<(), OrderedPlanError> { + if glyph.stable_id == 0 || glyph.content_revision == 0 { + return Err(OrderedPlanError::InvalidIdentity); + } + if glyph.resource_id == 0 + || glyph.resource_generation == 0 + || !(1..=32).contains(&glyph.resource_kind) + { + return Err(OrderedPlanError::InvalidResource); + } + Ok(()) +} + +fn collect_changed_ranges( + ranges: &mut Vec, + previous: &[InstanceState], + next: &[InstanceState], + replace: bool, +) -> Result<(), OrderedPlanError> { + ranges.clear(); + if next.is_empty() { + return Ok(()); + } + if replace { + ranges.push(RecordRange { + start: 0, + end: next.len() as u32, + }); + return Ok(()); + } + let mut start = None; + for (slot, next) in next.iter().enumerate() { + let changed = previous.get(slot).is_none_or(|previous| { + previous.stable_id != next.stable_id + || previous.content_revision != next.content_revision + }); + match (start, changed) { + (None, true) => start = Some(slot as u32), + (Some(first), false) => { + reserve(ranges, 1)?; + ranges.push(RecordRange { + start: first, + end: slot as u32, + }); + start = None; + } + _ => {} + } + } + if let Some(start) = start { + reserve(ranges, 1)?; + ranges.push(RecordRange { + start, + end: next.len() as u32, + }); + } + Ok(()) +} + +fn instance_unchanged( + previous: &[InstanceState], + next: &[InstanceState], + slot: u32, + replace: bool, +) -> bool { + !replace + && previous.get(slot as usize).is_some_and(|previous| { + let next = next[slot as usize]; + previous.stable_id == next.stable_id + && previous.content_revision == next.content_revision + }) +} + +fn coalesce_ranges( + ranges: &mut Vec, + program: &super::policy::ProgramDescriptor, + capability: &super::policy::CapabilitySet, + live_records: u32, +) -> Result<(), OrderedPlanError> { + if ranges.is_empty() { + return Ok(()); + } + let bytes_per_record = program.buffers.iter().try_fold(0_u32, |total, schema| { + total + .checked_add(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow) + })?; + let accepted_gap = capability + .coalesce_gap_bytes + .max(capability.range_call_penalty_bytes); + if ranges.len() > 1 { + let mut write = 0; + for read in 1..ranges.len() { + let gap = ranges[read] + .start + .saturating_sub(ranges[write].end) + .saturating_mul(bytes_per_record); + if gap <= accepted_gap { + ranges[write].end = ranges[read].end; + } else { + write += 1; + ranges[write] = ranges[read]; + } + } + ranges.truncate(write + 1); + } + if ranges.len() > usize::from(capability.fragmentation_budget) { + let first = ranges[0].start; + let last = ranges.last().ok_or(OrderedPlanError::InvalidIdentity)?.end; + ranges.clear(); + ranges.push(RecordRange { + start: first, + end: last, + }); + } + let upload_records = ranges.iter().try_fold(0_u32, |total, range| { + total + .checked_add(range.end - range.start) + .ok_or(OrderedPlanError::ArithmeticOverflow) + })?; + let upload_cost = upload_records + .checked_mul(bytes_per_record) + .and_then(|bytes| { + bytes.checked_add( + (ranges.len() as u32).saturating_mul(capability.range_call_penalty_bytes), + ) + }) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + let full_bytes = live_records + .checked_mul(bytes_per_record) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + if upload_cost.saturating_mul(10_000) + >= full_bytes.saturating_mul(u32::from(capability.whole_buffer_threshold_basis_points)) + { + ranges.clear(); + ranges.push(RecordRange { + start: 0, + end: live_records, + }); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn execute_run( + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + program: &super::policy::ProgramDescriptor, + input: OrderedPlanInput<'_>, + input_index: usize, + record_count: u32, + output_record: u32, + payload: &mut [u8], + payload_starts: &[usize; MAX_PHYSICAL_BUFFERS], + output_records: u32, +) -> Result<(), OrderedPlanError> { + let input_end = input_index + .checked_add(record_count as usize) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + let mut f32_fields: [&[f32]; super::policy::MAX_REGISTERS] = + [&[]; super::policy::MAX_REGISTERS]; + let mut u32_fields: [&[u32]; super::policy::MAX_REGISTERS] = + [&[]; super::policy::MAX_REGISTERS]; + for (target, field) in f32_fields + .iter_mut() + .zip(input.f32_fields.iter()) + .take(usize::from(program.f32_input_count)) + { + *target = &field[input_index..input_end]; + } + for (target, field) in u32_fields + .iter_mut() + .zip(input.u32_fields.iter()) + .take(usize::from(program.u32_input_count)) + { + *target = &field[input_index..input_end]; + } + + let mut outputs: [mem::MaybeUninit>; MAX_PHYSICAL_BUFFERS] = + [const { mem::MaybeUninit::uninit() }; MAX_PHYSICAL_BUFFERS]; + let base = payload.as_mut_ptr(); + for (index, schema) in program.buffers.iter().copied().enumerate() { + let length = output_records as usize * schema.stride(); + // SAFETY: all payload segments were sized before this call, are mutually disjoint, and + // `payload` cannot reallocate while these temporary views exist. + let bytes = unsafe { slice::from_raw_parts_mut(base.add(payload_starts[index]), length) }; + outputs[index].write(PhysicalBufferMut { schema, bytes }); + } + // SAFETY: the prefix contains exactly one initialized value per declared program buffer. + let outputs = unsafe { + slice::from_raw_parts_mut( + outputs.as_mut_ptr().cast::>(), + program.buffers.len(), + ) + }; + policy + .execute( + capability_set, + program.technique, + program.variant, + SemanticInputBatch { + f32_fields: &f32_fields[..usize::from(program.f32_input_count)], + u32_fields: &u32_fields[..usize::from(program.u32_input_count)], + record_count: record_count as usize, + }, + output_record as usize, + outputs, + ) + .map_err(OrderedPlanError::PolicyExecution) +} + +fn apply_writes( + buffer: &mut PhysicalBufferState, + patches: &[PatchRecord], + payload: &[u8], +) -> Result<(), OrderedPlanError> { + for patch in patches.iter().filter(|patch| { + patch.opcode == PATCH_WRITE + && patch.buffer_id == buffer.id + && patch.buffer_generation == buffer.generation + }) { + let destination = patch.destination_offset as usize; + let source = patch.payload_start as usize; + let length = patch.byte_length as usize; + let destination = buffer + .bytes + .get_mut(destination..destination + length) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let source = payload + .get(source..source + length) + .ok_or(OrderedPlanError::InvalidIdentity)?; + destination.copy_from_slice(source); + } + Ok(()) +} + +fn take_allocation( + allocations: &mut Vec, + id: u32, + generation: u32, +) -> Option { + allocations + .iter() + .position(|allocation| { + allocation.state.id == id && allocation.state.generation == generation + }) + .map(|index| allocations.swap_remove(index)) +} + +fn grown_capacity(mut capacity: u32, required: u32) -> Result { + while capacity < required { + capacity = capacity + .checked_mul(2) + .ok_or(OrderedPlanError::CapacityExceeded)?; + } + Ok(capacity) +} + +fn record_alignment( + program: &super::policy::ProgramDescriptor, + byte_alignment: u32, +) -> Result { + program.buffers.iter().try_fold(1_u32, |records, schema| { + let stride = u32::from(schema.stride); + let divisor = gcd(byte_alignment, stride); + lcm(records, byte_alignment / divisor) + }) +} + +fn align_record_range(range: RecordRange, alignment: u32) -> Result { + let start = range.start / alignment * alignment; + let end = range + .end + .checked_add(alignment - 1) + .map(|value| value / alignment * alignment) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + Ok(RecordRange { start, end }) +} + +fn align_up(value: u32, alignment: u32) -> Result { + value + .checked_add(alignment - 1) + .map(|value| value / alignment * alignment) + .ok_or(OrderedPlanError::ArithmeticOverflow) +} + +fn gcd(mut left: u32, mut right: u32) -> u32 { + while right != 0 { + (left, right) = (right, left % right); + } + left +} + +fn lcm(left: u32, right: u32) -> Result { + left.checked_div(gcd(left, right)) + .and_then(|value| value.checked_mul(right)) + .ok_or(OrderedPlanError::ArithmeticOverflow) +} + +fn range(start: u32, count: u32) -> Result, OrderedPlanError> { + let end = start + .checked_add(count) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + Ok(start as usize..end as usize) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), OrderedPlanError> { + values + .try_reserve(additional) + .map_err(|_| OrderedPlanError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::policy::{ + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, + BufferId, CAP_ORDERED_DIRECT, CapabilitySet, Operation, PolicyDescriptor, + ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, + }; + use crate::engine::render_plan_wire::plan_layout; + use alloc::vec; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + const TECHNIQUE: TechniqueId = TechniqueId(1); + + #[test] + fn ordered_direct_uses_identity_revisions_instead_of_scanning_physical_bytes() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let x = [1.0, 2.0, 3.0]; + let glyphs = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare(&mut compiler, &policy, &glyphs, &x, true); + let first = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(first.buffers.len(), 1); + assert_eq!(first.patches.len(), 2); + assert!(plan_layout(first).unwrap().byte_length > 144); + compiler.commit().unwrap(); + + let changed_x = [1.0, 20.0, 3.0]; + let changed = [glyph(1, 1), glyph(2, 2), glyph(3, 1)]; + prepare(&mut compiler, &policy, &changed, &changed_x, false); + let delta = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(delta.patches.len(), 1); + assert_eq!(delta.patches[0].destination_offset, 4); + assert_eq!(delta.patches[0].byte_length, 4); + assert_eq!(delta.payload.len(), 4); + compiler.commit().unwrap(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 4), 20.0); + } + + #[test] + fn insertion_rewrites_only_the_ordered_batch_suffix_and_abort_preserves_state() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0, 3.0], true); + compiler.commit().unwrap(); + + let inserted = [glyph(1, 1), glyph(4, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &inserted, + &[1.0, 4.0, 2.0, 3.0], + false, + ); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches[0].opcode, PATCH_WRITE); + assert_eq!(plan.patches[0].destination_offset, 4); + compiler.abort(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 4), 2.0); + } + + #[test] + fn no_op_emits_nothing_and_tail_deletion_updates_only_live_metadata() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0, 3.0], true); + compiler.commit().unwrap(); + + prepare( + &mut compiler, + &policy, + &initial, + &[999.0, 999.0, 999.0], + false, + ); + let no_op = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(no_op.resources.is_empty()); + assert!(no_op.buffers.is_empty()); + assert!(no_op.patches.is_empty()); + assert!(no_op.payload.is_empty()); + compiler.commit().unwrap(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 0), 1.0); + + prepare(&mut compiler, &policy, &initial[..2], &[1.0, 2.0], false); + let shrink = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(shrink.buffers[0].live_records, 2); + assert!(shrink.patches.is_empty()); + assert_eq!(shrink.retirements.len(), 1); + assert_eq!(shrink.retirements[0].byte_offset, 8); + assert_eq!(shrink.retirements[0].byte_length, 4); + } + + #[test] + fn repeated_warm_updates_keep_every_glyph_scaled_scratch_capacity() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0, 4.0], + true, + ); + compiler.commit().unwrap(); + let changed = [glyph(1, 1), glyph(2, 2), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed, + &[1.0, 20.0, 3.0, 4.0], + false, + ); + compiler.commit().unwrap(); + let settled = capacities(&compiler); + let changed_again = [glyph(1, 1), glyph(2, 3), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed_again, + &[1.0, 21.0, 3.0, 4.0], + false, + ); + compiler.commit().unwrap(); + assert_eq!(capacities(&compiler), settled); + } + + fn prepare( + compiler: &mut OrderedPlanCompiler, + policy: &ValidatedPolicy, + glyphs: &[OrderedGlyph], + x: &[f32], + checkpoint: bool, + ) { + compiler + .prepare( + policy, + CAPABILITY, + OrderedPlanInput { + glyphs, + f32_fields: &[x], + u32_fields: &[], + }, + checkpoint, + 1, + ) + .unwrap(); + } + + fn glyph(stable_id: u32, content_revision: u32) -> OrderedGlyph { + OrderedGlyph { + stable_id, + content_revision, + technique: TECHNIQUE, + variant: 0, + resource_id: 11, + resource_generation: 1, + resource_kind: 1, + resource_reference: 99, + } + } + + fn policy() -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![CapabilitySet { + id: CAPABILITY, + flags: CAP_ORDERED_DIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 1, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 10_000, + }], + programs: vec![ProgramDescriptor { + technique: TECHNIQUE, + variant: 0, + id: ProgramId(5), + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 1, + u32_input_count: 0, + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } + + fn read_f32(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn capacities(compiler: &OrderedPlanCompiler) -> [usize; 13] { + [ + compiler.pending_batches.capacity(), + compiler.pending_instances.capacity(), + compiler.pending_allocations.capacity(), + compiler.input_batches.capacity(), + compiler.identity_keys.capacity(), + compiler.identity_epochs.capacity(), + compiler.batch_cursors.capacity(), + compiler.changed_ranges.capacity(), + compiler.resources.capacity(), + compiler.plan_buffers.capacity(), + compiler.patches.capacity(), + compiler.retirements.capacity(), + compiler.payload.capacity(), + ] + } +} diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 65d912ce..4d4e3609 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -553,6 +553,7 @@ pub enum PolicyError { InvalidResourceKinds, InvalidBatchKey, UnsupportedAllocationStrategy, + TooManyInputFields, EmptyBuffers, TooManyBuffers, InvalidBufferId, @@ -1043,6 +1044,11 @@ fn validate_capability_sets(capability_sets: &[CapabilitySet]) -> Result<(), Pol } fn validate_program(program: &ProgramDescriptor) -> Result<(), PolicyError> { + if usize::from(program.f32_input_count) > MAX_REGISTERS + || usize::from(program.u32_input_count) > MAX_REGISTERS + { + return Err(PolicyError::TooManyInputFields); + } if program.buffers.is_empty() { return Err(PolicyError::EmptyBuffers); } From 1d8a64aab8827524b7b247e2e83034143ba3d4d0 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 08:38:50 -0400 Subject: [PATCH 011/128] feat(text): compile ordered render plans --- docs/log.md | 17 + docs/packages/text.md | 23 +- docs/planning/decision-register.md | 11 +- docs/planning/index.md | 4 +- docs/planning/rust-layout-engine.md | 43 +- docs/planning/text-effect-composition.md | 153 +---- docs/planning/three-material-authority.md | 225 ++++--- packages/text/rust/shaper/src/abi_contract.rs | 38 +- .../rust/shaper/src/engine/ordered_plan.rs | 566 +++++++++++++++--- .../text/rust/shaper/src/engine/policy.rs | 43 +- .../rust/shaper/src/engine/render_plan.rs | 15 +- .../shaper/src/engine/render_plan_wire.rs | 12 +- packages/text/rust/shaper/src/engine/state.rs | 5 +- packages/text/rust/shaper/src/engine/wire.rs | 31 +- .../text/src/generated/text-shaper-abi.ts | 35 +- packages/text/tests/support/engine-abi.mjs | 15 +- 16 files changed, 835 insertions(+), 401 deletions(-) diff --git a/docs/log.md b/docs/log.md index c6ac1c3d..687ee169 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,23 @@ ## 2026-08-08 +- **Completed ordered-direct display-list compilation** — Dirty retained updates now publish complete compact binding + and command tables while keeping physical payloads revision-directed. Consecutive compatible glyphs compile into one + primitive span and draw packet; interleaved `A, A, B, A` resources preserve three ordered spans over two deduplicated + resources and buffers. Material IDs split ordered draws without splitting shared physical glyph storage. Draw records + also carry numeric clip and depth identities, and the generated TypeScript ABI derives their 60-byte compiler layout + from Rust. No-op output remains empty and one changed glyph remains one four-byte payload in the focused policy fixture. + Policies independently select storage and draw keys, proving material-split draws both over shared storage and over + material-partitioned buffers. The planner remains LTO-stripped until session wiring; reachable ABI/policy growth from + the preceding checkpoint measures 266 raw / 70 gzip / 139 Brotli bytes. Stable-indirect compilation and end-to-end + timing remain open. + +- **Replaced the unimplemented effects vocabulary with material routing** — Superseded `renderVariant` and the declared- + only `TextEffect` proposal with one batch → text → span `material` property and numeric Rust/Wire `material_id` identity. + The Rust contract is fixed: policies control material draw compatibility, material changes never reshape or relayout, + and different materials may share canonical glyph buffers. The exact Three material-factory API remains deliberately + provisional for a later design pass; current first-party targets do not yet implement it. + - **Implemented retained ordered-direct physical patches** — Added an abortable native planner that groups glyphs by policy program/resource and uses stable instance IDs plus semantic revisions, never full-buffer byte comparison, to select writes. Capability alignment and upload costs coalesce ranges; consecutive changed records retain SIMD policy diff --git a/docs/packages/text.md b/docs/packages/text.md index c69df8a9..fad86b0c 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:78ef2b495017f313318d824f1f946df614d65a778799ed91aac444441d962e90' +source_digest: 'sha256:155e19bc6f7cc851bb20e59c2398145d911a209b09bae9645d23b995ae9ca8c7' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -406,7 +406,7 @@ at its deliberately stale checked package-size snapshot; this stage records the that unrelated historical evidence. The render-plan wire layer now gives those result tables concrete compiler-mapped records: semantic 44 bytes, resource -40, physical buffer 36, patch 36, primitive 64, draw 48, retirement 24, and diagnostic 24. Resource kind is independent +40, physical buffer 36, patch 36, primitive 64, draw 60, retirement 24, and diagnostic 24. Resource kind is independent from create/update/retain action, and ordered-direct versus stable-indirect allocation is a dedicated buffer strategy. Patch payload bytes live inside the same immutable publication and write records carry absolute rebased spans; allocate/ resize, fill, copy, and retire records do not carry a payload address. Serialization is allocation-free, canonical @@ -419,8 +419,8 @@ wire and publication contract, not incremental-layout performance. Policy registration now supplies the missing inputs to that compiler through the same compiler-mapped direct-memory contract. Its 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte buffer, and 16-byte operation tables; registration decodes those bytes once into retained typed Rust state. Capability-set-specific lookup -validates backend flags, binding and draw limits, integer upload costs, resource-kind and batch-key masks, allocation -strategy, and aligned padded buffer strides before a session revision can advance. The executor honors declared stride +validates backend flags, binding and draw limits, integer upload costs, resource-kind masks, independent storage/draw +keys, allocation strategy, and aligned padded buffer strides before a session revision can advance. The executor honors declared stride without touching padding. Physical outputs remain disjoint independently bindable vector streams; policy operations pack wider records instead of introducing aliased mutable interleaved fields. Thirty-six Rust unit tests and the focused Node registration/frame tests pass. The optimized SIMD artifact is 739,647 raw / 272,532 gzip / 214,186 Brotli bytes, @@ -435,10 +435,17 @@ runs, while resource interleaving produces smaller scalar tails only where conti is separately viewable, committable, and abortable: no committed CPU mirror changes before immutable plan serialization. Tests cover exact first publication, a one-record update, ordered suffix movement, no-op zero output, metadata-only tail deletion, retirement generations, abort preservation, compiler-wire validation, and unchanged warm scratch capacities. -The slice emits only resource/buffer/patch/retirement tables and is not called by the shipping Wasm update yet; LTO -therefore removes it. The rebuilt optimized SIMD artifact is 739,643 raw / 272,537 gzip / 214,149 Brotli bytes, a delta -of -4 / +5 / -37 bytes from the preceding policy checkpoint. No planner latency claim is attached until the Wasm lab -and primitive/draw compilation make that code reachable. +Dirty transactions additionally publish complete compact resource/buffer bindings and ordered glyph-span/draw tables; +the physical buffer payload remains range-minimal. A primitive span represents consecutive compatible physical records +and carries one 16-bit record count, so the compiler splits only on ordering/binding identity, physical discontinuity, or +the 65,535-record wire limit instead of emitting a 64-byte primitive per glyph. Draws carry numeric material, clip, and +depth identities and exact table ranges, never a renderer object or callback. Different material IDs split draws under +the first-party policy while retaining one shared physical glyph buffer. An interleaved `A, A, B, A` resource test +produces three ordered spans over two deduplicated resources and buffers. The policy program is restricted packing +bytecode executed by Rust; the render plan itself is data. The planner remains unreachable from the shipping Wasm update +and is LTO-stripped. Only the reachable draw-wire and policy-key expansion changes the optimized SIMD artifact to +739,909 raw / 272,607 gzip / 214,288 Brotli bytes. No planner latency claim is attached until session wiring makes it +reachable. The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index ac38780c..897c56cf 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -215,9 +215,9 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | | D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | | D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | -| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Accepted | +| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | | D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | -| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Accepted | +| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | | D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | | D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | | D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | @@ -228,9 +228,10 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | | D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | | D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | -| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not a backend command buffer or raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (48), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | -| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, batch-key fields, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | -| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. The current slice emits resource/buffer/patch/retirement tables; primitive/draw tables, stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | +| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | +| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | +| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | +| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/index.md b/docs/planning/index.md index 8d1a3254..0ec96d3e 100644 --- a/docs/planning/index.md +++ b/docs/planning/index.md @@ -10,7 +10,7 @@ - [Raster technique and engine resource API](raster-technique-api.md) — authoritative split between portable baker/artifact/CPU technique data, reusable backend technique shaders, variant-aware programs, and engine GPU targets. - [TypeGPU raster programs and text engine](typegpu-api.md) — complete direct TypeGPU API for typed technique shaders, programs, variants, caller-owned render passes, transforms, synchronization, and disposal. - [TypeGPU-first shader authority](typegpu-first-shader-authority.md) — exploratory package shape and falsifiable proof ladder for sharing complete raster kernels with direct WebGPU hosts, Three.js, and gpucat without changing core. -- [Three material authority for text draws](three-material-authority.md) — **work in progress, follow-up.** Proposes render variants carrying a user material factory over the exported canonical shaders. Recorded so the proposal survives; maintainers have identified incorrect edges that are unresolved. +- [Three material authority for text draws](three-material-authority.md) — **work in progress.** Fixes the Rust `material_id` route and shared-storage/draw-split contract while leaving the exact Three material-factory types for a later design pass. - [Merged v0 raster and baker plugin guide](raster-baker-plugin.md) — build against the implemented combined runtime/renderer module before the target v1 extraction replaces it. - [Architecture](architecture.md) — system ownership, import boundaries, and runtime flow. - [Renderer-neutral core, batching, and engine integration](engine-integration-boundary.md) — WIP extraction and proof plan for the batched core API, Three.js migration, direct TypeGPU engine, and Wayfare adapter. @@ -47,7 +47,7 @@ - [MTSDF generation research](mtsdf-generation-research.md) — primary literature, implementation/license survey, owned Rust boundary, and data-oriented optimization gates. - [Grayscale bitmap hinting research](bitmap-hinting-research.md) — native pixel placement, hinted strikes, and four-phase grayscale packing gates. - [Renderer capabilities](renderer-capabilities.md) — feature matrix and developer guidance. -- [Three.js text effect composition](text-effect-composition.md) — optional TSL convenience over generic core variants and canonical raster shaders. +- [Three.js text effect composition](text-effect-composition.md) — superseded bespoke-effects proposal; custom material authority is the selected direction. - [Implementation difficulty](implementation-difficulty.md) — relative correctness and performance effort. - [Payload budget](payload-budget.md) — serialized, decoded, and resident cost model. - [GPU compression and Rust container ownership](gpu-compression.md) — transport/GPU compression constraints plus the GLB/KTX2 serializer decision. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index aff436fe..c93f9907 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -604,7 +604,8 @@ The descriptor includes: - a technique capability table mapping stable technique IDs to program IDs, accepted resource kinds, supported paint and compositing features, and physical schemas; - required semantic inputs and requested derived views; -- a batch-compatibility key assembled from declared resource, technique, material, clipping, depth, and ordering fields; +- separate storage and draw compatibility keys assembled from declared resource, technique, material, clipping, depth, + and ordering fields; - backend capabilities such as storage-buffer support, indirect draws, aliasable vector widths, maximum binding sizes, and update alignment; - an upload cost model: preferred coalescing gap, range/call penalty, whole-buffer threshold, and fragmentation budget; @@ -623,8 +624,9 @@ The compiler-mapped V0 registration ABI uses a 36-byte request header followed b program, 16-byte physical-buffer, and 16-byte operation records. Capability-set selection is part of program lookup: an exact set-specific program wins over a set-agnostic program, and an update naming an undeclared set fails before its revision changes. Capability sets own storage/indirect/aliasing flags, maximum binding and draw limits, update alignment, -and the integer upload-cost model. Programs own the resource-kind mask, semantic-view request, batch-key mask, and one -of the two allocation strategies. Physical streams own explicit alignment, padded stride, usage, and capacity class. +and the integer upload-cost model. Programs own the resource-kind mask, semantic-view request, storage-key mask, draw-key +mask, and one of the two allocation strategies. Physical streams own explicit alignment, padded stride, usage, and +capacity class. All reserved bits and fields are zero and unknown flags fail registration. V0 does not alias several logical stores into one mutable interleaved byte span. Augmentation instead combines semantic @@ -637,6 +639,10 @@ Augmentation examples include packing `origin + size` into `vec4`, adding atlas/ object IDs, quantizing fields, or requesting per-glyph bounds. It may not choose line breaks, mutate cluster order, or change semantic positions. +The policy program is the only bytecode in this design. It is a validated, forward-only packing expression executed by +Rust while compiling physical records; it has no loop, backward branch, arbitrary address, allocator, host callback, or +layout/shaping authority. The render plan below is fixed-record data, not executable bytecode. + ## Render-plan IR The render plan is a revisioned display-list and resource transaction, following the separation used by retained @@ -666,7 +672,7 @@ Bitmap uses `vec2`/`vec4` records and MSDF and Slug use `vec4`/`uvec4` records, consumer proves schema, patch, revision, and retirement semantics without claiming another renderer integration. The V0 wire checkpoint uses a 144-byte, 16-byte-aligned result header followed by compiler-mapped little-endian tables: -44-byte semantic, 40-byte resource, 36-byte physical-buffer, 36-byte patch, 64-byte primitive, 48-byte draw, 24-byte +44-byte semantic, 40-byte resource, 36-byte physical-buffer, 36-byte patch, 64-byte primitive, 60-byte draw, 24-byte retirement, and 24-byte diagnostic records. Resource kind and create/update/retain action are separate. Buffer strategy is an explicit ordered-direct or stable-indirect tag. Variable patch payload bytes are part of the same immutable publication; write patches rebase their checked payload span to an absolute result offset. Other patch opcodes carry no @@ -698,18 +704,31 @@ replacement, style edits, and flow changes at the start, middle, and end of larg If `required_base_revision` does not match the consumer, the engine returns a checkpoint containing complete live state. Skipped render revisions can never be repaired by applying an adjacent delta blindly. -The first retained compiler slice implements ordered-direct physical storage behind an explicit prepare/view/commit-or- -abort lifecycle. Stable instance IDs and semantic content revisions—not physical byte comparison—select dirty records. +The retained ordered-direct compiler implements physical storage behind an explicit prepare/view/commit-or-abort +lifecycle. Stable instance IDs and semantic content revisions—not physical byte comparison—select dirty records. Capability alignment expands ranges at record granularity; gap/call costs, fragmentation budget, and the whole-buffer threshold coalesce them. Consecutive changed inputs stay batched through the four-record SIMD policy executor. A no-op produces no resource, buffer, patch, retirement, or payload records; a tail deletion changes live metadata without an upload; a middle insertion rewrites only that resource/program batch's suffix. Checkpoint/growth allocates and writes -complete aligned storage. CPU mirrors change only on commit, so failed A/B serialization can abort preparation. - -This slice deliberately does not yet claim a complete display list: primitive/draw compilation, stable-indirect order -storage, session integration, and target-hardware timing remain open in Stage 2. Its production Wasm code is currently -unreachable from `text_update` and is removed by LTO; that keeps the shipping path unchanged while the missing tables -land, rather than treating native unit behavior as end-to-end evidence. +complete aligned storage. CPU mirrors change only on commit, so failed A/B serialization can abort preparation. A dirty +transaction republishes the complete compact binding and command tables while its fat physical payload stays delta- +minimal. Glyph primitives are spans over consecutive physical records, split by logical run, binding identity, or the +65,535-record wire limit; this avoids publishing one 64-byte command per glyph. Draw packets carry numeric material, +clip, and depth identities plus exact resource/buffer table ranges. No-op preparation publishes no table or payload. + +Storage and draw compatibility are deliberately independent. The standard shared-storage policy puts material in the +draw key but not the storage key, so different materials reference ranges in the same physical glyph buffers. A policy +may put material in both keys when a fallback or custom per-material schema requires separate physical buffers. This is +not left to the adapter after publication: focused tests prove both plans. The distinction matters for the pinned Three +implementation because its ordinary WebGPU and WebGL fallback render-object paths both submit `firstInstance = 0`; a +shared-buffer adapter must supply an explicit storage index base, while a partitioned policy avoids that requirement. + +Interleaved `A, A, B, A` resource tests prove three ordered spans over two deduplicated resources and buffers, and the +wire validator accepts the compiled transaction. Stable-indirect order storage, session integration, and target- +hardware timing remain open in Stage 2. The production planner is still unreachable from `text_update` and removed by +LTO; only the expanded reachable wire grammar and independent policy keys change the optimized artifact, from 739,643 +to 739,909 raw bytes, 272,537 to 272,607 gzip bytes, and 214,149 to 214,288 Brotli bytes. This is not end-to-end latency +evidence. ## Performance contract diff --git a/docs/planning/text-effect-composition.md b/docs/planning/text-effect-composition.md index 8d1371fc..9cabb0e9 100644 --- a/docs/planning/text-effect-composition.md +++ b/docs/planning/text-effect-composition.md @@ -1,148 +1,31 @@ --- -type: API Specification +type: Research Concept title: Three.js text effect composition -description: Optional TSL convenience for composing parameterized effects after canonical raster technique shaders while core carries only opaque render variants. -documentation_type: reference -tags: [rendering, effects, tsl, threejs, webgpu, variants] -status: stable +description: Superseded proposal for a bespoke TSL effect layer; custom material authority is the selected direction. +documentation_type: explanation +tags: [rendering, effects, tsl, threejs, superseded] +status: deprecated sources: - - id: raster-contract - resource: ../../packages/text/src/raster.ts - title: Raster module contract - - id: mtsdf-runtime - resource: ../../packages/text/src/raster/msdf.ts - title: MTSDF runtime material and paint implementation - - id: text-runtime - resource: ../../packages/text/src/three/text.ts - title: Framework-neutral Text lifecycle - - id: tsl-skill - resource: ../../.agents/skills/tsl/SKILL.md - title: Repository TSL implementation guidance + - id: material-authority + resource: three-material-authority.md + title: Three material authority - id: core-api resource: core-api.md - title: Core render variants and glyph runs - - id: three-api - resource: three-api.md - title: Three.js text API - - id: typegpu-api - resource: typegpu-api.md - title: TypeGPU raster programs and text engine + title: Core material routing generated: by: openai-codex/gpt-5.6 - at: '2026-08-07T03:25:58Z' + at: '2026-08-08T00:00:00Z' --- # Three.js text effect composition -`TextEffect` is optional Three/TSL program authoring sugar. It is not a core concept. Core carries an integration-defined -`renderVariant` from batch, paragraph, and span state into ordered glyph runs; the selected Three program interprets it. +This proposal is superseded. The package will not add `TextEffect`, `defineTextEffect`, effect lists, an effect parameter +schema, or an effect graph registry. Those concepts duplicate renderer material systems and do not provide ordinary +lighting, depth, shadow, or pipeline authority. -```ts -const chromatic = defineTextEffect(slugShader, { - parameters: { phase: 'f32' }, - compose(base, parameters, context) { - return { - ...base, - color: chromaticColor(base.color, context.paintIndex, parameters.phase), - }; - }, -}); +The selected Rust route carries numeric `material_id` values from the resolved text/span cascade into policy-shaped draw +packets. The exact Three material-factory API remains under design in [Three material authority](three-material-authority.md). +Reusable TSL functions remain ordinary application helpers for constructing a material; they are not a second text-core +rendering vocabulary. -const text = new Text({ - font, - text: 'Spectrum', - renderVariant: { - effects: [chromatic.bind({ phase: phaseUniform })], - }, -}); -``` - -## Complete helper surface - -```ts -type ThreeEffectParameterSchema = Readonly>; -type ThreeNodeFor = Type extends 'f32' - ? ReturnType - : Type extends 'vec2f' - ? ReturnType - : Type extends 'vec3f' - ? ReturnType - : ReturnType; -type ThreeEffectParametersOf = { - readonly [Key in keyof Schema]: ThreeNodeFor; -}; - -interface ThreeTextEffectDefinition { - readonly shader: Shader; - readonly parameters: Schema; - compose( - base: ThreeRasterFragmentOutputOf, - parameters: ThreeEffectParametersOf, - context: ThreeRasterFragmentContextOf, - ): ThreeRasterFragmentOutputOf; - bind(parameters: ThreeEffectParametersOf): ThreeTextEffectBinding; -} - -interface ThreeTextEffectBinding { - readonly effect: ThreeTextEffectDefinition; - readonly parameters: ThreeEffectParametersOf; -} - -declare function defineTextEffect( - shader: Shader, - definition: Omit, 'shader' | 'bind'>, -): ThreeTextEffectDefinition; - -interface ThreeRenderVariant { - readonly effects?: readonly ThreeTextEffectBinding[]; -} -``` - -The shader argument is what makes the callback contextual: `base`, `context`, and the return type come from that exact -shader, while the literal schema maps every parameter key to its TSL node type. No type parameter is expected to infer only -from a callback parameter position. The heterogeneous binding list is erased only after construction; the standard program -narrows it by retained effect-definition identity before composing or writing parameters. - -## Composition boundary - -Every effect composes after the program's canonical technique shader: - -```ts -let output = slugShader.fragment(context); // canonical curve traversal and coverage -for (const binding of variant.effects ?? []) { - output = composeKnownEffect(output, binding, context); -} -return output; -``` - -Bitmap, MTSDF, and Slug retain atlas/curve sampling, coverage reconstruction, clipping, outline constraints, and -technique-specific validation. An effect changes resolved output; it does not replace the hard raster algorithm. A custom -`ThreeRasterProgram` may bypass this helper and define its own variant contract while still calling the same exported -technique shader. - -## Batching contract - -Effect-definition identity and declaration order determine graph compatibility. Parameter values do not. The standard -program may therefore place bindings for many texts and spans into indexed sidecar storage and draw them together through -one material. A different ordered definition list requires another material/pipeline variant and may split the draw plan. - -Core only preserves variant boundaries and text order. It neither assigns TSL material keys nor forces one draw per effect. -Changing a paragraph/span binding rebuilds core glyph runs without reshaping. Updating a stable uniform or sidecar binding -may require no core call and no instance-buffer rewrite. - -## Required invariants - -- effects compose in declaration order over the previous resolved output; -- graph identity is definition identity plus ordered composition, never current parameter values; -- parameters remain text/span-local even when materials and pipelines are shared; -- semantic context is explicit and small: resolved output, paint/span index, glyph index, and normalized local coordinates; -- unsupported semantic inputs fail while staging and do not replace the live target revision; -- effect bindings and material variants have deterministic leases and disposal; -- TypeGPU-authored pure WebGPU math may adapt only within capabilities proven for the pinned `toTSL()` bridge, while native - TSL effects remain Three-specific; and -- proof measures graph construction, first pipeline creation, parameter updates, upload changes, CPU submit, GPU time, and - untouched-text bundle/pipeline cost. - -The API is complete only after two chained effects, shared graph/independent parameters, Bitmap and Slug composition, -disposal, pinned WebGPURenderer output, and single-draw multi-variant batching have causal tests. This is an integration -feature layered on the accepted core variant contract; failure of the convenience helper cannot remove core customization. +This file remains only to make the rejected direction and its replacement discoverable. diff --git a/docs/planning/three-material-authority.md b/docs/planning/three-material-authority.md index 66689357..fb79e980 100644 --- a/docs/planning/three-material-authority.md +++ b/docs/planning/three-material-authority.md @@ -1,10 +1,10 @@ --- type: Research Concept title: Three material authority for text draws -description: Proposes that a render variant carry an optional user material factory over the exported canonical shaders, so applications compose text materials with ordinary Three.js idioms instead of a bespoke effects vocabulary. -documentation_type: explanation +description: Defines user-owned material factories carried from text and span properties through numeric Rust render-plan material identities. +documentation_type: reference status: draft -tags: [planning, threejs, tsl, materials, render-variants, follow-up] +tags: [planning, threejs, tsl, materials, render-plan] sources: - id: three-api resource: three-api.md @@ -15,110 +15,149 @@ sources: - id: raster-technique resource: raster-technique-api.md title: Raster technique and engine resource API - - id: variant-run-split - resource: ../../packages/text/src/paragraph-batch.ts - title: Ordered run compilation and variant splitting + - id: ordered-plan + resource: ../../packages/text/rust/shaper/src/engine/ordered_plan.rs + title: Rust retained ordered-plan compiler + - id: render-plan + resource: ../../packages/text/rust/shaper/src/engine/render_plan.rs + title: Rust render-plan records - id: bitmap-target resource: ../../packages/text/src/three/bitmap-target.ts - title: Three Bitmap target and its program-owned material - - id: program-registry - resource: ../../packages/text/src/three/program-registry.ts - title: Three raster program registry + title: Three Bitmap target and current program-owned material generated: - by: anthropic-claude/opus-5 - at: '2026-08-07T19:05:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-08T00:00:00Z' --- # Three material authority for text draws -**Status: work in progress.** This records a proposal made during the target-v1 Three slice so it is not lost. Maintainers -have identified incorrect edges in it that are not yet resolved. Treat every section as a starting position for that -discussion rather than an accepted design, and do not implement from it without settling the open questions below. +Applications may supply a material factory at batch, text, or span scope. `material` is one cascaded rendering property; +the nearest authored value wins. It replaces the former generic `renderVariant` name and the unimplemented `TextEffect` +proposal. The Rust engine carries only a numeric `material_id`, while the Three integration owns the corresponding +factory, `NodeMaterial`, bindings, cache, and disposal. -## The problem +This boundary keeps layout and shaping renderer-neutral. Rust never stores a JavaScript object, invokes a host callback, +or interprets a Three material. It uses `material_id` only as policy-directed draw compatibility data and writes it into +the fixed render-plan draw record as `materialId`. -Target-v1's Three targets construct their own material and never expose it: +## Provisional public shape ```ts -new THREE.MeshBasicNodeMaterial({ depthTest: false, depthWrite: false, side: THREE.DoubleSide, transparent: true }) +interface ThreeTextMaterialContext { + /** The package-owned canonical Bitmap, MTSDF, or Slug shader for this exact technique. */ + readonly shader: Shader; + /** Exact resource and instance accessors for the draw being realized. */ + readonly resource: ThreeRasterResourceContextOf; + readonly instance: ThreeRasterInstanceContextOf; + /** Builds the same default material used when no application material is supplied. */ + createDefaultMaterial(): ThreeRasterMaterialOf; +} + +interface ThreeTextMaterial { + create(context: ThreeTextMaterialContext): THREE.NodeMaterial; +} + +declare function defineTextMaterial( + shader: Shader, + create: (context: ThreeTextMaterialContext) => THREE.NodeMaterial, +): ThreeTextMaterial; + +interface TextGroupOptions { + readonly material?: ThreeTextMaterial; +} + +interface TextProperties { + readonly material?: ThreeTextMaterial; +} + +interface TextSpan { + readonly material?: ThreeTextMaterial; +} ``` -That single choice decides more than it appears to. `MeshBasicNodeMaterial` is unlit, so text cannot receive lighting. -Without depth write or depth test, text never enters the depth buffer, so it cannot cast shadows, receive them, or -participate in depth-composited effects such as depth of field. Text renders as a depth-less transparent overlay, which -is a reasonable default for labels and heads-up displays and is part of what makes the pinned Bitmap conformance exact, -but it prevents text from behaving like an ordinary object in a three-dimensional scene. - -Colour-space composition already works: a third party can register a program and compose over the exported canonical -shaders, which is how the composed-program proof masks its own paint over `bitmapShader`. The gap is that doing so -requires implementing an entire `ThreeRasterProgram` — owning buffers, geometry, pipeline, and draw compilation — which -is heavy ceremony for an application that only wants a different material. - -## The proposal - -Three.js attaches materials to objects, and overriding an object's material is ordinary practice. Text should accept a -user-defined material through that same instinct rather than through a bespoke effects vocabulary. - -The routing already exists. Core treats a render variant as opaque render intent and splits ordered runs by it: the -run-merge condition requires `Object.is(previousRun.variant, variant)`, so two variants already produce two runs and -therefore two draws. Nothing new is needed to dispatch a per-variant material. - -So the render variant carries an optional material factory, invoked where each program currently constructs its own -material. That call happens per glyph batch, because each batch binds different pages and textures — which is already -the shape the exported shaders take. +The exact conditional types remain technique-specific in the public declaration. The abbreviated aliases above state +ownership, not a new universal shader context. ```ts -const etched = { - material: (text) => { - const material = new MeshStandardNodeMaterial(); - material.colorNode = mix(base, sheen, text.coverage); - material.opacityNode = text.coverage; - return material; - }, -}; - -new TextGroup({ technique: slug, renderVariant: etched }); +const etched = defineTextMaterial(slugShader, ({ shader, resource, instance }) => { + const raster = shader({ resource, instance }); + const material = new THREE.MeshStandardNodeMaterial({ transparent: true }); + material.positionNode = raster.position; + material.colorNode = mix(raster.color, sheen, raster.coverage); + material.opacityNode = raster.opacity; + return material; +}); + +const text = new Text({ font, text: 'Etched', material: etched }); +text.setSpan(0, { start: 0, end: 3, material: warning }); ``` -Composing a variant of an existing technique then needs no raster module. `registerThreeRasterProgram` remains for -implementing a genuinely new technique, not for restyling one that exists. - -Lighting, shadows, and depth of field stop being this package's concern under this shape. They follow from the material -the application chose — a standard material with depth write enabled participates in depth and lighting like any other -object — rather than from flags this package invents and maintains. - -## Factory rather than subclass - -Export a factory returning exactly the material each program builds internally, so an application starts from that -material and changes one node instead of reconstructing it. A subclassable base class would bind applications to a -package-owned class and work against the ecosystem idiom, which is to assign nodes to a material the application chose. -A factory also keeps the internal path and the application path on the same code, the same property that makes the -exported canonical shaders trustworthy: deleting the export breaks the built-in target. - -## Consequence for the effect placeholder - -`ThreeRenderVariant.effects` is declared and never read. Under this proposal a variant carrying a material is the -effect, so the placeholder becomes unnecessary and should be removed rather than implemented. That also settles the -deferred `TextEffect` helper in the direction of not building it. - -## Constraint - -The default path must stay byte-identical. The pinned Bitmap conformance frame depends on the exact current material -state, so application materials are opt-in per variant and the default variant keeps the program-owned material. - -## Open questions and known incorrect edges - -Maintainers have flagged that this proposal has edges that are wrong as written. Those are not yet enumerated here, and -enumerating them is the first task when this is picked up. Known open items so far: - -- Whether glyph coverage drives a shadow-casting depth prepass cleanly is empirical and unproven. It should be spiked - against a standard material before any of this is committed to. -- The relationship between a per-variant material and paint resolved per glyph is unspecified. Core resolves paint into - canonical instance storage, so a material that ignores the resolved paint would silently discard authored colour, and - the interaction between authored spans and an application material needs a stated rule. -- Whether a material factory can be reconciled with the requirement that programs own variant compatibility and final - draw compilation is unexamined. Two variants that differ only by material may or may not be safely coalescable. -- A separate request asked for text usable as a sampled function, `getTextNode({ coordinates })`, which is a different - model from instanced glyph geometry. Rendering a group to a render target satisfies it today with no package change, - and that route should be documented; an analytic evaluation at arbitrary paragraph coordinates is conceivable for - MSDF and Slug but not for Bitmap, whose atlas sampling and pixel snapping are per quad. +`createDefaultMaterial()` is the DRY path for changing ordinary material state while retaining the package's canonical +placement, coverage, color, and opacity nodes. Creating another `NodeMaterial` is the low-level path for lighting, +shadows, depth writes/tests, and other standard Three behavior. Neither path may replace or duplicate the technique's +glyph coverage algorithm unless the application registers a complete custom raster program. + +## Identity and render-plan route + +The Three integration interns each live `ThreeTextMaterial` by object identity and assigns a nonzero `u32 material_id`; +zero means the built-in default material. The frame request carries the resolved ID on Rust-owned material segments. +Rust resolves the ordinary batch → text → span cascade, maps clusters to one material ID, and preserves that ID through +glyph primitives into draw packets. + +The registered policy has independent storage and draw key masks. Every first-party policy includes material in its draw +key. A capability-specific policy may omit it from the storage key, producing different material draws over ranges in +the same physical glyph buffers, or include it when per-material schemas or backend addressing make distinct buffers +preferable. A third-party policy may omit material from the draw key only when it packs per-instance material selection +and its renderer can draw those materials together; the draw-level `materialId` is then zero and the policy-owned +physical record is authoritative. + +Material assignment changes run/draw planning and any policy-requested material sidecar; it never reshapes text or +recomputes line layout. Stable material-owned uniforms may change outside the core update. Replacing the factory object +changes material identity and schedules render-plan recompilation. + +## Construction, caching, and lifetime + +Factories run only when the Three adapter needs a compatible material/resource realization: first use, a new material +ID, an incompatible resource binding, or a retired realization. They never run in Rust, per glyph, or merely because a +frame was requested. The adapter retains a bounded cache keyed by technique, material ID, program, and resource-binding +compatibility. + +The adapter owns and disposes every material returned by a factory. A factory must return a fresh unowned material for +each invocation; returning a material already owned by another scene object is rejected. Removing a material ID from the +live plan retires it only after the renderer-safe publication/fence boundary. Disposing a material definition still +referenced by a live batch, text, or span is an integration lifecycle error. + +## Paint and batching rules + +Fill, opacity, outline, and shadow remain Rust-resolved per-glyph paint. The canonical shader consumes those values before +the application material composes its output. A custom material may intentionally ignore canonical color, but it cannot +silently disable required glyph placement, clipping, or coverage. Bitmap still rejects outline/shadow; MTSDF retains its +bounded distance-based implementation; Slug remains fill-only until a separately measured shared-traversal design lands. + +Material identity is always independent from shaping/layout and is policy-selectable at the two rendering boundaries. +Adjacent glyph spans with the same draw key form one draw span. Different material IDs may reference the same physical +buffers at different record ranges, or a storage key containing material may partition those records. Rust ordered-plan +tests prove both outcomes rather than leaving the adapter to reinterpret the published plan. + +## Rejected effects layer + +There is no `TextEffect`, effect list, graph-composition registry, or effect parameter schema. Those types duplicated the +material system, introduced a second shader vocabulary, and still could not express ordinary Three lighting/depth/shadow +behavior without material authority. Reusable functions may help applications build `NodeMaterial` graphs, but they are +ordinary Three/TSL code outside the text core contract. + +## Required implementation evidence + +- batch, text, and nested-span material cascade reaches exact `materialId` draw records without shaping/layout work; +- two materials over one resource share physical glyph buffers and produce ordered draw spans; +- Bitmap, MTSDF, and Slug factories consume the exact canonical shaders used by their default targets; +- WebGPURenderer and its WebGL2 fallback preserve placement and coverage for default and custom materials; +- replacement, failure, cache eviction, renderer retirement, and disposal preserve the previous complete frame; +- a lit/depth-writing material proves standard Three lighting, depth, and shadow participation where Three supports it; +- untouched text pays no material-factory call, allocation, pipeline rebuild, or extra package import; and +- package raw/minified/gzip/Brotli and first-pipeline costs are reported before the API is marked implemented. + +The numeric `material_id` route, material-directed draw compatibility, shared physical glyph storage, and rejection of a +second effects vocabulary are settled inputs to the Rust plan. The exact Three factory types above remain provisional for +the later material-design pass. Until its gates pass, the current first-party Three targets remain the implementation +gap; documentation must not describe the factory as already shipped. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index aacc6d37..30bf39a3 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -91,7 +91,7 @@ struct PolicyProgramRecord { capability_set_id: u32, resource_kind_mask: u32, semantic_view_mask: u32, - batch_key_mask: u32, + storage_key_mask: u32, paint_capabilities: u32, compositing_capabilities: u32, buffer_start: u32, @@ -103,7 +103,7 @@ struct PolicyProgramRecord { f32_input_count: u8, u32_input_count: u8, reserved0: u16, - reserved1: u32, + draw_key_mask: u32, } #[repr(C)] @@ -481,9 +481,9 @@ field_offset!( semantic_view_mask ); field_offset!( - POLICY_PROGRAM_BATCH_KEY_MASK, + POLICY_PROGRAM_STORAGE_KEY_MASK, PolicyProgramRecord, - batch_key_mask + storage_key_mask ); field_offset!(POLICY_PROGRAM_VARIANT, PolicyProgramRecord, variant); field_offset!( @@ -532,7 +532,11 @@ field_offset!( PolicyProgramRecord, allocation_strategy ); -field_offset!(POLICY_PROGRAM_RESERVED1, PolicyProgramRecord, reserved1); +field_offset!( + POLICY_PROGRAM_DRAW_KEY_MASK, + PolicyProgramRecord, + draw_key_mask +); field_offset!(POLICY_BUFFER_ID, PolicyBufferRecord, id); field_offset!(POLICY_BUFFER_SCALAR, PolicyBufferRecord, scalar); field_offset!(POLICY_BUFFER_VECTOR_WIDTH, PolicyBufferRecord, vector_width); @@ -896,8 +900,8 @@ field_offset!( resource_generation ); field_offset!(PRIMITIVE_PROGRAM_ID, PrimitiveRecord, program_id); -field_offset!(PRIMITIVE_VARIANT, PrimitiveRecord, variant); -field_offset!(PRIMITIVE_RESERVED, PrimitiveRecord, reserved); +field_offset!(PRIMITIVE_PROGRAM_VARIANT, PrimitiveRecord, program_variant); +field_offset!(PRIMITIVE_RECORD_COUNT, PrimitiveRecord, record_count); field_offset!(PRIMITIVE_BUFFER_ID, PrimitiveRecord, buffer_id); field_offset!(PRIMITIVE_RECORD_INDEX, PrimitiveRecord, record_index); field_offset!(PRIMITIVE_LOGICAL_ORDER, PrimitiveRecord, logical_order); @@ -909,8 +913,11 @@ field_offset!(PRIMITIVE_INLINE_EXTENT, PrimitiveRecord, inline_extent); field_offset!(PRIMITIVE_BLOCK_EXTENT, PrimitiveRecord, block_extent); field_offset!(DRAW_ID, DrawRecord, id); field_offset!(DRAW_PROGRAM_ID, DrawRecord, program_id); -field_offset!(DRAW_VARIANT, DrawRecord, variant); +field_offset!(DRAW_PROGRAM_VARIANT, DrawRecord, program_variant); field_offset!(DRAW_FLAGS, DrawRecord, flags); +field_offset!(DRAW_MATERIAL_ID, DrawRecord, material_id); +field_offset!(DRAW_CLIP_ID, DrawRecord, clip_id); +field_offset!(DRAW_DEPTH_KEY, DrawRecord, depth_key); field_offset!(DRAW_PRIMITIVE_START, DrawRecord, primitive_start); field_offset!(DRAW_PRIMITIVE_COUNT, DrawRecord, primitive_count); field_offset!(DRAW_BUFFER_START, DrawRecord, buffer_start); @@ -1124,7 +1131,8 @@ pub fn json() -> String { "capabilitySetId": POLICY_PROGRAM_CAPABILITY_SET_ID, "resourceKindMask": POLICY_PROGRAM_RESOURCE_KIND_MASK, "semanticViewMask": POLICY_PROGRAM_SEMANTIC_VIEW_MASK, - "batchKeyMask": POLICY_PROGRAM_BATCH_KEY_MASK, + "storageKeyMask": POLICY_PROGRAM_STORAGE_KEY_MASK, + "drawKeyMask": POLICY_PROGRAM_DRAW_KEY_MASK, "variant": POLICY_PROGRAM_VARIANT, "f32InputCount": POLICY_PROGRAM_F32_INPUT_COUNT, "u32InputCount": POLICY_PROGRAM_U32_INPUT_COUNT, @@ -1135,8 +1143,7 @@ pub fn json() -> String { "reserved0": POLICY_PROGRAM_RESERVED0, "operationStart": POLICY_PROGRAM_OPERATION_START, "operationCount": POLICY_PROGRAM_OPERATION_COUNT, - "allocationStrategy": POLICY_PROGRAM_ALLOCATION_STRATEGY, - "reserved1": POLICY_PROGRAM_RESERVED1 + "allocationStrategy": POLICY_PROGRAM_ALLOCATION_STRATEGY }, "policyBuffer": { "size": POLICY_BUFFER_RECORD_SIZE, @@ -1304,8 +1311,8 @@ pub fn json() -> String { "resourceId": PRIMITIVE_RESOURCE_ID, "resourceGeneration": PRIMITIVE_RESOURCE_GENERATION, "programId": PRIMITIVE_PROGRAM_ID, - "variant": PRIMITIVE_VARIANT, - "reserved": PRIMITIVE_RESERVED, + "programVariant": PRIMITIVE_PROGRAM_VARIANT, + "recordCount": PRIMITIVE_RECORD_COUNT, "bufferId": PRIMITIVE_BUFFER_ID, "recordIndex": PRIMITIVE_RECORD_INDEX, "logicalOrder": PRIMITIVE_LOGICAL_ORDER, @@ -1321,8 +1328,11 @@ pub fn json() -> String { "alignment": DRAW_RECORD_ALIGNMENT, "id": DRAW_ID, "programId": DRAW_PROGRAM_ID, - "variant": DRAW_VARIANT, + "programVariant": DRAW_PROGRAM_VARIANT, "flags": DRAW_FLAGS, + "materialId": DRAW_MATERIAL_ID, + "clipId": DRAW_CLIP_ID, + "depthKey": DRAW_DEPTH_KEY, "primitiveStart": DRAW_PRIMITIVE_START, "primitiveCount": DRAW_PRIMITIVE_COUNT, "bufferStart": DRAW_BUFFER_START, diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index 1c87e85a..a7b3cb03 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -9,28 +9,37 @@ use core::{mem, slice}; use super::{ policy::{ - ALLOCATION_ORDERED_DIRECT, BufferSchema, CapabilitySetId, PhysicalBufferMut, - PolicyExecutionError, SemanticInputBatch, TechniqueId, ValidatedPolicy, + ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BufferSchema, CapabilitySetId, + PhysicalBufferMut, PolicyExecutionError, SemanticInputBatch, TechniqueId, ValidatedPolicy, }, render_plan::{ - BUFFER_ORDERED_DIRECT, BufferRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, PatchRecord, - RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RETIRE_BUFFER, RETIRE_RESOURCE, - RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, RetirementRecord, + BUFFER_ORDERED_DIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, + PRIMITIVE_GLYPH, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, + RESOURCE_ACTION_RETAIN, RETIRE_BUFFER, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, + ResourceRecord, RetirementRecord, }, }; const MAX_PHYSICAL_BUFFERS: usize = 16; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct OrderedGlyph { pub stable_id: u32, pub content_revision: u32, pub technique: TechniqueId, - pub variant: u16, + pub program_variant: u16, pub resource_id: u32, pub resource_generation: u32, pub resource_kind: u16, pub resource_reference: u32, + pub semantic_id: u32, + pub material_id: u32, + pub clip_id: u32, + pub depth_key: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, } #[derive(Clone, Copy)] @@ -61,12 +70,15 @@ pub enum OrderedPlanError { #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct BatchKey { technique: TechniqueId, - variant: u16, + program_variant: u16, program_id: u32, resource_id: u32, resource_generation: u32, resource_kind: u16, resource_reference: u32, + material_id: u32, + clip_id: u32, + depth_key: u32, } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -98,7 +110,7 @@ struct PhysicalBufferState { struct PendingBatch { state: BatchState, prior_index: Option, - changed: bool, + capacity: u32, buffer_ids: [u32; MAX_PHYSICAL_BUFFERS], buffer_generations: [u32; MAX_PHYSICAL_BUFFERS], } @@ -134,6 +146,7 @@ pub struct OrderedPlanCompiler { pending_instances: Vec, pending_allocations: Vec, input_batches: Vec, + input_slots: Vec, identity_keys: Vec, identity_epochs: Vec, identity_epoch: u32, @@ -141,11 +154,16 @@ pub struct OrderedPlanCompiler { changed_ranges: Vec, resources: Vec, plan_buffers: Vec, + primitives: Vec, + draws: Vec, + live_primitives: Vec, + live_draws: Vec, patches: Vec, retirements: Vec, payload: Vec, next_buffer_id: u32, pending_next_buffer_id: u32, + publish_bindings: bool, prepared: bool, } @@ -170,8 +188,10 @@ impl OrderedPlanCompiler { validate_input(input)?; self.reset_pending(); reserve(&mut self.input_batches, input.glyphs.len())?; + reserve(&mut self.input_slots, input.glyphs.len())?; reserve(&mut self.pending_instances, input.glyphs.len())?; self.input_batches.resize(input.glyphs.len(), 0); + self.input_slots.resize(input.glyphs.len(), 0); self.prepare_identity_set(input.glyphs.len())?; for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { @@ -180,7 +200,7 @@ impl OrderedPlanCompiler { return Err(OrderedPlanError::DuplicateIdentity); } let program = policy - .program(capability_set, glyph.technique, glyph.variant) + .program(capability_set, glyph.technique, glyph.program_variant) .ok_or(OrderedPlanError::ProgramMissing)?; if program.allocation_strategy != ALLOCATION_ORDERED_DIRECT { return Err(OrderedPlanError::UnsupportedStrategy); @@ -193,12 +213,27 @@ impl OrderedPlanCompiler { } let key = BatchKey { technique: glyph.technique, - variant: glyph.variant, + program_variant: glyph.program_variant, program_id: program.id.0, resource_id: glyph.resource_id, resource_generation: glyph.resource_generation, resource_kind: glyph.resource_kind, resource_reference: glyph.resource_reference, + material_id: if program.storage_key_mask & BATCH_MATERIAL != 0 { + glyph.material_id + } else { + 0 + }, + clip_id: if program.storage_key_mask & super::policy::BATCH_CLIP != 0 { + glyph.clip_id + } else { + 0 + }, + depth_key: if program.storage_key_mask & super::policy::BATCH_DEPTH != 0 { + glyph.depth_key + } else { + 0 + }, }; let batch_index = match self .pending_batches @@ -222,7 +257,7 @@ impl OrderedPlanCompiler { buffer_count: 0, }, prior_index, - changed: checkpoint || prior_index.is_none(), + capacity: 0, buffer_ids: [0; MAX_PHYSICAL_BUFFERS], buffer_generations: [0; MAX_PHYSICAL_BUFFERS], }); @@ -253,6 +288,7 @@ impl OrderedPlanCompiler { self.prepare_batch(context, batch_index)?; } self.prepare_removed_batches(publication_generation)?; + self.compile_bindings(context)?; self.prepared = true; Ok(()) } @@ -266,14 +302,36 @@ impl OrderedPlanCompiler { if !self.prepared { return Err(OrderedPlanError::NotPrepared); } + let resources = if self.publish_bindings { + self.resources.as_slice() + } else { + &[] + }; + let buffers = if self.publish_bindings { + self.plan_buffers.as_slice() + } else { + &[] + }; + let primitives = if self.publish_bindings { + self.primitives.as_slice() + } else { + &[] + }; + let draws = if self.publish_bindings { + self.draws.as_slice() + } else { + &[] + }; Ok(RenderPlanView { policy_handle, capability_set: capability_set.0, policy_fingerprint, - resources: &self.resources, - buffers: &self.plan_buffers, + resources, + buffers, patches: &self.patches, retirements: &self.retirements, + primitives, + draws, payload: &self.payload, ..RenderPlanView::default() }) @@ -319,6 +377,10 @@ impl OrderedPlanCompiler { self.spare_batches.clear(); mem::swap(&mut self.instances, &mut self.pending_instances); self.pending_instances.clear(); + mem::swap(&mut self.live_primitives, &mut self.primitives); + self.primitives.clear(); + mem::swap(&mut self.live_draws, &mut self.draws); + self.draws.clear(); self.next_buffer_id = self.pending_next_buffer_id; self.prepared = false; Ok(()) @@ -351,9 +413,12 @@ impl OrderedPlanCompiler { self.changed_ranges.clear(); self.resources.clear(); self.plan_buffers.clear(); + self.primitives.clear(); + self.draws.clear(); self.patches.clear(); self.retirements.clear(); self.payload.clear(); + self.publish_bindings = false; } fn prepare_identity_set(&mut self, count: usize) -> Result<(), OrderedPlanError> { @@ -422,6 +487,9 @@ impl OrderedPlanCompiler { content_revision: glyph.content_revision, input_index: input_index as u32, }; + self.input_slots[input_index] = u32::try_from(destination) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)? + - self.pending_batches[batch].state.instance_start; self.batch_cursors[batch] = self.batch_cursors[batch] .checked_add(1) .ok_or(OrderedPlanError::ArithmeticOverflow)?; @@ -445,7 +513,7 @@ impl OrderedPlanCompiler { let pending = self.pending_batches[batch_index]; let key = pending.state.key; let program = policy - .program(capability_set, key.technique, key.variant) + .program(capability_set, key.technique, key.program_variant) .ok_or(OrderedPlanError::ProgramMissing)?; let prior = pending .prior_index @@ -471,8 +539,8 @@ impl OrderedPlanCompiler { return Err(OrderedPlanError::CapacityExceeded); } } + self.pending_batches[batch_index].capacity = capacity; - let buffer_start = self.plan_buffers.len(); let new_or_resized = prior.is_none() || capacity != prior_capacity; let prior_instances = match prior { Some(batch) => self @@ -490,37 +558,6 @@ impl OrderedPlanCompiler { checkpoint || new_or_resized, )?; coalesce_ranges(&mut self.changed_ranges, program, capability, required)?; - if !self.changed_ranges.is_empty() - || prior.is_some_and(|old| old.instance_count != required) - { - self.pending_batches[batch_index].changed = true; - } - - if self.pending_batches[batch_index].changed { - let existing_resource = self.batches.iter().any(|batch| { - batch.key.resource_id == key.resource_id - && batch.key.resource_generation == key.resource_generation - }); - if !self.resources.iter().any(|resource| { - resource.id == key.resource_id && resource.generation == key.resource_generation - }) { - reserve(&mut self.resources, 1)?; - self.resources.push(ResourceRecord { - id: key.resource_id, - generation: key.resource_generation, - technique_id: key.technique.0, - resource_kind: key.resource_kind, - action: if checkpoint || !existing_resource { - RESOURCE_ACTION_CREATE - } else { - RESOURCE_ACTION_RETAIN - }, - reference_id: key.resource_reference, - ..ResourceRecord::default() - }); - } - } - for (schema_index, schema) in program.buffers.iter().copied().enumerate() { let previous = prior .and_then(|batch| self.buffers.get(batch.buffer_start as usize + schema_index)) @@ -543,25 +580,6 @@ impl OrderedPlanCompiler { .ok_or(OrderedPlanError::IdentifierExhausted)?; (self.pending_next_buffer_id, 1) }; - if self.pending_batches[batch_index].changed { - reserve(&mut self.plan_buffers, 1)?; - self.plan_buffers.push(BufferRecord { - id, - generation, - program_id: key.program_id, - policy_buffer_id: schema.id.0, - scalar_type: schema.scalar as u8, - vector_width: schema.vector_width, - strategy: BUFFER_ORDERED_DIRECT, - flags: schema.usage as u16, - live_records: required, - capacity_records: capacity, - byte_length: capacity - .checked_mul(u32::from(schema.stride)) - .ok_or(OrderedPlanError::ArithmeticOverflow)?, - order_buffer_id: 0, - }); - } if checkpoint || new_or_resized { reserve(&mut self.patches, 1)?; self.patches.push(PatchRecord { @@ -599,9 +617,8 @@ impl OrderedPlanCompiler { input, program, prior, - pending, + self.pending_batches[batch_index], checkpoint || new_or_resized, - buffer_start, )?; if let Some(prior) = prior && required < prior.instance_count @@ -623,8 +640,6 @@ impl OrderedPlanCompiler { }); } } - self.pending_batches[batch_index].state.buffer_start = - u32::try_from(buffer_start).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; self.pending_batches[batch_index].state.buffer_count = u16::try_from(program.buffers.len()) .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; Ok(()) @@ -641,7 +656,6 @@ impl OrderedPlanCompiler { prior: Option, pending: PendingBatch, replace: bool, - plan_buffer_start: usize, ) -> Result<(), OrderedPlanError> { let record_alignment = record_alignment(program, capability.update_alignment)?; let next_instances = &self.pending_instances @@ -711,10 +725,8 @@ impl OrderedPlanCompiler { } for (schema_index, schema) in program.buffers.iter().enumerate() { - let record = self - .plan_buffers - .get(plan_buffer_start + schema_index) - .ok_or(OrderedPlanError::InvalidIdentity)?; + let buffer_id = pending.buffer_ids[schema_index]; + let buffer_generation = pending.buffer_generations[schema_index]; let byte_length = count .checked_mul(u32::from(schema.stride)) .ok_or(OrderedPlanError::ArithmeticOverflow)?; @@ -725,8 +737,8 @@ impl OrderedPlanCompiler { reserve(&mut self.patches, 1)?; self.patches.push(PatchRecord { opcode: PATCH_WRITE, - buffer_id: record.id, - buffer_generation: record.generation, + buffer_id, + buffer_generation, destination_offset, byte_length, payload_start: u32::try_from(payload_starts[schema_index]) @@ -734,8 +746,8 @@ impl OrderedPlanCompiler { ..PatchRecord::default() }); if let Some(allocation) = self.pending_allocations.iter_mut().find(|allocation| { - allocation.state.id == record.id - && allocation.state.generation == record.generation + allocation.state.id == buffer_id + && allocation.state.generation == buffer_generation }) { let destination = destination_offset as usize; let source = payload_starts[schema_index]; @@ -786,6 +798,199 @@ impl OrderedPlanCompiler { Ok(()) } + fn compile_bindings(&mut self, context: PrepareContext<'_>) -> Result<(), OrderedPlanError> { + for batch_index in 0..self.pending_batches.len() { + let batch = self.pending_batches[batch_index]; + let program = context + .policy + .program( + context.capability_set, + batch.state.key.technique, + batch.state.key.program_variant, + ) + .ok_or(OrderedPlanError::ProgramMissing)?; + if usize::from(batch.state.buffer_count) + > usize::from(context.capability.max_buffers_per_draw) + { + return Err(OrderedPlanError::CapacityExceeded); + } + let buffer_start = self.plan_buffers.len(); + reserve(&mut self.plan_buffers, program.buffers.len())?; + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + self.plan_buffers.push(BufferRecord { + id: batch.buffer_ids[schema_index], + generation: batch.buffer_generations[schema_index], + program_id: batch.state.key.program_id, + policy_buffer_id: schema.id.0, + scalar_type: schema.scalar as u8, + vector_width: schema.vector_width, + strategy: BUFFER_ORDERED_DIRECT, + flags: schema.usage as u16, + live_records: batch.state.instance_count, + capacity_records: batch.capacity, + byte_length: batch + .capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(OrderedPlanError::ArithmeticOverflow)?, + order_buffer_id: 0, + }); + } + self.pending_batches[batch_index].state.buffer_start = + u32::try_from(buffer_start).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + } + + for glyph in context.input.glyphs.iter().copied() { + if let Some(resource) = self.resources.iter().find(|resource| { + resource.id == glyph.resource_id && resource.generation == glyph.resource_generation + }) { + if resource.technique_id != glyph.technique.0 + || resource.resource_kind != glyph.resource_kind + || resource.reference_id != glyph.resource_reference + { + return Err(OrderedPlanError::InvalidResource); + } + continue; + } + reserve(&mut self.resources, 1)?; + let existing = self.batches.iter().any(|batch| { + batch.key.resource_id == glyph.resource_id + && batch.key.resource_generation == glyph.resource_generation + }); + self.resources.push(ResourceRecord { + id: glyph.resource_id, + generation: glyph.resource_generation, + technique_id: glyph.technique.0, + resource_kind: glyph.resource_kind, + action: if context.checkpoint || !existing { + RESOURCE_ACTION_CREATE + } else { + RESOURCE_ACTION_RETAIN + }, + reference_id: glyph.resource_reference, + ..ResourceRecord::default() + }); + } + + if context.capability.max_resources_per_draw < 1 { + return Err(OrderedPlanError::CapacityExceeded); + } + let mut input_index = 0_usize; + while input_index < context.input.glyphs.len() { + let first = context.input.glyphs[input_index]; + let batch_index = self.input_batches[input_index] as usize; + let first_slot = self.input_slots[input_index]; + let program = context + .policy + .program( + context.capability_set, + first.technique, + first.program_variant, + ) + .ok_or(OrderedPlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let mut end = input_index + 1; + while end < context.input.glyphs.len() + && end - input_index < usize::from(u16::MAX) + && self.same_draw_span( + context.input.glyphs, + input_index, + end, + batch_index, + first_slot, + split_material, + ) + { + end += 1; + } + let count = u16::try_from(end - input_index) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = + span_bounds(&context.input.glyphs[input_index..end])?; + let batch = self.pending_batches[batch_index]; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(OrderedPlanError::InvalidResource)?; + let primitive_start = self.primitives.len(); + reserve(&mut self.primitives, 1)?; + self.primitives.push(PrimitiveRecord { + id: first.stable_id, + kind: PRIMITIVE_GLYPH, + technique_id: first.technique.0, + resource_id: first.resource_id, + resource_generation: first.resource_generation, + program_id: batch.state.key.program_id, + program_variant: first.program_variant, + record_count: count, + buffer_id: batch.buffer_ids[0], + record_index: first_slot, + logical_order: u32::try_from(input_index) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + clip_id: first.clip_id, + semantic_id: first.semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + ..PrimitiveRecord::default() + }); + reserve(&mut self.draws, 1)?; + self.draws.push(DrawRecord { + id: first.stable_id, + program_id: batch.state.key.program_id, + program_variant: first.program_variant, + material_id: if split_material { first.material_id } else { 0 }, + clip_id: first.clip_id, + depth_key: first.depth_key, + primitive_start: u32::try_from(primitive_start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + primitive_count: 1, + buffer_start: batch.state.buffer_start, + buffer_count: u32::from(batch.state.buffer_count), + resource_start: u32::try_from(resource_start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + resource_count: 1, + order_token: u32::try_from(input_index) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + ..DrawRecord::default() + }); + input_index = end; + } + self.publish_bindings = context.checkpoint + || !self.patches.is_empty() + || !self.retirements.is_empty() + || self.primitives != self.live_primitives + || self.draws != self.live_draws; + Ok(()) + } + + fn same_draw_span( + &self, + glyphs: &[OrderedGlyph], + start: usize, + next: usize, + batch_index: usize, + first_slot: u32, + split_material: bool, + ) -> bool { + let first = glyphs[start]; + let glyph = glyphs[next]; + self.input_batches[next] as usize == batch_index + && self.input_slots[next] == first_slot + (next - start) as u32 + && glyph.technique == first.technique + && glyph.program_variant == first.program_variant + && glyph.resource_id == first.resource_id + && glyph.resource_generation == first.resource_generation + && (!split_material || glyph.material_id == first.material_id) + && glyph.clip_id == first.clip_id + && glyph.depth_key == first.depth_key + && glyph.semantic_id == first.semantic_id + } + fn prepare_removed_batches( &mut self, publication_generation: u32, @@ -867,9 +1072,40 @@ fn validate_glyph(glyph: OrderedGlyph) -> Result<(), OrderedPlanError> { { return Err(OrderedPlanError::InvalidResource); } + if !glyph.inline_start.is_finite() + || !glyph.block_start.is_finite() + || !glyph.inline_extent.is_finite() + || !glyph.block_extent.is_finite() + || glyph.inline_extent < 0.0 + || glyph.block_extent < 0.0 + || !(glyph.inline_start + glyph.inline_extent).is_finite() + || !(glyph.block_start + glyph.block_extent).is_finite() + { + return Err(OrderedPlanError::InvalidInputShape); + } Ok(()) } +fn span_bounds(glyphs: &[OrderedGlyph]) -> Result<(f32, f32, f32, f32), OrderedPlanError> { + let first = glyphs.first().ok_or(OrderedPlanError::InvalidInputShape)?; + let mut inline_start = first.inline_start; + let mut block_start = first.block_start; + let mut inline_end = first.inline_start + first.inline_extent; + let mut block_end = first.block_start + first.block_extent; + for glyph in &glyphs[1..] { + inline_start = inline_start.min(glyph.inline_start); + block_start = block_start.min(glyph.block_start); + inline_end = inline_end.max(glyph.inline_start + glyph.inline_extent); + block_end = block_end.max(glyph.block_start + glyph.block_extent); + } + let inline_extent = inline_end - inline_start; + let block_extent = block_end - block_start; + if !inline_extent.is_finite() || !block_extent.is_finite() { + return Err(OrderedPlanError::InvalidInputShape); + } + Ok((inline_start, block_start, inline_extent, block_extent)) +} + fn collect_changed_ranges( ranges: &mut Vec, previous: &[InstanceState], @@ -1173,9 +1409,9 @@ fn reserve(values: &mut Vec, additional: usize) -> Result<(), OrderedPlanE mod tests { use super::*; use crate::engine::policy::{ - BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, - BufferId, CAP_ORDERED_DIRECT, CapabilitySet, Operation, PolicyDescriptor, - ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, + BATCH_MATERIAL, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, CAP_ORDERED_DIRECT, CapabilitySet, + Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, }; use crate::engine::render_plan_wire::plan_layout; use alloc::vec; @@ -1195,6 +1431,11 @@ mod tests { .unwrap(); assert_eq!(first.buffers.len(), 1); assert_eq!(first.patches.len(), 2); + assert_eq!(first.primitives.len(), 1); + assert_eq!(first.primitives[0].record_count, 3); + assert_eq!(first.draws.len(), 1); + assert_eq!(first.draws[0].buffer_count, 1); + assert_eq!(first.draws[0].resource_count, 1); assert!(plan_layout(first).unwrap().byte_length > 144); compiler.commit().unwrap(); @@ -1208,6 +1449,8 @@ mod tests { assert_eq!(delta.patches[0].destination_offset, 4); assert_eq!(delta.patches[0].byte_length, 4); assert_eq!(delta.payload.len(), 4); + assert_eq!(delta.primitives, first_span(3).as_slice()); + assert_eq!(delta.draws.len(), 1); compiler.commit().unwrap(); assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 4), 20.0); } @@ -1258,6 +1501,8 @@ mod tests { assert!(no_op.resources.is_empty()); assert!(no_op.buffers.is_empty()); assert!(no_op.patches.is_empty()); + assert!(no_op.primitives.is_empty()); + assert!(no_op.draws.is_empty()); assert!(no_op.payload.is_empty()); compiler.commit().unwrap(); assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 0), 1.0); @@ -1273,6 +1518,99 @@ mod tests { assert_eq!(shrink.retirements[0].byte_length, 4); } + #[test] + fn interleaved_resources_compile_to_ordered_spans_with_shared_bindings() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0, 3.0, 4.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.resources.len(), 2); + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.primitives.len(), 3); + assert_eq!(plan.draws.len(), 3); + assert_eq!(plan.primitives[0].record_count, 2); + assert_eq!(plan.primitives[0].record_index, 0); + assert_eq!(plan.primitives[1].resource_id, 12); + assert_eq!(plan.primitives[2].resource_id, 11); + assert_eq!(plan.primitives[2].record_index, 2); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert_eq!(plan.draws[2].order_token, 3); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn material_identity_splits_draws_without_splitting_physical_storage() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.buffers.len(), 1); + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.draws[0].material_id, 1); + assert_eq!(plan.draws[1].material_id, 2); + assert_eq!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn policy_can_partition_physical_storage_by_material() { + let policy = policy_with_material_storage(true); + let mut compiler = OrderedPlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.draws.len(), 2); + assert_ne!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn glyph_spans_split_at_the_wire_record_limit() { + let policy = policy_with_limits(false, 512 * 1024); + let mut compiler = OrderedPlanCompiler::default(); + let glyphs: Vec<_> = (1..=u32::from(u16::MAX) + 1) + .map(|stable_id| glyph(stable_id, 1)) + .collect(); + let x = vec![0.0; glyphs.len()]; + prepare(&mut compiler, &policy, &glyphs, &x, true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.primitives[0].record_count, u16::MAX); + assert_eq!(plan.primitives[1].record_count, 1); + assert_eq!(plan.primitives[1].record_index, u32::from(u16::MAX)); + assert_eq!(plan.draws.len(), 2); + assert!(plan_layout(plan).is_ok()); + } + #[test] fn repeated_warm_updates_keep_every_glyph_scaled_scratch_capacity() { let policy = policy(); @@ -1335,20 +1673,36 @@ mod tests { stable_id, content_revision, technique: TECHNIQUE, - variant: 0, + program_variant: 0, resource_id: 11, resource_generation: 1, resource_kind: 1, resource_reference: 99, + semantic_id: 1, + material_id: 1, + clip_id: 0, + depth_key: 0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 1.0, + block_extent: 1.0, } } fn policy() -> ValidatedPolicy { + policy_with_material_storage(false) + } + + fn policy_with_material_storage(partition_materials: bool) -> ValidatedPolicy { + policy_with_limits(partition_materials, 1024) + } + + fn policy_with_limits(partition_materials: bool, max_buffer_bytes: u32) -> ValidatedPolicy { ValidatedPolicy::new(PolicyDescriptor { capability_sets: vec![CapabilitySet { id: CAPABILITY, flags: CAP_ORDERED_DIRECT, - max_buffer_bytes: 1024, + max_buffer_bytes, update_alignment: 4, coalesce_gap_bytes: 0, range_call_penalty_bytes: 0, @@ -1365,7 +1719,19 @@ mod tests { capability_set: CapabilitySetId(0), resource_kind_mask: 1, semantic_view_mask: 0, - batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + storage_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | if partition_materials { + BATCH_MATERIAL + } else { + 0 + }, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_MATERIAL + | BATCH_ORDER, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, @@ -1397,18 +1763,46 @@ mod tests { f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) } - fn capacities(compiler: &OrderedPlanCompiler) -> [usize; 13] { + fn first_span(record_count: u16) -> [PrimitiveRecord; 1] { + [PrimitiveRecord { + id: 1, + kind: PRIMITIVE_GLYPH, + technique_id: TECHNIQUE.0, + resource_id: 11, + resource_generation: 1, + program_id: 5, + program_variant: 0, + record_count, + buffer_id: 1, + record_index: 0, + logical_order: 0, + clip_id: 0, + semantic_id: 1, + inline_start: 1.0, + block_start: 0.0, + inline_extent: record_count as f32, + block_extent: 1.0, + ..PrimitiveRecord::default() + }] + } + + fn capacities(compiler: &OrderedPlanCompiler) -> [usize; 18] { [ compiler.pending_batches.capacity(), compiler.pending_instances.capacity(), compiler.pending_allocations.capacity(), compiler.input_batches.capacity(), + compiler.input_slots.capacity(), compiler.identity_keys.capacity(), compiler.identity_epochs.capacity(), compiler.batch_cursors.capacity(), compiler.changed_ranges.capacity(), compiler.resources.capacity(), compiler.plan_buffers.capacity(), + compiler.primitives.capacity(), + compiler.draws.capacity(), + compiler.live_primitives.capacity(), + compiler.live_draws.capacity(), compiler.patches.capacity(), compiler.retirements.capacity(), compiler.payload.capacity(), diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 4d4e3609..00c712b4 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -41,6 +41,9 @@ const BATCH_FIELDS: u32 = BATCH_TECHNIQUE | BATCH_CLIP | BATCH_DEPTH | BATCH_ORDER; +const STORAGE_KEY_FIELDS: u32 = BATCH_FIELDS & !BATCH_ORDER; +const REQUIRED_STORAGE_KEYS: u32 = BATCH_TECHNIQUE | BATCH_RESOURCE | BATCH_PROGRAM; +const REQUIRED_DRAW_KEYS: u32 = REQUIRED_STORAGE_KEYS | BATCH_ORDER; pub const BUFFER_USAGE_VERTEX: u32 = 1 << 0; pub const BUFFER_USAGE_STORAGE: u32 = 1 << 1; @@ -232,7 +235,8 @@ pub struct ProgramDescriptor { pub capability_set: CapabilitySetId, pub resource_kind_mask: u32, pub semantic_view_mask: u32, - pub batch_key_mask: u32, + pub storage_key_mask: u32, + pub draw_key_mask: u32, pub allocation_strategy: u16, pub f32_input_count: u8, pub u32_input_count: u8, @@ -377,7 +381,8 @@ fn policy_fingerprint(descriptor: &PolicyDescriptor) -> u64 { mix_u32(&mut fingerprint, program.capability_set.0); mix_u32(&mut fingerprint, program.resource_kind_mask); mix_u32(&mut fingerprint, program.semantic_view_mask); - mix_u32(&mut fingerprint, program.batch_key_mask); + mix_u32(&mut fingerprint, program.storage_key_mask); + mix_u32(&mut fingerprint, program.draw_key_mask); mix_u32(&mut fingerprint, u32::from(program.allocation_strategy)); mix_u32(&mut fingerprint, u32::from(program.variant)); mix_u32(&mut fingerprint, u32::from(program.f32_input_count)); @@ -951,8 +956,10 @@ fn validate_policy(descriptor: &PolicyDescriptor) -> Result<(), PolicyError> { if program.resource_kind_mask == 0 { return Err(PolicyError::InvalidResourceKinds); } - if program.batch_key_mask & !BATCH_FIELDS != 0 - || program.batch_key_mask & BATCH_PROGRAM == 0 + if program.storage_key_mask & !STORAGE_KEY_FIELDS != 0 + || program.storage_key_mask & REQUIRED_STORAGE_KEYS != REQUIRED_STORAGE_KEYS + || program.draw_key_mask & !BATCH_FIELDS != 0 + || program.draw_key_mask & REQUIRED_DRAW_KEYS != REQUIRED_DRAW_KEYS { return Err(PolicyError::InvalidBatchKey); } @@ -1305,7 +1312,8 @@ mod tests { capability_set: CapabilitySetId(0), resource_kind_mask: 1, semantic_view_mask: 0, - batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 2, u32_input_count: 0, @@ -1445,6 +1453,28 @@ mod tests { assert_eq!(policy.programs().len(), 2); } + #[test] + fn storage_and_draw_keys_validate_independently() { + let mut missing_storage_resource = valid_program(); + missing_storage_resource.storage_key_mask = BATCH_TECHNIQUE | BATCH_PROGRAM; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![missing_storage_resource])).unwrap_err(), + PolicyError::InvalidBatchKey, + ); + + let mut missing_draw_order = valid_program(); + missing_draw_order.draw_key_mask = BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE; + assert_eq!( + ValidatedPolicy::new(descriptor(vec![missing_draw_order])).unwrap_err(), + PolicyError::InvalidBatchKey, + ); + + let mut material_partitioned = valid_program(); + material_partitioned.storage_key_mask |= BATCH_MATERIAL; + material_partitioned.draw_key_mask |= BATCH_MATERIAL; + assert!(ValidatedPolicy::new(descriptor(vec![material_partitioned])).is_ok()); + } + #[test] fn capability_sets_select_exact_programs_and_reject_invalid_costs() { let mut webgpu = valid_capability_set(); @@ -1571,7 +1601,8 @@ mod tests { capability_set: CapabilitySetId(0), resource_kind_mask: 1, semantic_view_mask: 0, - batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 1, diff --git a/packages/text/rust/shaper/src/engine/render_plan.rs b/packages/text/rust/shaper/src/engine/render_plan.rs index 61a96902..fc66cfd6 100644 --- a/packages/text/rust/shaper/src/engine/render_plan.rs +++ b/packages/text/rust/shaper/src/engine/render_plan.rs @@ -111,8 +111,9 @@ pub struct PrimitiveRecord { pub resource_id: u32, pub resource_generation: u32, pub program_id: u32, - pub variant: u16, - pub reserved: u16, + pub program_variant: u16, + /// Number of consecutive physical records represented by this primitive span. + pub record_count: u16, pub buffer_id: u32, pub record_index: u32, pub logical_order: u32, @@ -129,8 +130,14 @@ pub struct PrimitiveRecord { pub struct DrawRecord { pub id: u32, pub program_id: u32, - pub variant: u16, + pub program_variant: u16, pub flags: u16, + /// Renderer-owned material/pipeline identity. This is data, never a host callback. + pub material_id: u32, + /// Clip identity shared by every primitive in this packet. + pub clip_id: u32, + /// Caller-defined sortable depth bucket; logical order remains authoritative within a bucket. + pub depth_key: u32, pub primitive_start: u32, pub primitive_count: u32, pub buffer_start: u32, @@ -188,6 +195,6 @@ const _: () = assert!(core::mem::size_of::() == 40); const _: () = assert!(core::mem::size_of::() == 36); const _: () = assert!(core::mem::size_of::() == 36); const _: () = assert!(core::mem::size_of::() == 64); -const _: () = assert!(core::mem::size_of::() == 48); +const _: () = assert!(core::mem::size_of::() == 60); const _: () = assert!(core::mem::size_of::() == 24); const _: () = assert!(core::mem::size_of::() == 24); diff --git a/packages/text/rust/shaper/src/engine/render_plan_wire.rs b/packages/text/rust/shaper/src/engine/render_plan_wire.rs index 244af920..3bccc398 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_wire.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_wire.rs @@ -269,7 +269,7 @@ fn validate_plan(plan: RenderPlanView<'_>) -> Result<(), u32> { | PRIMITIVE_CLIP | PRIMITIVE_POLICY ) - || record.reserved != 0 + || record.record_count == 0 || !finite4( record.inline_start, record.block_start, @@ -471,8 +471,8 @@ fn write_primitive(bytes: &mut [u8], at: usize, value: PrimitiveRecord) { value.resource_generation, ); u32_at(bytes, at, PRIMITIVE_PROGRAM_ID, value.program_id); - u16_at(bytes, at, PRIMITIVE_VARIANT, value.variant); - u16_at(bytes, at, PRIMITIVE_RESERVED, value.reserved); + u16_at(bytes, at, PRIMITIVE_PROGRAM_VARIANT, value.program_variant); + u16_at(bytes, at, PRIMITIVE_RECORD_COUNT, value.record_count); u32_at(bytes, at, PRIMITIVE_BUFFER_ID, value.buffer_id); u32_at(bytes, at, PRIMITIVE_RECORD_INDEX, value.record_index); u32_at(bytes, at, PRIMITIVE_LOGICAL_ORDER, value.logical_order); @@ -487,8 +487,11 @@ fn write_primitive(bytes: &mut [u8], at: usize, value: PrimitiveRecord) { fn write_draw(bytes: &mut [u8], at: usize, value: DrawRecord) { u32_at(bytes, at, DRAW_ID, value.id); u32_at(bytes, at, DRAW_PROGRAM_ID, value.program_id); - u16_at(bytes, at, DRAW_VARIANT, value.variant); + u16_at(bytes, at, DRAW_PROGRAM_VARIANT, value.program_variant); u16_at(bytes, at, DRAW_FLAGS, value.flags); + u32_at(bytes, at, DRAW_MATERIAL_ID, value.material_id); + u32_at(bytes, at, DRAW_CLIP_ID, value.clip_id); + u32_at(bytes, at, DRAW_DEPTH_KEY, value.depth_key); u32_at(bytes, at, DRAW_PRIMITIVE_START, value.primitive_start); u32_at(bytes, at, DRAW_PRIMITIVE_COUNT, value.primitive_count); u32_at(bytes, at, DRAW_BUFFER_START, value.buffer_start); @@ -610,6 +613,7 @@ mod tests { resource_id: 2, resource_generation: 3, program_id: 8, + record_count: 1, buffer_id: 6, semantic_id: 1, inline_extent: 8.0, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index cbd068eb..18e035e9 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -163,7 +163,7 @@ impl TextEngine { mod tests { use super::*; use crate::engine::policy::{ - ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, + ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CAP_ORDERED_DIRECT, CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, TechniqueId, @@ -275,7 +275,8 @@ mod tests { capability_set: CapabilitySetId(0), resource_kind_mask: 1, semantic_view_mask: 0, - batch_key_mask: BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, diff --git a/packages/text/rust/shaper/src/engine/wire.rs b/packages/text/rust/shaper/src/engine/wire.rs index c5cf770c..e6f948c3 100644 --- a/packages/text/rust/shaper/src/engine/wire.rs +++ b/packages/text/rust/shaper/src/engine/wire.rs @@ -26,13 +26,13 @@ use crate::{ POLICY_OPERATION_IMMEDIATE2, POLICY_OPERATION_OPCODE, POLICY_OPERATION_OPERAND0, POLICY_OPERATION_OPERAND1, POLICY_OPERATION_RECORD_ALIGNMENT, POLICY_OPERATION_RECORD_SIZE, POLICY_OPERATION_TARGET, POLICY_OPERATIONS_OFFSET, POLICY_PROGRAM_ALLOCATION_STRATEGY, - POLICY_PROGRAM_BATCH_KEY_MASK, POLICY_PROGRAM_BUFFER_COUNT, POLICY_PROGRAM_BUFFER_START, - POLICY_PROGRAM_CAPABILITY_SET_ID, POLICY_PROGRAM_COMPOSITING_CAPABILITIES, - POLICY_PROGRAM_COUNT, POLICY_PROGRAM_F32_INPUT_COUNT, POLICY_PROGRAM_ID, + POLICY_PROGRAM_BUFFER_COUNT, POLICY_PROGRAM_BUFFER_START, POLICY_PROGRAM_CAPABILITY_SET_ID, + POLICY_PROGRAM_COMPOSITING_CAPABILITIES, POLICY_PROGRAM_COUNT, + POLICY_PROGRAM_DRAW_KEY_MASK, POLICY_PROGRAM_F32_INPUT_COUNT, POLICY_PROGRAM_ID, POLICY_PROGRAM_OPERATION_COUNT, POLICY_PROGRAM_OPERATION_START, POLICY_PROGRAM_PAINT_CAPABILITIES, POLICY_PROGRAM_RECORD_ALIGNMENT, - POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, POLICY_PROGRAM_RESERVED1, - POLICY_PROGRAM_RESOURCE_KIND_MASK, POLICY_PROGRAM_SEMANTIC_VIEW_MASK, + POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, POLICY_PROGRAM_RESOURCE_KIND_MASK, + POLICY_PROGRAM_SEMANTIC_VIEW_MASK, POLICY_PROGRAM_STORAGE_KEY_MASK, POLICY_PROGRAM_TECHNIQUE_ID, POLICY_PROGRAM_U32_INPUT_COUNT, POLICY_PROGRAM_VARIANT, POLICY_PROGRAMS_OFFSET, POLICY_REQUEST_HEADER_SIZE, }, @@ -109,9 +109,7 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { .try_reserve_exact(usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)?) .map_err(|_| STATUS_INVALID_REQUEST)?; for record in programs.chunks_exact(POLICY_PROGRAM_RECORD_SIZE as usize) { - if read_u16(record, POLICY_PROGRAM_RESERVED0)? != 0 - || read_u32(record, POLICY_PROGRAM_RESERVED1)? != 0 - { + if read_u16(record, POLICY_PROGRAM_RESERVED0)? != 0 { return Err(STATUS_INVALID_REQUEST); } let selected_buffers = indexed_records( @@ -133,7 +131,8 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { capability_set: CapabilitySetId(read_u32(record, POLICY_PROGRAM_CAPABILITY_SET_ID)?), resource_kind_mask: read_u32(record, POLICY_PROGRAM_RESOURCE_KIND_MASK)?, semantic_view_mask: read_u32(record, POLICY_PROGRAM_SEMANTIC_VIEW_MASK)?, - batch_key_mask: read_u32(record, POLICY_PROGRAM_BATCH_KEY_MASK)?, + storage_key_mask: read_u32(record, POLICY_PROGRAM_STORAGE_KEY_MASK)?, + draw_key_mask: read_u32(record, POLICY_PROGRAM_DRAW_KEY_MASK)?, allocation_strategy: read_u16(record, POLICY_PROGRAM_ALLOCATION_STRATEGY)?, f32_input_count: byte(record, POLICY_PROGRAM_F32_INPUT_COUNT)?, u32_input_count: byte(record, POLICY_PROGRAM_U32_INPUT_COUNT)?, @@ -508,8 +507,18 @@ mod tests { put_u32(program, POLICY_PROGRAM_RESOURCE_KIND_MASK, 1); put_u32( program, - POLICY_PROGRAM_BATCH_KEY_MASK, - crate::engine::policy::BATCH_PROGRAM, + POLICY_PROGRAM_STORAGE_KEY_MASK, + crate::engine::policy::BATCH_TECHNIQUE + | crate::engine::policy::BATCH_PROGRAM + | crate::engine::policy::BATCH_RESOURCE, + ); + put_u32( + program, + POLICY_PROGRAM_DRAW_KEY_MASK, + crate::engine::policy::BATCH_TECHNIQUE + | crate::engine::policy::BATCH_PROGRAM + | crate::engine::policy::BATCH_RESOURCE + | crate::engine::policy::BATCH_ORDER, ); put_u16( program, diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index e2eaf8aa..3fdb69f3 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -150,20 +150,23 @@ export const textShaperAbi = { }, "engineDraw": { "alignment": 4, - "bufferCount": 24, - "bufferStart": 20, + "bufferCount": 36, + "bufferStart": 32, + "clipId": 16, + "depthKey": 20, "flags": 10, "id": 0, - "indirectBufferId": 40, - "indirectOffset": 44, - "orderToken": 36, - "primitiveCount": 16, - "primitiveStart": 12, + "indirectBufferId": 52, + "indirectOffset": 56, + "materialId": 12, + "orderToken": 48, + "primitiveCount": 28, + "primitiveStart": 24, "programId": 4, - "resourceCount": 32, - "resourceStart": 28, - "size": 48, - "variant": 8 + "programVariant": 8, + "resourceCount": 44, + "resourceStart": 40, + "size": 60 }, "enginePatch": { "alignment": 4, @@ -192,14 +195,14 @@ export const textShaperAbi = { "kind": 4, "logicalOrder": 36, "programId": 20, + "programVariant": 24, + "recordCount": 26, "recordIndex": 32, - "reserved": 26, "resourceGeneration": 16, "resourceId": 12, "semanticId": 44, "size": 64, - "techniqueId": 8, - "variant": 24 + "techniqueId": 8 }, "engineResource": { "action": 14, @@ -364,21 +367,21 @@ export const textShaperAbi = { "policyProgram": { "alignment": 4, "allocationStrategy": 46, - "batchKeyMask": 20, "bufferCount": 42, "bufferStart": 32, "capabilitySetId": 8, "compositingCapabilities": 28, + "drawKeyMask": 52, "f32InputCount": 48, "operationCount": 44, "operationStart": 36, "paintCapabilities": 24, "programId": 4, "reserved0": 50, - "reserved1": 52, "resourceKindMask": 12, "semanticViewMask": 16, "size": 56, + "storageKeyMask": 20, "techniqueId": 0, "u32InputCount": 49, "variant": 40 diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 718fab65..70f58078 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -162,9 +162,18 @@ function policyBytes(abi, programs) { view.setUint32(offset + programLayout.resourceKindMask, descriptor.resourceKindMask ?? 1, true); view.setUint32(offset + programLayout.semanticViewMask, descriptor.semanticViewMask ?? 0, true); view.setUint32( - offset + programLayout.batchKeyMask, - descriptor.batchKeyMask ?? - (abi.policy.batchFields.program | abi.policy.batchFields.resource | abi.policy.batchFields.order), + offset + programLayout.storageKeyMask, + descriptor.storageKeyMask ?? + (abi.policy.batchFields.technique | abi.policy.batchFields.program | abi.policy.batchFields.resource), + true, + ); + view.setUint32( + offset + programLayout.drawKeyMask, + descriptor.drawKeyMask ?? + (abi.policy.batchFields.technique | + abi.policy.batchFields.program | + abi.policy.batchFields.resource | + abi.policy.batchFields.order), true, ); view.setUint16(offset + programLayout.variant, descriptor.variant ?? 0, true); From a978808eaf7c35b9599b260461a41967fb15b2e5 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 09:04:00 -0400 Subject: [PATCH 012/128] feat(text): retain stable render slots --- docs/packages/text.md | 9 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 15 +- packages/text/rust/shaper/src/engine/mod.rs | 6 + .../rust/shaper/src/engine/ordered_plan.rs | 227 +------ .../text/rust/shaper/src/engine/plan_input.rs | 97 +++ .../rust/shaper/src/engine/plan_packing.rs | 128 ++++ .../rust/shaper/src/engine/stable_order.rs | 575 ++++++++++++++++++ .../rust/shaper/src/engine/stable_pool.rs | 493 +++++++++++++++ packages/text/tests/support/engine-abi.mjs | 8 +- 10 files changed, 1348 insertions(+), 211 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/plan_input.rs create mode 100644 packages/text/rust/shaper/src/engine/plan_packing.rs create mode 100644 packages/text/rust/shaper/src/engine/stable_order.rs create mode 100644 packages/text/rust/shaper/src/engine/stable_pool.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index fad86b0c..3cd55695 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:155e19bc6f7cc851bb20e59c2398145d911a209b09bae9645d23b995ae9ca8c7' +source_digest: 'sha256:ed891ae9acf97c9f3974df2f797b6d005a4c688143c59f15f4fdaad5cc9f2031' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -445,7 +445,12 @@ produces three ordered spans over two deduplicated resources and buffers. The po bytecode executed by Rust; the render plan itself is data. The planner remains unreachable from the shipping Wasm update and is LTO-stripped. Only the reachable draw-wire and policy-key expansion changes the optimized SIMD artifact to 739,909 raw / 272,607 gzip / 214,288 Brotli bytes. No planner latency claim is attached until session wiring makes it -reachable. +reachable. Stable-indirect storage now has a private tested allocator foundation: semantic identities retain physical +record slots, content revisions select writes, and logical order reconciles through fixed 64-entry chunks. Removed slots +and chunks stay quarantined until an explicit renderer-fence acknowledgment; applying a plan is not treated as proof +that queued GPU work completed. Prepare/abort tests prove that tentative reuse cannot leak into committed state. The +complete stable-indirect display-list compiler and ABI acknowledgment field remain open, so this foundation adds no +end-to-end performance claim. The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 897c56cf..a0ca6cd7 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -232,6 +232,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | | D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | | D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | +| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index c93f9907..00139aff 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -724,11 +724,16 @@ implementation because its ordinary WebGPU and WebGL fallback render-object path shared-buffer adapter must supply an explicit storage index base, while a partitioned policy avoids that requirement. Interleaved `A, A, B, A` resource tests prove three ordered spans over two deduplicated resources and buffers, and the -wire validator accepts the compiled transaction. Stable-indirect order storage, session integration, and target- -hardware timing remain open in Stage 2. The production planner is still unreachable from `text_update` and removed by -LTO; only the expanded reachable wire grammar and independent policy keys change the optimized artifact, from 739,643 -to 739,909 raw bytes, 272,537 to 272,607 gzip bytes, and 214,149 to 214,288 Brotli bytes. This is not end-to-end latency -evidence. +wire validator accepts the compiled transaction. Stable-indirect now has tested transactional foundations: persistent +physical slots, 64-entry order chunks, explicit publication-fence quarantine, acknowledgment-gated reuse, and abort +restoration. Local insertion changes one physical record plus only affected order chunks when retained capacity +permits; arbitrary reorder remains correct even when every order chunk changes. Order-buffer growth requires a complete +live rewrite because the replacement allocation cannot assume the prior allocation's bytes. The final compiler, a +dedicated renderer-fence acknowledgment in the session request, and target-hardware timing remain open in Stage 2; +`consumed_plan_revision` cannot substitute because host application does not prove GPU completion. The production +planner is still unreachable from `text_update` and removed by LTO; only the expanded reachable wire grammar and +independent policy keys change the optimized artifact, from 739,643 to 739,909 raw bytes, 272,537 to 272,607 gzip bytes, +and 214,149 to 214,288 Brotli bytes. This is not end-to-end latency evidence. ## Performance contract diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 600facf0..75fdc260 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -16,9 +16,15 @@ mod state; pub(crate) mod transport; pub mod ordered_plan; +pub mod plan_input; +mod plan_packing; pub mod policy; pub mod render_plan; pub(crate) mod render_plan_wire; +#[cfg_attr(not(test), allow(dead_code))] +mod stable_order; +#[cfg_attr(not(test), allow(dead_code))] +mod stable_pool; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod wire; diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index a7b3cb03..e32987b0 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -5,12 +5,16 @@ //! aborted; committed CPU mirrors change only after the immutable plan has been serialized. use alloc::vec::Vec; -use core::{mem, slice}; +use core::mem; use super::{ + plan_input::{PlanInputError, span_bounds, validate_glyph, validate_input}, + plan_packing::{ + MAX_PHYSICAL_BUFFERS, PackingError, align_up, execute_run, grown_capacity, record_alignment, + }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BufferSchema, CapabilitySetId, - PhysicalBufferMut, PolicyExecutionError, SemanticInputBatch, TechniqueId, ValidatedPolicy, + PolicyExecutionError, TechniqueId, ValidatedPolicy, }, render_plan::{ BUFFER_ORDERED_DIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, @@ -20,34 +24,7 @@ use super::{ }, }; -const MAX_PHYSICAL_BUFFERS: usize = 16; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct OrderedGlyph { - pub stable_id: u32, - pub content_revision: u32, - pub technique: TechniqueId, - pub program_variant: u16, - pub resource_id: u32, - pub resource_generation: u32, - pub resource_kind: u16, - pub resource_reference: u32, - pub semantic_id: u32, - pub material_id: u32, - pub clip_id: u32, - pub depth_key: u32, - pub inline_start: f32, - pub block_start: f32, - pub inline_extent: f32, - pub block_extent: f32, -} - -#[derive(Clone, Copy)] -pub struct OrderedPlanInput<'a> { - pub glyphs: &'a [OrderedGlyph], - pub f32_fields: &'a [&'a [f32]], - pub u32_fields: &'a [&'a [u32]], -} +pub use super::plan_input::{PlanGlyph as OrderedGlyph, PlanInput as OrderedPlanInput}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OrderedPlanError { @@ -67,6 +44,26 @@ pub enum OrderedPlanError { PolicyExecution(PolicyExecutionError), } +impl From for OrderedPlanError { + fn from(error: PlanInputError) -> Self { + match error { + PlanInputError::InvalidShape => Self::InvalidInputShape, + PlanInputError::InvalidIdentity => Self::InvalidIdentity, + PlanInputError::InvalidResource => Self::InvalidResource, + } + } +} + +impl From for OrderedPlanError { + fn from(error: PackingError) -> Self { + match error { + PackingError::ArithmeticOverflow => Self::ArithmeticOverflow, + PackingError::CapacityExceeded => Self::CapacityExceeded, + PackingError::Policy(error) => Self::PolicyExecution(error), + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct BatchKey { technique: TechniqueId, @@ -1044,68 +1041,6 @@ impl OrderedPlanCompiler { } } -fn validate_input(input: OrderedPlanInput<'_>) -> Result<(), OrderedPlanError> { - if u32::try_from(input.glyphs.len()).is_err() { - return Err(OrderedPlanError::InvalidInputShape); - } - if input - .f32_fields - .iter() - .any(|field| field.len() != input.glyphs.len()) - || input - .u32_fields - .iter() - .any(|field| field.len() != input.glyphs.len()) - { - return Err(OrderedPlanError::InvalidInputShape); - } - Ok(()) -} - -fn validate_glyph(glyph: OrderedGlyph) -> Result<(), OrderedPlanError> { - if glyph.stable_id == 0 || glyph.content_revision == 0 { - return Err(OrderedPlanError::InvalidIdentity); - } - if glyph.resource_id == 0 - || glyph.resource_generation == 0 - || !(1..=32).contains(&glyph.resource_kind) - { - return Err(OrderedPlanError::InvalidResource); - } - if !glyph.inline_start.is_finite() - || !glyph.block_start.is_finite() - || !glyph.inline_extent.is_finite() - || !glyph.block_extent.is_finite() - || glyph.inline_extent < 0.0 - || glyph.block_extent < 0.0 - || !(glyph.inline_start + glyph.inline_extent).is_finite() - || !(glyph.block_start + glyph.block_extent).is_finite() - { - return Err(OrderedPlanError::InvalidInputShape); - } - Ok(()) -} - -fn span_bounds(glyphs: &[OrderedGlyph]) -> Result<(f32, f32, f32, f32), OrderedPlanError> { - let first = glyphs.first().ok_or(OrderedPlanError::InvalidInputShape)?; - let mut inline_start = first.inline_start; - let mut block_start = first.block_start; - let mut inline_end = first.inline_start + first.inline_extent; - let mut block_end = first.block_start + first.block_extent; - for glyph in &glyphs[1..] { - inline_start = inline_start.min(glyph.inline_start); - block_start = block_start.min(glyph.block_start); - inline_end = inline_end.max(glyph.inline_start + glyph.inline_extent); - block_end = block_end.max(glyph.block_start + glyph.block_extent); - } - let inline_extent = inline_end - inline_start; - let block_extent = block_end - block_start; - if !inline_extent.is_finite() || !block_extent.is_finite() { - return Err(OrderedPlanError::InvalidInputShape); - } - Ok((inline_start, block_start, inline_extent, block_extent)) -} - fn collect_changed_ranges( ranges: &mut Vec, previous: &[InstanceState], @@ -1236,74 +1171,6 @@ fn coalesce_ranges( Ok(()) } -#[allow(clippy::too_many_arguments)] -fn execute_run( - policy: &ValidatedPolicy, - capability_set: CapabilitySetId, - program: &super::policy::ProgramDescriptor, - input: OrderedPlanInput<'_>, - input_index: usize, - record_count: u32, - output_record: u32, - payload: &mut [u8], - payload_starts: &[usize; MAX_PHYSICAL_BUFFERS], - output_records: u32, -) -> Result<(), OrderedPlanError> { - let input_end = input_index - .checked_add(record_count as usize) - .ok_or(OrderedPlanError::ArithmeticOverflow)?; - let mut f32_fields: [&[f32]; super::policy::MAX_REGISTERS] = - [&[]; super::policy::MAX_REGISTERS]; - let mut u32_fields: [&[u32]; super::policy::MAX_REGISTERS] = - [&[]; super::policy::MAX_REGISTERS]; - for (target, field) in f32_fields - .iter_mut() - .zip(input.f32_fields.iter()) - .take(usize::from(program.f32_input_count)) - { - *target = &field[input_index..input_end]; - } - for (target, field) in u32_fields - .iter_mut() - .zip(input.u32_fields.iter()) - .take(usize::from(program.u32_input_count)) - { - *target = &field[input_index..input_end]; - } - - let mut outputs: [mem::MaybeUninit>; MAX_PHYSICAL_BUFFERS] = - [const { mem::MaybeUninit::uninit() }; MAX_PHYSICAL_BUFFERS]; - let base = payload.as_mut_ptr(); - for (index, schema) in program.buffers.iter().copied().enumerate() { - let length = output_records as usize * schema.stride(); - // SAFETY: all payload segments were sized before this call, are mutually disjoint, and - // `payload` cannot reallocate while these temporary views exist. - let bytes = unsafe { slice::from_raw_parts_mut(base.add(payload_starts[index]), length) }; - outputs[index].write(PhysicalBufferMut { schema, bytes }); - } - // SAFETY: the prefix contains exactly one initialized value per declared program buffer. - let outputs = unsafe { - slice::from_raw_parts_mut( - outputs.as_mut_ptr().cast::>(), - program.buffers.len(), - ) - }; - policy - .execute( - capability_set, - program.technique, - program.variant, - SemanticInputBatch { - f32_fields: &f32_fields[..usize::from(program.f32_input_count)], - u32_fields: &u32_fields[..usize::from(program.u32_input_count)], - record_count: record_count as usize, - }, - output_record as usize, - outputs, - ) - .map_err(OrderedPlanError::PolicyExecution) -} - fn apply_writes( buffer: &mut PhysicalBufferState, patches: &[PatchRecord], @@ -1342,26 +1209,6 @@ fn take_allocation( .map(|index| allocations.swap_remove(index)) } -fn grown_capacity(mut capacity: u32, required: u32) -> Result { - while capacity < required { - capacity = capacity - .checked_mul(2) - .ok_or(OrderedPlanError::CapacityExceeded)?; - } - Ok(capacity) -} - -fn record_alignment( - program: &super::policy::ProgramDescriptor, - byte_alignment: u32, -) -> Result { - program.buffers.iter().try_fold(1_u32, |records, schema| { - let stride = u32::from(schema.stride); - let divisor = gcd(byte_alignment, stride); - lcm(records, byte_alignment / divisor) - }) -} - fn align_record_range(range: RecordRange, alignment: u32) -> Result { let start = range.start / alignment * alignment; let end = range @@ -1372,26 +1219,6 @@ fn align_record_range(range: RecordRange, alignment: u32) -> Result Result { - value - .checked_add(alignment - 1) - .map(|value| value / alignment * alignment) - .ok_or(OrderedPlanError::ArithmeticOverflow) -} - -fn gcd(mut left: u32, mut right: u32) -> u32 { - while right != 0 { - (left, right) = (right, left % right); - } - left -} - -fn lcm(left: u32, right: u32) -> Result { - left.checked_div(gcd(left, right)) - .and_then(|value| value.checked_mul(right)) - .ok_or(OrderedPlanError::ArithmeticOverflow) -} - fn range(start: u32, count: u32) -> Result, OrderedPlanError> { let end = start .checked_add(count) diff --git a/packages/text/rust/shaper/src/engine/plan_input.rs b/packages/text/rust/shaper/src/engine/plan_input.rs new file mode 100644 index 00000000..c6fa2e26 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_input.rs @@ -0,0 +1,97 @@ +//! Allocation-strategy-neutral glyph input for render-plan compilation. + +use super::policy::TechniqueId; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PlanGlyph { + pub stable_id: u32, + pub content_revision: u32, + pub technique: TechniqueId, + pub program_variant: u16, + pub resource_id: u32, + pub resource_generation: u32, + pub resource_kind: u16, + pub resource_reference: u32, + pub semantic_id: u32, + pub material_id: u32, + pub clip_id: u32, + pub depth_key: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +#[derive(Clone, Copy)] +pub struct PlanInput<'a> { + pub glyphs: &'a [PlanGlyph], + pub f32_fields: &'a [&'a [f32]], + pub u32_fields: &'a [&'a [u32]], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlanInputError { + InvalidShape, + InvalidIdentity, + InvalidResource, +} + +pub fn validate_input(input: PlanInput<'_>) -> Result<(), PlanInputError> { + if u32::try_from(input.glyphs.len()).is_err() + || input + .f32_fields + .iter() + .any(|field| field.len() != input.glyphs.len()) + || input + .u32_fields + .iter() + .any(|field| field.len() != input.glyphs.len()) + { + return Err(PlanInputError::InvalidShape); + } + Ok(()) +} + +pub fn validate_glyph(glyph: PlanGlyph) -> Result<(), PlanInputError> { + if glyph.stable_id == 0 || glyph.content_revision == 0 { + return Err(PlanInputError::InvalidIdentity); + } + if glyph.resource_id == 0 + || glyph.resource_generation == 0 + || !(1..=32).contains(&glyph.resource_kind) + { + return Err(PlanInputError::InvalidResource); + } + if !glyph.inline_start.is_finite() + || !glyph.block_start.is_finite() + || !glyph.inline_extent.is_finite() + || !glyph.block_extent.is_finite() + || glyph.inline_extent < 0.0 + || glyph.block_extent < 0.0 + || !(glyph.inline_start + glyph.inline_extent).is_finite() + || !(glyph.block_start + glyph.block_extent).is_finite() + { + return Err(PlanInputError::InvalidShape); + } + Ok(()) +} + +pub fn span_bounds(glyphs: &[PlanGlyph]) -> Result<(f32, f32, f32, f32), PlanInputError> { + let first = glyphs.first().ok_or(PlanInputError::InvalidShape)?; + let mut inline_start = first.inline_start; + let mut block_start = first.block_start; + let mut inline_end = first.inline_start + first.inline_extent; + let mut block_end = first.block_start + first.block_extent; + for glyph in &glyphs[1..] { + inline_start = inline_start.min(glyph.inline_start); + block_start = block_start.min(glyph.block_start); + inline_end = inline_end.max(glyph.inline_start + glyph.inline_extent); + block_end = block_end.max(glyph.block_start + glyph.block_extent); + } + let inline_extent = inline_end - inline_start; + let block_extent = block_end - block_start; + if !inline_extent.is_finite() || !block_extent.is_finite() { + return Err(PlanInputError::InvalidShape); + } + Ok((inline_start, block_start, inline_extent, block_extent)) +} diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs new file mode 100644 index 00000000..3c8ceeda --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -0,0 +1,128 @@ +//! Shared policy execution and capacity arithmetic for retained plan compilers. + +use core::{mem, slice}; + +use super::{ + plan_input::PlanInput, + policy::{ + CapabilitySetId, PhysicalBufferMut, PolicyExecutionError, SemanticInputBatch, + ValidatedPolicy, + }, +}; + +pub const MAX_PHYSICAL_BUFFERS: usize = 16; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PackingError { + ArithmeticOverflow, + CapacityExceeded, + Policy(PolicyExecutionError), +} + +#[allow(clippy::too_many_arguments)] +pub fn execute_run( + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + program: &super::policy::ProgramDescriptor, + input: PlanInput<'_>, + input_index: usize, + record_count: u32, + output_record: u32, + payload: &mut [u8], + payload_starts: &[usize; MAX_PHYSICAL_BUFFERS], + output_records: u32, +) -> Result<(), PackingError> { + let input_end = input_index + .checked_add(record_count as usize) + .ok_or(PackingError::ArithmeticOverflow)?; + let mut f32_fields: [&[f32]; super::policy::MAX_REGISTERS] = + [&[]; super::policy::MAX_REGISTERS]; + let mut u32_fields: [&[u32]; super::policy::MAX_REGISTERS] = + [&[]; super::policy::MAX_REGISTERS]; + for (target, field) in f32_fields + .iter_mut() + .zip(input.f32_fields.iter()) + .take(usize::from(program.f32_input_count)) + { + *target = &field[input_index..input_end]; + } + for (target, field) in u32_fields + .iter_mut() + .zip(input.u32_fields.iter()) + .take(usize::from(program.u32_input_count)) + { + *target = &field[input_index..input_end]; + } + + let mut outputs: [mem::MaybeUninit>; MAX_PHYSICAL_BUFFERS] = + [const { mem::MaybeUninit::uninit() }; MAX_PHYSICAL_BUFFERS]; + let base = payload.as_mut_ptr(); + for (index, schema) in program.buffers.iter().copied().enumerate() { + let length = output_records as usize * schema.stride(); + // SAFETY: the caller sizes mutually disjoint payload segments before this call, and the + // payload cannot reallocate while these temporary views exist. + let bytes = unsafe { slice::from_raw_parts_mut(base.add(payload_starts[index]), length) }; + outputs[index].write(PhysicalBufferMut { schema, bytes }); + } + // SAFETY: the prefix contains exactly one initialized value per declared program buffer. + let outputs = unsafe { + slice::from_raw_parts_mut( + outputs.as_mut_ptr().cast::>(), + program.buffers.len(), + ) + }; + policy + .execute( + capability_set, + program.technique, + program.variant, + SemanticInputBatch { + f32_fields: &f32_fields[..usize::from(program.f32_input_count)], + u32_fields: &u32_fields[..usize::from(program.u32_input_count)], + record_count: record_count as usize, + }, + output_record as usize, + outputs, + ) + .map_err(PackingError::Policy) +} + +pub fn grown_capacity(mut capacity: u32, required: u32) -> Result { + while capacity < required { + capacity = capacity + .checked_mul(2) + .ok_or(PackingError::CapacityExceeded)?; + } + Ok(capacity) +} + +pub fn record_alignment( + program: &super::policy::ProgramDescriptor, + byte_alignment: u32, +) -> Result { + program.buffers.iter().try_fold(1_u32, |records, schema| { + let stride = u32::from(schema.stride); + let divisor = gcd(byte_alignment, stride); + lcm(records, byte_alignment / divisor) + }) +} + +pub fn align_up(value: u32, alignment: u32) -> Result { + value + .checked_add(alignment - 1) + .map(|value| value / alignment * alignment) + .ok_or(PackingError::ArithmeticOverflow) +} + +fn gcd(mut left: u32, mut right: u32) -> u32 { + while right != 0 { + (left, right) = (right, left % right); + } + left +} + +fn lcm(left: u32, right: u32) -> Result { + left.checked_div(gcd(left, right)) + .and_then(|value| value.checked_mul(right)) + .ok_or(PackingError::ArithmeticOverflow) +} diff --git a/packages/text/rust/shaper/src/engine/stable_order.rs b/packages/text/rust/shaper/src/engine/stable_order.rs new file mode 100644 index 00000000..3f726dc9 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/stable_order.rs @@ -0,0 +1,575 @@ +//! Transactional fixed-chunk order storage for stable-indirect glyph records. + +use alloc::vec::Vec; + +pub const ORDER_CHUNK_RECORDS: u32 = 64; +const SCRATCH_NONE: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct OrderEntry { + pub stable_id: u32, + pub record_slot: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ChunkState { + slot: u32, + len: u16, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PendingChunk { + pub slot: u32, + pub len: u16, + scratch_start: u32, +} + +impl PendingChunk { + pub const fn record_start(self) -> u32 { + self.slot * ORDER_CHUNK_RECORDS + } + + pub const fn changed(self) -> bool { + self.scratch_start != SCRATCH_NONE + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChunkedOrderError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + ArithmeticOverflow, + InvalidIdentity, +} + +#[derive(Default)] +pub struct ChunkedOrder { + chunks: Vec, + entries: Vec, + free_chunks: Vec, + pending_chunks: Vec, + pending_entries: Vec, + pending_retired: Vec, + allocated_free_chunks: Vec, + next_chunk_slot: u32, + pending_next_chunk_slot: u32, + capacity_chunks: u32, + pending_capacity_chunks: u32, + prepared: bool, +} + +impl ChunkedOrder { + pub fn prepare(&mut self, desired: &[OrderEntry]) -> Result<(), ChunkedOrderError> { + if self.prepared { + return Err(ChunkedOrderError::AlreadyPrepared); + } + let result = (|| { + validate_desired(desired)?; + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + self.allocated_free_chunks.clear(); + self.pending_next_chunk_slot = self.next_chunk_slot; + self.pending_capacity_chunks = self.capacity_chunks; + + let old_len = self + .chunks + .iter() + .try_fold(0_usize, |total, chunk| { + total.checked_add(usize::from(chunk.len)) + }) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let common_prefix = self.common_prefix(desired); + let common_suffix = self.common_suffix(desired, common_prefix, old_len); + + let mut prefix_chunk_count = 0_usize; + let mut prefix_entries = 0_usize; + while let Some(chunk) = self.chunks.get(prefix_chunk_count) { + let next = prefix_entries + usize::from(chunk.len); + if next > common_prefix { + break; + } + prefix_entries = next; + prefix_chunk_count += 1; + } + + let old_changed_end = old_len - common_suffix; + let mut suffix_chunk_start = self.chunks.len(); + let mut suffix_entries = 0_usize; + let mut chunk_start = old_len; + while suffix_chunk_start > prefix_chunk_count { + let candidate = self.chunks[suffix_chunk_start - 1]; + chunk_start -= usize::from(candidate.len); + if chunk_start < old_changed_end { + break; + } + suffix_chunk_start -= 1; + suffix_entries += usize::from(candidate.len); + } + + reserve( + &mut self.pending_chunks, + self.chunks.len().saturating_add(1), + )?; + for chunk in &self.chunks[..prefix_chunk_count] { + self.pending_chunks.push(PendingChunk { + slot: chunk.slot, + len: chunk.len, + scratch_start: SCRATCH_NONE, + }); + } + + let rebuild_end = desired + .len() + .checked_sub(suffix_entries) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let rebuild = desired + .get(prefix_entries..rebuild_end) + .ok_or(ChunkedOrderError::InvalidIdentity)?; + let old_rebuild_len = suffix_chunk_start - prefix_chunk_count; + let required_chunks = rebuild.len().div_ceil(ORDER_CHUNK_RECORDS as usize); + let mut rebuilt_offset = 0_usize; + for index in 0..required_chunks { + let remaining = rebuild.len() - rebuilt_offset; + let remaining_chunks = required_chunks - index; + let len = remaining.div_ceil(remaining_chunks); + let next_offset = rebuilt_offset + len; + let entries = &rebuild[rebuilt_offset..next_offset]; + let slot = if index < old_rebuild_len { + self.chunks[prefix_chunk_count + index].slot + } else { + self.allocate_chunk()? + }; + self.push_pending_chunk(slot, entries)?; + rebuilt_offset = next_offset; + } + for index in required_chunks.min(old_rebuild_len)..old_rebuild_len { + let slot = self.chunks[prefix_chunk_count + index].slot; + reserve(&mut self.pending_retired, 1)?; + self.pending_retired.push(slot); + } + + for chunk in &self.chunks[suffix_chunk_start..] { + self.pending_chunks.push(PendingChunk { + slot: chunk.slot, + len: chunk.len, + scratch_start: SCRATCH_NONE, + }); + } + self.grow_capacity()?; + if self.pending_capacity_chunks != self.capacity_chunks { + self.mark_every_chunk_changed()?; + } + self.prepared = true; + Ok(()) + })(); + if result.is_err() { + self.abort(); + } + result + } + + pub fn pending_chunks(&self) -> Result<&[PendingChunk], ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(&self.pending_chunks) + } + + pub fn entries(&self, chunk: PendingChunk) -> Result<&[OrderEntry], ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + let len = usize::from(chunk.len); + if chunk.changed() { + let start = chunk.scratch_start as usize; + return self + .pending_entries + .get(start..start + len) + .ok_or(ChunkedOrderError::InvalidIdentity); + } + self.committed_entries(chunk.slot, chunk.len) + } + + pub fn retired_chunks(&self) -> Result<&[u32], ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(&self.pending_retired) + } + + /// Makes consumer-acknowledged chunk slots available to a later transaction. + /// + /// Retired chunks deliberately do not enter the free list during `commit`: the consumer may + /// still be reading an older publication. The owner calls this only after that publication's + /// retirement fence has been acknowledged. + pub fn reclaim_chunks(&mut self, chunks: &[u32]) -> Result<(), ChunkedOrderError> { + if self.prepared { + return Err(ChunkedOrderError::AlreadyPrepared); + } + for &slot in chunks { + if slot >= self.next_chunk_slot + || self.chunks.iter().any(|chunk| chunk.slot == slot) + || self.free_chunks.contains(&slot) + { + return Err(ChunkedOrderError::InvalidIdentity); + } + } + reserve(&mut self.free_chunks, chunks.len())?; + self.free_chunks.extend_from_slice(chunks); + Ok(()) + } + + pub fn capacity_records(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + self.pending_capacity_chunks + .checked_mul(ORDER_CHUNK_RECORDS) + .ok_or(ChunkedOrderError::ArithmeticOverflow) + } + + pub fn grew(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(self.pending_capacity_chunks != self.capacity_chunks) + } + + pub fn commit(&mut self) -> Result<(), ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + let required = usize::try_from(self.pending_capacity_chunks) + .ok() + .and_then(|chunks| chunks.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let additional = required.saturating_sub(self.entries.len()); + reserve(&mut self.entries, additional)?; + self.entries.resize(required, OrderEntry::default()); + for chunk in self + .pending_chunks + .iter() + .copied() + .filter(|chunk| chunk.changed()) + { + let source_start = chunk.scratch_start as usize; + let destination_start = chunk.record_start() as usize; + let len = usize::from(chunk.len); + let source = self + .pending_entries + .get(source_start..source_start + len) + .ok_or(ChunkedOrderError::InvalidIdentity)?; + let destination = self + .entries + .get_mut(destination_start..destination_start + len) + .ok_or(ChunkedOrderError::InvalidIdentity)?; + destination.copy_from_slice(source); + } + self.chunks.clear(); + reserve(&mut self.chunks, self.pending_chunks.len())?; + self.chunks + .extend(self.pending_chunks.iter().map(|chunk| ChunkState { + slot: chunk.slot, + len: chunk.len, + })); + self.next_chunk_slot = self.pending_next_chunk_slot; + self.capacity_chunks = self.pending_capacity_chunks; + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + self.allocated_free_chunks.clear(); + self.prepared = false; + Ok(()) + } + + pub fn abort(&mut self) { + for slot in self.allocated_free_chunks.drain(..) { + self.free_chunks.push(slot); + } + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + self.prepared = false; + } + + fn common_prefix(&self, desired: &[OrderEntry]) -> usize { + let mut matched = 0_usize; + for chunk in &self.chunks { + let Ok(entries) = self.committed_entries(chunk.slot, chunk.len) else { + return matched; + }; + for entry in entries { + if desired.get(matched) != Some(entry) { + return matched; + } + matched += 1; + } + } + matched + } + + fn common_suffix(&self, desired: &[OrderEntry], prefix: usize, old_len: usize) -> usize { + let mut matched = 0_usize; + for chunk in self.chunks.iter().rev() { + let Ok(entries) = self.committed_entries(chunk.slot, chunk.len) else { + return matched; + }; + for entry in entries.iter().rev() { + if prefix + matched >= old_len.min(desired.len()) + || desired.get(desired.len() - 1 - matched) != Some(entry) + { + return matched; + } + matched += 1; + } + } + matched + } + + fn committed_entries(&self, slot: u32, len: u16) -> Result<&[OrderEntry], ChunkedOrderError> { + let start = usize::try_from(slot) + .ok() + .and_then(|slot| slot.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + self.entries + .get(start..start + usize::from(len)) + .ok_or(ChunkedOrderError::InvalidIdentity) + } + + fn allocate_chunk(&mut self) -> Result { + if let Some(slot) = self.free_chunks.pop() { + reserve(&mut self.allocated_free_chunks, 1)?; + self.allocated_free_chunks.push(slot); + return Ok(slot); + } + let slot = self.pending_next_chunk_slot; + self.pending_next_chunk_slot = self + .pending_next_chunk_slot + .checked_add(1) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + Ok(slot) + } + + fn push_pending_chunk( + &mut self, + slot: u32, + entries: &[OrderEntry], + ) -> Result<(), ChunkedOrderError> { + let len = + u16::try_from(entries.len()).map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + let unchanged = self + .chunks + .iter() + .find(|chunk| chunk.slot == slot && chunk.len == len) + .and_then(|chunk| self.committed_entries(chunk.slot, chunk.len).ok()) + == Some(entries); + let scratch_start = if unchanged { + SCRATCH_NONE + } else { + let start = u32::try_from(self.pending_entries.len()) + .map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + reserve(&mut self.pending_entries, entries.len())?; + self.pending_entries.extend_from_slice(entries); + start + }; + self.pending_chunks.push(PendingChunk { + slot, + len, + scratch_start, + }); + Ok(()) + } + + fn grow_capacity(&mut self) -> Result<(), ChunkedOrderError> { + if self.pending_next_chunk_slot == 0 { + self.pending_capacity_chunks = 0; + return Ok(()); + } + let mut capacity = self.pending_capacity_chunks.max(1); + while capacity < self.pending_next_chunk_slot { + capacity = capacity + .checked_mul(2) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + } + self.pending_capacity_chunks = capacity; + Ok(()) + } + + fn mark_every_chunk_changed(&mut self) -> Result<(), ChunkedOrderError> { + for index in 0..self.pending_chunks.len() { + if self.pending_chunks[index].changed() { + continue; + } + let chunk = self.pending_chunks[index]; + let committed_start = usize::try_from(chunk.slot) + .ok() + .and_then(|slot| slot.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let committed_end = committed_start + .checked_add(usize::from(chunk.len)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + if committed_end > self.entries.len() { + return Err(ChunkedOrderError::InvalidIdentity); + } + let start = u32::try_from(self.pending_entries.len()) + .map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + reserve(&mut self.pending_entries, usize::from(chunk.len))?; + let entries = &self.entries[committed_start..committed_end]; + self.pending_entries.extend_from_slice(entries); + self.pending_chunks[index].scratch_start = start; + } + Ok(()) + } +} + +fn validate_desired(desired: &[OrderEntry]) -> Result<(), ChunkedOrderError> { + if desired + .iter() + .any(|entry| entry.stable_id == 0 || entry.record_slot == u32::MAX) + { + return Err(ChunkedOrderError::InvalidIdentity); + } + Ok(()) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), ChunkedOrderError> { + values + .try_reserve(additional) + .map_err(|_| ChunkedOrderError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn localized_insert_rewrites_one_chunk_until_it_splits() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=64).map(entry).collect(); + order.prepare(&initial).unwrap(); + assert!(order.grew().unwrap()); + assert_eq!(order.capacity_records().unwrap(), 64); + assert_eq!(changed_chunks(&order), vec![0]); + order.commit().unwrap(); + + let mut inserted = initial.clone(); + inserted.insert( + 32, + OrderEntry { + stable_id: 100, + record_slot: 100, + }, + ); + order.prepare(&inserted).unwrap(); + assert_eq!(order.pending_chunks().unwrap().len(), 2); + assert_eq!(changed_chunks(&order), vec![0, 1]); + assert_eq!(flatten(&order), inserted); + } + + #[test] + fn insertion_at_a_chunk_boundary_preserves_both_neighbor_chunks() { + let mut order = ChunkedOrder::default(); + // Establish spare physical capacity so this assertion measures order reconciliation rather + // than the intentionally full rewrite required by an order-buffer resize. + let capacity_seed: Vec<_> = (1..=129).map(entry).collect(); + order.prepare(&capacity_seed).unwrap(); + order.commit().unwrap(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + + let mut inserted = initial.clone(); + inserted.insert( + 64, + OrderEntry { + stable_id: 200, + record_slot: 200, + }, + ); + order.prepare(&inserted).unwrap(); + assert_eq!(order.pending_chunks().unwrap().len(), 3); + assert_eq!(changed_chunks(&order).len(), 1); + assert_eq!(flatten(&order), inserted); + } + + #[test] + fn deletion_retires_unused_chunks_and_abort_preserves_committed_order() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + + let shortened = &initial[..32]; + order.prepare(shortened).unwrap(); + assert_eq!(order.retired_chunks().unwrap().len(), 1); + order.abort(); + order.prepare(&initial).unwrap(); + assert!(changed_chunks(&order).is_empty()); + assert_eq!(flatten(&order), initial); + } + + #[test] + fn retired_chunks_are_reused_only_after_explicit_reclamation() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + + order.prepare(&initial[..32]).unwrap(); + let retired = order.retired_chunks().unwrap().to_vec(); + assert_eq!(retired.len(), 1); + order.commit().unwrap(); + + let expanded: Vec<_> = (1..=96).map(entry).collect(); + order.prepare(&expanded).unwrap(); + let allocated_before_ack = order.pending_chunks().unwrap()[1].slot; + order.abort(); + assert_ne!(allocated_before_ack, retired[0]); + + order.reclaim_chunks(&retired).unwrap(); + order.prepare(&expanded).unwrap(); + assert_eq!(order.pending_chunks().unwrap()[1].slot, retired[0]); + } + + #[test] + fn arbitrary_reorder_is_correct_even_when_it_rewrites_every_chunk() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=130).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + let mut reversed = initial.clone(); + reversed.reverse(); + order.prepare(&reversed).unwrap(); + assert_eq!(flatten(&order), reversed); + assert_eq!(changed_chunks(&order).len(), 3); + } + + fn entry(stable_id: u32) -> OrderEntry { + OrderEntry { + stable_id, + record_slot: stable_id - 1, + } + } + + fn changed_chunks(order: &ChunkedOrder) -> Vec { + order + .pending_chunks() + .unwrap() + .iter() + .copied() + .filter(|chunk| chunk.changed()) + .map(|chunk| chunk.slot) + .collect() + } + + fn flatten(order: &ChunkedOrder) -> Vec { + let mut entries = Vec::new(); + for chunk in order.pending_chunks().unwrap().iter().copied() { + entries.extend_from_slice(order.entries(chunk).unwrap()); + } + entries + } +} diff --git a/packages/text/rust/shaper/src/engine/stable_pool.rs b/packages/text/rust/shaper/src/engine/stable_pool.rs new file mode 100644 index 00000000..cccb2014 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/stable_pool.rs @@ -0,0 +1,493 @@ +//! Transactional stable physical-slot assignment with acknowledgment-gated reuse. + +use alloc::vec::Vec; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotIdentity { + pub stable_id: u32, + pub content_revision: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SlotAssignment { + pub stable_id: u32, + pub content_revision: u32, + pub slot: u32, + pub changed: bool, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct SlotState { + stable_id: u32, + content_revision: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct QuarantinedSlot { + slot: u32, + after_publication_generation: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StablePoolError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + InvalidIdentity, + DuplicateIdentity, + IdentifierExhausted, + ArithmeticOverflow, +} + +#[derive(Default)] +pub struct StableSlotPool { + slots: Vec, + free_slots: Vec, + quarantine: Vec, + assignments: Vec, + retired_slots: Vec, + allocated_free_slots: Vec, + identity_keys: Vec, + identity_slots: Vec, + identity_epochs: Vec, + identity_epoch: u32, + seen_slots: Vec, + seen_epoch: u32, + pending_slot_count: u32, + pending_publication_generation: u32, + prepared: bool, +} + +impl StableSlotPool { + /// Releases slots whose renderer fence has completed. + /// + /// Acknowledgment is monotonic state independent of a later prepare/abort transaction. + pub fn acknowledge(&mut self, through_generation: u32) -> Result<(), StablePoolError> { + if self.prepared { + return Err(StablePoolError::AlreadyPrepared); + } + let reclaim_count = self + .quarantine + .iter() + .filter(|entry| entry.after_publication_generation <= through_generation) + .count(); + reserve(&mut self.free_slots, reclaim_count)?; + let mut write = 0_usize; + for read in 0..self.quarantine.len() { + let entry = self.quarantine[read]; + if entry.after_publication_generation <= through_generation { + self.free_slots.push(entry.slot); + } else { + self.quarantine[write] = entry; + write += 1; + } + } + self.quarantine.truncate(write); + Ok(()) + } + + pub fn prepare( + &mut self, + desired: &[SlotIdentity], + publication_generation: u32, + ) -> Result<(), StablePoolError> { + if self.prepared { + return Err(StablePoolError::AlreadyPrepared); + } + if publication_generation == 0 || u32::try_from(desired.len()).is_err() { + return Err(StablePoolError::InvalidIdentity); + } + self.assignments.clear(); + self.retired_slots.clear(); + self.allocated_free_slots.clear(); + self.pending_slot_count = + u32::try_from(self.slots.len()).map_err(|_| StablePoolError::ArithmeticOverflow)?; + self.pending_publication_generation = publication_generation; + let result = (|| { + self.prepare_identity_map(desired.len())?; + self.prepare_seen_slots()?; + reserve(&mut self.assignments, desired.len())?; + + for slot in 0..self.slots.len() { + let stable_id = self.slots[slot].stable_id; + if stable_id != 0 { + self.insert_committed_identity(stable_id, slot as u32)?; + } + } + + for identity in desired.iter().copied() { + if identity.stable_id == 0 || identity.content_revision == 0 { + return Err(StablePoolError::InvalidIdentity); + } + let existing = self.find_identity(identity.stable_id); + let slot = match existing { + Some(slot) => { + if self.slot_seen(slot)? { + return Err(StablePoolError::DuplicateIdentity); + } + slot + } + None => { + let slot = self.allocate_slot()?; + self.insert_new_identity(identity.stable_id, slot)?; + slot + } + }; + self.mark_slot_seen(slot)?; + let changed = self.slots.get(slot as usize).is_none_or(|state| { + state.stable_id != identity.stable_id + || state.content_revision != identity.content_revision + }); + self.assignments.push(SlotAssignment { + stable_id: identity.stable_id, + content_revision: identity.content_revision, + slot, + changed, + }); + } + + reserve(&mut self.retired_slots, self.slots.len())?; + for (slot, state) in self.slots.iter().enumerate() { + if state.stable_id != 0 && !self.slot_seen(slot as u32)? { + self.retired_slots.push(slot as u32); + } + } + reserve(&mut self.quarantine, self.retired_slots.len())?; + let required_slots = usize::try_from(self.pending_slot_count) + .map_err(|_| StablePoolError::ArithmeticOverflow)?; + let additional_slots = required_slots.saturating_sub(self.slots.len()); + reserve(&mut self.slots, additional_slots)?; + self.prepared = true; + Ok(()) + })(); + if result.is_err() { + self.abort(); + } + result + } + + pub fn assignments(&self) -> Result<&[SlotAssignment], StablePoolError> { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + Ok(&self.assignments) + } + + pub fn retired_slots(&self) -> Result<&[u32], StablePoolError> { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + Ok(&self.retired_slots) + } + + pub fn required_slots(&self) -> Result { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + Ok(self.pending_slot_count) + } + + pub fn live_count(&self) -> Result { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + u32::try_from(self.assignments.len()).map_err(|_| StablePoolError::ArithmeticOverflow) + } + + pub fn commit(&mut self) -> Result<(), StablePoolError> { + if !self.prepared { + return Err(StablePoolError::NotPrepared); + } + self.slots + .resize(self.pending_slot_count as usize, SlotState::default()); + for &slot in &self.retired_slots { + self.slots[slot as usize] = SlotState::default(); + self.quarantine.push(QuarantinedSlot { + slot, + after_publication_generation: self.pending_publication_generation, + }); + } + for assignment in &self.assignments { + self.slots[assignment.slot as usize] = SlotState { + stable_id: assignment.stable_id, + content_revision: assignment.content_revision, + }; + } + self.finish_transaction(); + Ok(()) + } + + pub fn abort(&mut self) { + for slot in self.allocated_free_slots.drain(..) { + self.free_slots.push(slot); + } + self.finish_transaction(); + } + + fn finish_transaction(&mut self) { + self.assignments.clear(); + self.retired_slots.clear(); + self.allocated_free_slots.clear(); + self.prepared = false; + } + + fn prepare_identity_map(&mut self, desired_count: usize) -> Result<(), StablePoolError> { + let live_count = self + .slots + .iter() + .filter(|state| state.stable_id != 0) + .count(); + let required = live_count + .checked_add(desired_count) + .and_then(|count| count.checked_mul(2)) + .and_then(usize::checked_next_power_of_two) + .unwrap_or(usize::MAX) + .max(8); + if required == usize::MAX { + return Err(StablePoolError::ArithmeticOverflow); + } + if self.identity_keys.len() < required { + let additional_keys = required - self.identity_keys.len(); + let additional_slots = required - self.identity_slots.len(); + let additional_epochs = required - self.identity_epochs.len(); + reserve(&mut self.identity_keys, additional_keys)?; + reserve(&mut self.identity_slots, additional_slots)?; + reserve(&mut self.identity_epochs, additional_epochs)?; + self.identity_keys.resize(required, 0); + self.identity_slots.resize(required, 0); + self.identity_epochs.resize(required, 0); + } + self.identity_epoch = next_epoch(&mut self.identity_epochs, self.identity_epoch); + Ok(()) + } + + fn prepare_seen_slots(&mut self) -> Result<(), StablePoolError> { + if self.seen_slots.len() < self.slots.len() { + let additional = self.slots.len() - self.seen_slots.len(); + reserve(&mut self.seen_slots, additional)?; + self.seen_slots.resize(self.slots.len(), 0); + } + self.seen_epoch = next_epoch(&mut self.seen_slots, self.seen_epoch); + Ok(()) + } + + fn insert_committed_identity( + &mut self, + identity: u32, + slot: u32, + ) -> Result<(), StablePoolError> { + let index = self.identity_insert_position(identity)?; + if self.identity_epochs[index] == self.identity_epoch { + return Err(StablePoolError::DuplicateIdentity); + } + self.identity_epochs[index] = self.identity_epoch; + self.identity_keys[index] = identity; + self.identity_slots[index] = slot; + Ok(()) + } + + fn insert_new_identity(&mut self, identity: u32, slot: u32) -> Result<(), StablePoolError> { + let index = self.identity_insert_position(identity)?; + if self.identity_epochs[index] == self.identity_epoch { + return Err(StablePoolError::DuplicateIdentity); + } + self.identity_epochs[index] = self.identity_epoch; + self.identity_keys[index] = identity; + self.identity_slots[index] = slot; + Ok(()) + } + + fn find_identity(&self, identity: u32) -> Option { + let mask = self.identity_keys.len() - 1; + let mut index = hash(identity) & mask; + loop { + if self.identity_epochs[index] != self.identity_epoch { + return None; + } + if self.identity_keys[index] == identity { + return Some(self.identity_slots[index]); + } + index = (index + 1) & mask; + } + } + + fn identity_insert_position(&self, identity: u32) -> Result { + let mask = self + .identity_keys + .len() + .checked_sub(1) + .ok_or(StablePoolError::ArithmeticOverflow)?; + let mut index = hash(identity) & mask; + loop { + if self.identity_epochs[index] != self.identity_epoch + || self.identity_keys[index] == identity + { + return Ok(index); + } + index = (index + 1) & mask; + } + } + + fn allocate_slot(&mut self) -> Result { + if let Some(slot) = self.free_slots.pop() { + reserve(&mut self.allocated_free_slots, 1)?; + self.allocated_free_slots.push(slot); + return Ok(slot); + } + let slot = self.pending_slot_count; + self.pending_slot_count = self + .pending_slot_count + .checked_add(1) + .ok_or(StablePoolError::IdentifierExhausted)?; + Ok(slot) + } + + fn slot_seen(&self, slot: u32) -> Result { + let index = usize::try_from(slot).map_err(|_| StablePoolError::ArithmeticOverflow)?; + Ok(self.seen_slots.get(index).copied() == Some(self.seen_epoch)) + } + + fn mark_slot_seen(&mut self, slot: u32) -> Result<(), StablePoolError> { + let required = usize::try_from(slot) + .ok() + .and_then(|slot| slot.checked_add(1)) + .ok_or(StablePoolError::ArithmeticOverflow)?; + if self.seen_slots.len() < required { + let additional = required - self.seen_slots.len(); + reserve(&mut self.seen_slots, additional)?; + self.seen_slots.resize(required, 0); + } + self.seen_slots[required - 1] = self.seen_epoch; + Ok(()) + } +} + +fn next_epoch(values: &mut [u32], current: u32) -> u32 { + match current.checked_add(1) { + Some(epoch) => epoch, + None => { + values.fill(0); + 1 + } + } +} + +fn hash(identity: u32) -> usize { + identity.wrapping_mul(0x9e37_79b1) as usize +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), StablePoolError> { + values + .try_reserve(additional) + .map_err(|_| StablePoolError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + #[test] + fn insertion_preserves_existing_slots_and_marks_only_new_content_dirty() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1), (3, 1)]), 1) + .unwrap(); + assert_eq!(slots(&pool), vec![0, 1, 2]); + pool.commit().unwrap(); + + pool.prepare(&identities(&[(1, 1), (4, 1), (2, 1), (3, 1)]), 2) + .unwrap(); + assert_eq!(slots(&pool), vec![0, 3, 1, 2]); + assert_eq!(changed_slots(&pool), vec![3]); + } + + #[test] + fn deletion_quarantines_slots_until_the_renderer_acknowledges_the_fence() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + + pool.prepare(&identities(&[(1, 1)]), 2).unwrap(); + assert_eq!(pool.retired_slots().unwrap(), &[1]); + pool.commit().unwrap(); + + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 2]); + pool.abort(); + + pool.acknowledge(1).unwrap(); + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 2]); + pool.abort(); + + pool.acknowledge(2).unwrap(); + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 1]); + } + + #[test] + fn content_revision_changes_rewrite_the_same_slot() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + pool.prepare(&identities(&[(2, 2), (1, 1)]), 2).unwrap(); + assert_eq!(slots(&pool), vec![1, 0]); + assert_eq!(changed_slots(&pool), vec![1]); + } + + #[test] + fn abort_returns_reclaimed_allocations_without_committing_identity() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + pool.prepare(&identities(&[(1, 1)]), 2).unwrap(); + pool.commit().unwrap(); + pool.acknowledge(2).unwrap(); + + pool.prepare(&identities(&[(1, 1), (3, 1)]), 3).unwrap(); + assert_eq!(slots(&pool), vec![0, 1]); + pool.abort(); + pool.prepare(&identities(&[(1, 1), (4, 1)]), 4).unwrap(); + assert_eq!(slots(&pool), vec![0, 1]); + } + + #[test] + fn duplicate_desired_identity_fails_without_leaking_a_slot() { + let mut pool = StableSlotPool::default(); + assert_eq!( + pool.prepare(&identities(&[(1, 1), (1, 1)]), 1), + Err(StablePoolError::DuplicateIdentity) + ); + pool.prepare(&identities(&[(2, 1)]), 1).unwrap(); + assert_eq!(slots(&pool), vec![0]); + assert_eq!(pool.required_slots().unwrap(), 1); + assert_eq!(pool.live_count().unwrap(), 1); + } + + fn identities(values: &[(u32, u32)]) -> Vec { + values + .iter() + .map(|&(stable_id, content_revision)| SlotIdentity { + stable_id, + content_revision, + }) + .collect() + } + + fn slots(pool: &StableSlotPool) -> Vec { + pool.assignments() + .unwrap() + .iter() + .map(|assignment| assignment.slot) + .collect() + } + + fn changed_slots(pool: &StableSlotPool) -> Vec { + pool.assignments() + .unwrap() + .iter() + .filter(|assignment| assignment.changed) + .map(|assignment| assignment.slot) + .collect() + } +} diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 70f58078..76bb93a7 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -164,16 +164,16 @@ function policyBytes(abi, programs) { view.setUint32( offset + programLayout.storageKeyMask, descriptor.storageKeyMask ?? - (abi.policy.batchFields.technique | abi.policy.batchFields.program | abi.policy.batchFields.resource), + abi.policy.batchFields.technique | abi.policy.batchFields.program | abi.policy.batchFields.resource, true, ); view.setUint32( offset + programLayout.drawKeyMask, descriptor.drawKeyMask ?? - (abi.policy.batchFields.technique | + abi.policy.batchFields.technique | abi.policy.batchFields.program | abi.policy.batchFields.resource | - abi.policy.batchFields.order), + abi.policy.batchFields.order, true, ); view.setUint16(offset + programLayout.variant, descriptor.variant ?? 0, true); @@ -206,7 +206,7 @@ function policyBytes(abi, programs) { view.setUint16(offset + bufferLayout.stride, buffer.stride ?? scalarBytes * buffer.vectorWidth, true); view.setUint32( offset + bufferLayout.usage, - buffer.usage ?? (abi.policy.bufferUsage.storage | abi.policy.bufferUsage.copyDst), + buffer.usage ?? abi.policy.bufferUsage.storage | abi.policy.bufferUsage.copyDst, true, ); view.setUint16(offset + bufferLayout.capacityClass, buffer.capacityClass ?? 1, true); From bbee9270e3d999a876e6af24b03a49048d1891b3 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 09:34:13 -0400 Subject: [PATCH 013/128] feat(text): compile stable indirect plans --- docs/packages/text.md | 20 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 27 +- packages/text/rust/shaper/src/abi_contract.rs | 16 +- packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/ordered_plan.rs | 161 +- .../rust/shaper/src/engine/plan_packing.rs | 168 ++ .../rust/shaper/src/engine/render_plan.rs | 2 + .../rust/shaper/src/engine/stable_order.rs | 140 +- .../rust/shaper/src/engine/stable_plan.rs | 1948 +++++++++++++++++ .../rust/shaper/src/engine/stable_pool.rs | 16 + .../text/src/generated/text-shaper-abi.ts | 3 + 12 files changed, 2311 insertions(+), 192 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/stable_plan.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index 3cd55695..ec0e72a2 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:ed891ae9acf97c9f3974df2f797b6d005a4c688143c59f15f4fdaad5cc9f2031' +source_digest: 'sha256:afa202c4e5724631c1e78a72458aed0c6300b0c4bf7aa455fbcbbfac3cb0af60' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -445,12 +445,18 @@ produces three ordered spans over two deduplicated resources and buffers. The po bytecode executed by Rust; the render plan itself is data. The planner remains unreachable from the shipping Wasm update and is LTO-stripped. Only the reachable draw-wire and policy-key expansion changes the optimized SIMD artifact to 739,909 raw / 272,607 gzip / 214,288 Brotli bytes. No planner latency claim is attached until session wiring makes it -reachable. Stable-indirect storage now has a private tested allocator foundation: semantic identities retain physical -record slots, content revisions select writes, and logical order reconciles through fixed 64-entry chunks. Removed slots -and chunks stay quarantined until an explicit renderer-fence acknowledgment; applying a plan is not treated as proof -that queued GPU work completed. Prepare/abort tests prove that tentative reuse cannot leak into committed state. The -complete stable-indirect display-list compiler and ABI acknowledgment field remain open, so this foundation adds no -end-to-end performance claim. +reachable. Stable-indirect storage now compiles complete resource, physical-buffer, patch, glyph-span, draw, and +retirement tables. Semantic identities retain physical record slots; content revisions select writes; fixed 64-entry +`u32` chunks carry logical order through the generated reserved binding ID 65,535. In the one-stream fixture, a localized +insertion writes one new 4-byte physical record and one affected 16-byte order range, while a pure reorder emits no +physical write. Removed slots/chunks remain quarantined until an explicit renderer-fence acknowledgment; applying a plan +is not proof that queued GPU work completed. When physical order spans would exceed the capability fragmentation budget, +the compiler transactionally rebases only the order buffer, retires its prior generation, and preserves glyph-buffer +generations. Tests cover no-op, abort, mixed resources, shared and material-partitioned storage, fence-gated reuse, wire +validation, bounded order fragmentation, and unchanged nested scratch capacities after warm settlement. Session wiring +and the ABI acknowledgment field remain open. The planner remains LTO-stripped: raw Wasm stays 739,909 bytes; the +reachable binding identity shifts gzip 272,607→272,624 and Brotli 214,288→214,395 bytes. No planner-latency or end-to-end +claim is attached yet. The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index a0ca6cd7..3e8aa3e9 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -233,6 +233,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | | D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | | D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | +| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 00139aff..0222d3b3 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -723,17 +723,22 @@ not left to the adapter after publication: focused tests prove both plans. The d implementation because its ordinary WebGPU and WebGL fallback render-object paths both submit `firstInstance = 0`; a shared-buffer adapter must supply an explicit storage index base, while a partitioned policy avoids that requirement. -Interleaved `A, A, B, A` resource tests prove three ordered spans over two deduplicated resources and buffers, and the -wire validator accepts the compiled transaction. Stable-indirect now has tested transactional foundations: persistent -physical slots, 64-entry order chunks, explicit publication-fence quarantine, acknowledgment-gated reuse, and abort -restoration. Local insertion changes one physical record plus only affected order chunks when retained capacity -permits; arbitrary reorder remains correct even when every order chunk changes. Order-buffer growth requires a complete -live rewrite because the replacement allocation cannot assume the prior allocation's bytes. The final compiler, a -dedicated renderer-fence acknowledgment in the session request, and target-hardware timing remain open in Stage 2; -`consumed_plan_revision` cannot substitute because host application does not prove GPU completion. The production -planner is still unreachable from `text_update` and removed by LTO; only the expanded reachable wire grammar and -independent policy keys change the optimized artifact, from 739,643 to 739,909 raw bytes, 272,537 to 272,607 gzip bytes, -and 214,149 to 214,288 Brotli bytes. This is not end-to-end latency evidence. +Interleaved `A, A, B, A` resource tests prove three ordered spans over two deduplicated resources and buffers, and both +allocation strategies pass wire validation. Stable-indirect compiles persistent physical slots plus 64-entry `u32` +order chunks under the same transactional prepare/view/commit-or-abort lifecycle. Each physical buffer identifies its +order buffer; that buffer uses the compiler-generated reserved binding ID 65,535. A localized insertion in the focused +one-stream fixture writes one new 4-byte physical record and one 16-byte order range, while arbitrary reorder writes +only order bytes. Deleted slots and chunks stay in publication-fence quarantine until acknowledged. The registered +fragmentation budget bounds accumulated draw spans: when an edit would exceed it, Rust rebases only the order buffer to +dense chunks, increments and retires that buffer generation, and preserves the physical glyph-buffer generation. +Order-buffer growth likewise republishes every live chunk because a replacement allocation cannot assume prior bytes. +No-op, abort, mixed-resource order, shared/partitioned material storage, fence-gated reuse, fragmentation rebasing, and +settled nested scratch capacities have focused tests. Session integration, a dedicated renderer-fence acknowledgment in +the request, and target-hardware timing remain open in Stage 2; `consumed_plan_revision` cannot substitute because host +application does not prove GPU completion. The planners are still unreachable from `text_update` and removed by LTO. +Adding the reachable reserved-binding ABI identity leaves the optimized artifact at 739,909 raw bytes and changes only +compression from 272,607 to 272,624 gzip bytes and 214,288 to 214,395 Brotli bytes. This is not end-to-end latency +evidence. ## Performance contract diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 30bf39a3..ff018498 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -14,12 +14,13 @@ use crate::engine::policy::{ }; use crate::engine::render_plan::{ BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT, BufferRecord, DiagnosticRecord, DrawRecord, - PATCH_ALLOCATE_OR_RESIZE, PATCH_COPY, PATCH_FILL, PATCH_RETIRE, PATCH_WRITE, PRIMITIVE_CLIP, - PRIMITIVE_DECORATION, PRIMITIVE_GLYPH, PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, - PrimitiveRecord, RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, - RETIRE_BUFFER, RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, ResourceRecord, - RetirementRecord, SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_INSERTED_GLYPH, - SEMANTIC_LINE, SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, + PATCH_ALLOCATE_OR_RESIZE, PATCH_COPY, PATCH_FILL, PATCH_RETIRE, PATCH_WRITE, + POLICY_BUFFER_ORDER, PRIMITIVE_CLIP, PRIMITIVE_DECORATION, PRIMITIVE_GLYPH, + PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, PrimitiveRecord, + RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_BUFFER, + RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, ResourceRecord, RetirementRecord, + SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_INSERTED_GLYPH, SEMANTIC_LINE, + SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, }; pub const ABI_VERSION: u32 = 0; @@ -1510,6 +1511,9 @@ pub fn json() -> String { "orderedDirect": BUFFER_ORDERED_DIRECT, "stableIndirect": BUFFER_STABLE_INDIRECT }, + "internalBufferBindings": { + "order": POLICY_BUFFER_ORDER + }, "patchOpcodes": { "allocateOrResize": PATCH_ALLOCATE_OR_RESIZE, "write": PATCH_WRITE, diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 75fdc260..c5313f68 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -23,6 +23,7 @@ pub mod render_plan; pub(crate) mod render_plan_wire; #[cfg_attr(not(test), allow(dead_code))] mod stable_order; +pub mod stable_plan; #[cfg_attr(not(test), allow(dead_code))] mod stable_pool; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index e32987b0..c608d960 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -10,7 +10,9 @@ use core::mem; use super::{ plan_input::{PlanInputError, span_bounds, validate_glyph, validate_input}, plan_packing::{ - MAX_PHYSICAL_BUFFERS, PackingError, align_up, execute_run, grown_capacity, record_alignment, + MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, + align_record_range, align_up, apply_writes, coalesce_ranges, execute_run, grown_capacity, + record_alignment, take_allocation, }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BufferSchema, CapabilitySetId, @@ -57,8 +59,10 @@ impl From for OrderedPlanError { impl From for OrderedPlanError { fn from(error: PackingError) -> Self { match error { + PackingError::AllocationFailed => Self::AllocationFailed, PackingError::ArithmeticOverflow => Self::ArithmeticOverflow, PackingError::CapacityExceeded => Self::CapacityExceeded, + PackingError::InvalidIdentity => Self::InvalidIdentity, PackingError::Policy(error) => Self::PolicyExecution(error), } } @@ -94,15 +98,6 @@ struct BatchState { buffer_count: u16, } -struct PhysicalBufferState { - id: u32, - generation: u32, - program_id: u32, - schema: BufferSchema, - capacity: u32, - bytes: Vec, -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct PendingBatch { state: BatchState, @@ -112,16 +107,6 @@ struct PendingBatch { buffer_generations: [u32; MAX_PHYSICAL_BUFFERS], } -struct PendingAllocation { - state: PhysicalBufferState, -} - -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -struct RecordRange { - start: u32, - end: u32, -} - #[derive(Clone, Copy)] struct PrepareContext<'a> { policy: &'a ValidatedPolicy, @@ -772,25 +757,9 @@ impl OrderedPlanCompiler { schema: BufferSchema, capacity: u32, ) -> Result<(), OrderedPlanError> { - let length = usize::try_from(capacity) - .ok() - .and_then(|value| value.checked_mul(schema.stride())) - .ok_or(OrderedPlanError::ArithmeticOverflow)?; - let mut bytes = Vec::new(); - bytes - .try_reserve_exact(length) - .map_err(|_| OrderedPlanError::AllocationFailed)?; - bytes.resize(length, 0); reserve(&mut self.pending_allocations, 1)?; self.pending_allocations.push(PendingAllocation { - state: PhysicalBufferState { - id, - generation, - program_id, - schema, - capacity, - bytes, - }, + state: PhysicalBufferState::new(id, generation, program_id, schema, capacity)?, }); Ok(()) } @@ -1101,124 +1070,6 @@ fn instance_unchanged( }) } -fn coalesce_ranges( - ranges: &mut Vec, - program: &super::policy::ProgramDescriptor, - capability: &super::policy::CapabilitySet, - live_records: u32, -) -> Result<(), OrderedPlanError> { - if ranges.is_empty() { - return Ok(()); - } - let bytes_per_record = program.buffers.iter().try_fold(0_u32, |total, schema| { - total - .checked_add(u32::from(schema.stride)) - .ok_or(OrderedPlanError::ArithmeticOverflow) - })?; - let accepted_gap = capability - .coalesce_gap_bytes - .max(capability.range_call_penalty_bytes); - if ranges.len() > 1 { - let mut write = 0; - for read in 1..ranges.len() { - let gap = ranges[read] - .start - .saturating_sub(ranges[write].end) - .saturating_mul(bytes_per_record); - if gap <= accepted_gap { - ranges[write].end = ranges[read].end; - } else { - write += 1; - ranges[write] = ranges[read]; - } - } - ranges.truncate(write + 1); - } - if ranges.len() > usize::from(capability.fragmentation_budget) { - let first = ranges[0].start; - let last = ranges.last().ok_or(OrderedPlanError::InvalidIdentity)?.end; - ranges.clear(); - ranges.push(RecordRange { - start: first, - end: last, - }); - } - let upload_records = ranges.iter().try_fold(0_u32, |total, range| { - total - .checked_add(range.end - range.start) - .ok_or(OrderedPlanError::ArithmeticOverflow) - })?; - let upload_cost = upload_records - .checked_mul(bytes_per_record) - .and_then(|bytes| { - bytes.checked_add( - (ranges.len() as u32).saturating_mul(capability.range_call_penalty_bytes), - ) - }) - .ok_or(OrderedPlanError::ArithmeticOverflow)?; - let full_bytes = live_records - .checked_mul(bytes_per_record) - .ok_or(OrderedPlanError::ArithmeticOverflow)?; - if upload_cost.saturating_mul(10_000) - >= full_bytes.saturating_mul(u32::from(capability.whole_buffer_threshold_basis_points)) - { - ranges.clear(); - ranges.push(RecordRange { - start: 0, - end: live_records, - }); - } - Ok(()) -} - -fn apply_writes( - buffer: &mut PhysicalBufferState, - patches: &[PatchRecord], - payload: &[u8], -) -> Result<(), OrderedPlanError> { - for patch in patches.iter().filter(|patch| { - patch.opcode == PATCH_WRITE - && patch.buffer_id == buffer.id - && patch.buffer_generation == buffer.generation - }) { - let destination = patch.destination_offset as usize; - let source = patch.payload_start as usize; - let length = patch.byte_length as usize; - let destination = buffer - .bytes - .get_mut(destination..destination + length) - .ok_or(OrderedPlanError::InvalidIdentity)?; - let source = payload - .get(source..source + length) - .ok_or(OrderedPlanError::InvalidIdentity)?; - destination.copy_from_slice(source); - } - Ok(()) -} - -fn take_allocation( - allocations: &mut Vec, - id: u32, - generation: u32, -) -> Option { - allocations - .iter() - .position(|allocation| { - allocation.state.id == id && allocation.state.generation == generation - }) - .map(|index| allocations.swap_remove(index)) -} - -fn align_record_range(range: RecordRange, alignment: u32) -> Result { - let start = range.start / alignment * alignment; - let end = range - .end - .checked_add(alignment - 1) - .map(|value| value / alignment * alignment) - .ok_or(OrderedPlanError::ArithmeticOverflow)?; - Ok(RecordRange { start, end }) -} - fn range(start: u32, count: u32) -> Result, OrderedPlanError> { let end = start .checked_add(count) diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs index 3c8ceeda..d9380376 100644 --- a/packages/text/rust/shaper/src/engine/plan_packing.rs +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -8,17 +8,105 @@ use super::{ CapabilitySetId, PhysicalBufferMut, PolicyExecutionError, SemanticInputBatch, ValidatedPolicy, }, + render_plan::{PATCH_WRITE, PatchRecord}, }; pub const MAX_PHYSICAL_BUFFERS: usize = 16; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PackingError { + AllocationFailed, ArithmeticOverflow, CapacityExceeded, + InvalidIdentity, Policy(PolicyExecutionError), } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RecordRange { + pub start: u32, + pub end: u32, +} + +pub struct PhysicalBufferState { + pub id: u32, + pub generation: u32, + pub program_id: u32, + pub schema: super::policy::BufferSchema, + pub capacity: u32, + pub bytes: alloc::vec::Vec, +} + +pub struct PendingAllocation { + pub state: PhysicalBufferState, +} + +impl PhysicalBufferState { + pub fn new( + id: u32, + generation: u32, + program_id: u32, + schema: super::policy::BufferSchema, + capacity: u32, + ) -> Result { + let length = usize::try_from(capacity) + .ok() + .and_then(|value| value.checked_mul(schema.stride())) + .ok_or(PackingError::ArithmeticOverflow)?; + let mut bytes = alloc::vec::Vec::new(); + bytes + .try_reserve_exact(length) + .map_err(|_| PackingError::AllocationFailed)?; + bytes.resize(length, 0); + Ok(Self { + id, + generation, + program_id, + schema, + capacity, + bytes, + }) + } +} + +pub fn apply_writes( + buffer: &mut PhysicalBufferState, + patches: &[PatchRecord], + payload: &[u8], +) -> Result<(), PackingError> { + for patch in patches.iter().filter(|patch| { + patch.opcode == PATCH_WRITE + && patch.buffer_id == buffer.id + && patch.buffer_generation == buffer.generation + }) { + let destination = patch.destination_offset as usize; + let source = patch.payload_start as usize; + let length = patch.byte_length as usize; + let destination = buffer + .bytes + .get_mut(destination..destination + length) + .ok_or(PackingError::InvalidIdentity)?; + let source = payload + .get(source..source + length) + .ok_or(PackingError::InvalidIdentity)?; + destination.copy_from_slice(source); + } + Ok(()) +} + +pub fn take_allocation( + allocations: &mut alloc::vec::Vec, + id: u32, + generation: u32, +) -> Option { + allocations + .iter() + .position(|allocation| { + allocation.state.id == id && allocation.state.generation == generation + }) + .map(|index| allocations.swap_remove(index)) +} + #[allow(clippy::too_many_arguments)] pub fn execute_run( policy: &ValidatedPolicy, @@ -114,6 +202,86 @@ pub fn align_up(value: u32, alignment: u32) -> Result { .ok_or(PackingError::ArithmeticOverflow) } +pub fn align_record_range(range: RecordRange, alignment: u32) -> Result { + let start = range.start / alignment * alignment; + let end = range + .end + .checked_add(alignment - 1) + .map(|value| value / alignment * alignment) + .ok_or(PackingError::ArithmeticOverflow)?; + Ok(RecordRange { start, end }) +} + +pub fn coalesce_ranges( + ranges: &mut alloc::vec::Vec, + program: &super::policy::ProgramDescriptor, + capability: &super::policy::CapabilitySet, + live_records: u32, +) -> Result<(), PackingError> { + if ranges.is_empty() { + return Ok(()); + } + let bytes_per_record = program.buffers.iter().try_fold(0_u32, |total, schema| { + total + .checked_add(u32::from(schema.stride)) + .ok_or(PackingError::ArithmeticOverflow) + })?; + let accepted_gap = capability + .coalesce_gap_bytes + .max(capability.range_call_penalty_bytes); + if ranges.len() > 1 { + let mut write = 0; + for read in 1..ranges.len() { + let gap = ranges[read] + .start + .saturating_sub(ranges[write].end) + .saturating_mul(bytes_per_record); + if gap <= accepted_gap { + ranges[write].end = ranges[read].end; + } else { + write += 1; + ranges[write] = ranges[read]; + } + } + ranges.truncate(write + 1); + } + if ranges.len() > usize::from(capability.fragmentation_budget) { + let first = ranges[0].start; + let last = ranges.last().ok_or(PackingError::InvalidIdentity)?.end; + ranges.clear(); + ranges.push(RecordRange { + start: first, + end: last, + }); + } + let upload_records = ranges.iter().try_fold(0_u32, |total, range| { + total + .checked_add(range.end - range.start) + .ok_or(PackingError::ArithmeticOverflow) + })?; + let upload_cost = upload_records + .checked_mul(bytes_per_record) + .and_then(|bytes| { + bytes.checked_add( + (ranges.len() as u32).saturating_mul(capability.range_call_penalty_bytes), + ) + }) + .ok_or(PackingError::ArithmeticOverflow)?; + let full_bytes = live_records + .checked_mul(bytes_per_record) + .ok_or(PackingError::ArithmeticOverflow)?; + if upload_cost.saturating_mul(10_000) + >= full_bytes.saturating_mul(u32::from(capability.whole_buffer_threshold_basis_points)) + { + ranges.clear(); + ranges.push(RecordRange { + start: 0, + end: live_records, + }); + } + Ok(()) +} + fn gcd(mut left: u32, mut right: u32) -> u32 { while right != 0 { (left, right) = (right, left % right); diff --git a/packages/text/rust/shaper/src/engine/render_plan.rs b/packages/text/rust/shaper/src/engine/render_plan.rs index fc66cfd6..ffb3dde9 100644 --- a/packages/text/rust/shaper/src/engine/render_plan.rs +++ b/packages/text/rust/shaper/src/engine/render_plan.rs @@ -17,6 +17,8 @@ pub const RESOURCE_ACTION_RETAIN: u16 = 3; pub const BUFFER_ORDERED_DIRECT: u16 = 1; pub const BUFFER_STABLE_INDIRECT: u16 = 2; +/// Reserved non-policy binding ID for the stable-indirect logical-order buffer. +pub const POLICY_BUFFER_ORDER: u16 = u16::MAX; pub const PATCH_ALLOCATE_OR_RESIZE: u16 = 1; pub const PATCH_WRITE: u16 = 2; diff --git a/packages/text/rust/shaper/src/engine/stable_order.rs b/packages/text/rust/shaper/src/engine/stable_order.rs index 3f726dc9..b181033e 100644 --- a/packages/text/rust/shaper/src/engine/stable_order.rs +++ b/packages/text/rust/shaper/src/engine/stable_order.rs @@ -56,6 +56,7 @@ pub struct ChunkedOrder { pending_next_chunk_slot: u32, capacity_chunks: u32, pending_capacity_chunks: u32, + pending_rebased: bool, prepared: bool, } @@ -72,6 +73,7 @@ impl ChunkedOrder { self.allocated_free_chunks.clear(); self.pending_next_chunk_slot = self.next_chunk_slot; self.pending_capacity_chunks = self.capacity_chunks; + self.pending_rebased = false; let old_len = self .chunks @@ -108,10 +110,15 @@ impl ChunkedOrder { suffix_entries += usize::from(candidate.len); } - reserve( - &mut self.pending_chunks, - self.chunks.len().saturating_add(1), - )?; + let desired_chunks = desired.len().div_ceil(ORDER_CHUNK_RECORDS as usize); + let pending_chunk_bound = self + .chunks + .len() + .checked_add(desired_chunks) + .and_then(|count| count.checked_add(2)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + reserve(&mut self.pending_chunks, pending_chunk_bound)?; + reserve(&mut self.pending_retired, self.chunks.len())?; for chunk in &self.chunks[..prefix_chunk_count] { self.pending_chunks.push(PendingChunk { slot: chunk.slot, @@ -161,6 +168,14 @@ impl ChunkedOrder { if self.pending_capacity_chunks != self.capacity_chunks { self.mark_every_chunk_changed()?; } + let required_entries = usize::try_from(self.pending_capacity_chunks) + .ok() + .and_then(|chunks| chunks.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let additional_entries = required_entries.saturating_sub(self.entries.len()); + reserve(&mut self.entries, additional_entries)?; + let additional_chunks = self.pending_chunks.len().saturating_sub(self.chunks.len()); + reserve(&mut self.chunks, additional_chunks)?; self.prepared = true; Ok(()) })(); @@ -177,6 +192,66 @@ impl ChunkedOrder { Ok(&self.pending_chunks) } + /// Replaces a fragmented pending order with dense full chunks in a fresh buffer generation. + pub fn rebase(&mut self, desired: &[OrderEntry]) -> Result<(), ChunkedOrderError> { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + validate_desired(desired)?; + self.pending_chunks.clear(); + self.pending_entries.clear(); + self.pending_retired.clear(); + for slot in self.allocated_free_chunks.drain(..) { + self.free_chunks.push(slot); + } + let required_chunks = desired.len().div_ceil(ORDER_CHUNK_RECORDS as usize); + reserve(&mut self.pending_chunks, required_chunks)?; + reserve(&mut self.pending_entries, desired.len())?; + self.pending_rebased = true; + for (slot, entries) in desired.chunks(ORDER_CHUNK_RECORDS as usize).enumerate() { + let slot = u32::try_from(slot).map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + self.push_pending_chunk(slot, entries)?; + } + self.pending_next_chunk_slot = + u32::try_from(required_chunks).map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; + self.pending_capacity_chunks = 0; + self.grow_capacity()?; + let required_entries = usize::try_from(self.pending_capacity_chunks) + .ok() + .and_then(|chunks| chunks.checked_mul(ORDER_CHUNK_RECORDS as usize)) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + let additional_entries = required_entries.saturating_sub(self.entries.len()); + reserve(&mut self.entries, additional_entries)?; + let additional_chunks = required_chunks.saturating_sub(self.chunks.len()); + reserve(&mut self.chunks, additional_chunks)?; + Ok(()) + } + + pub fn span_count(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + let mut spans = 0_u32; + let mut expected = None; + for chunk in &self.pending_chunks { + let start = chunk.record_start(); + if expected != Some(start) { + spans = spans + .checked_add(1) + .ok_or(ChunkedOrderError::ArithmeticOverflow)?; + } + expected = start.checked_add(u32::from(chunk.len)); + } + Ok(spans) + } + + pub fn rebased(&self) -> Result { + if !self.prepared { + return Err(ChunkedOrderError::NotPrepared); + } + Ok(self.pending_rebased) + } + pub fn entries(&self, chunk: PendingChunk) -> Result<&[OrderEntry], ChunkedOrderError> { if !self.prepared { return Err(ChunkedOrderError::NotPrepared); @@ -245,8 +320,6 @@ impl ChunkedOrder { .ok() .and_then(|chunks| chunks.checked_mul(ORDER_CHUNK_RECORDS as usize)) .ok_or(ChunkedOrderError::ArithmeticOverflow)?; - let additional = required.saturating_sub(self.entries.len()); - reserve(&mut self.entries, additional)?; self.entries.resize(required, OrderEntry::default()); for chunk in self .pending_chunks @@ -268,7 +341,6 @@ impl ChunkedOrder { destination.copy_from_slice(source); } self.chunks.clear(); - reserve(&mut self.chunks, self.pending_chunks.len())?; self.chunks .extend(self.pending_chunks.iter().map(|chunk| ChunkState { slot: chunk.slot, @@ -276,10 +348,14 @@ impl ChunkedOrder { })); self.next_chunk_slot = self.pending_next_chunk_slot; self.capacity_chunks = self.pending_capacity_chunks; + if self.pending_rebased { + self.free_chunks.clear(); + } self.pending_chunks.clear(); self.pending_entries.clear(); self.pending_retired.clear(); self.allocated_free_chunks.clear(); + self.pending_rebased = false; self.prepared = false; Ok(()) } @@ -291,9 +367,24 @@ impl ChunkedOrder { self.pending_chunks.clear(); self.pending_entries.clear(); self.pending_retired.clear(); + self.pending_rebased = false; self.prepared = false; } + #[cfg(test)] + pub fn scratch_capacities(&self) -> [usize; 8] { + [ + self.chunks.capacity(), + self.entries.capacity(), + self.free_chunks.capacity(), + self.pending_chunks.capacity(), + self.pending_entries.capacity(), + self.pending_retired.capacity(), + self.allocated_free_chunks.capacity(), + usize::try_from(self.capacity_chunks).unwrap_or(usize::MAX), + ] + } + fn common_prefix(&self, desired: &[OrderEntry]) -> usize { let mut matched = 0_usize; for chunk in &self.chunks { @@ -359,12 +450,13 @@ impl ChunkedOrder { ) -> Result<(), ChunkedOrderError> { let len = u16::try_from(entries.len()).map_err(|_| ChunkedOrderError::ArithmeticOverflow)?; - let unchanged = self - .chunks - .iter() - .find(|chunk| chunk.slot == slot && chunk.len == len) - .and_then(|chunk| self.committed_entries(chunk.slot, chunk.len).ok()) - == Some(entries); + let unchanged = !self.pending_rebased + && self + .chunks + .iter() + .find(|chunk| chunk.slot == slot && chunk.len == len) + .and_then(|chunk| self.committed_entries(chunk.slot, chunk.len).ok()) + == Some(entries); let scratch_start = if unchanged { SCRATCH_NONE } else { @@ -547,6 +639,28 @@ mod tests { assert_eq!(changed_chunks(&order).len(), 3); } + #[test] + fn rebase_restores_one_dense_span_transactionally() { + let mut order = ChunkedOrder::default(); + let initial: Vec<_> = (1..=128).map(entry).collect(); + order.prepare(&initial).unwrap(); + order.commit().unwrap(); + let mut inserted = initial.clone(); + inserted.insert( + 64, + OrderEntry { + stable_id: 200, + record_slot: 200, + }, + ); + order.prepare(&inserted).unwrap(); + assert!(order.span_count().unwrap() > 1); + order.rebase(&inserted).unwrap(); + assert!(order.rebased().unwrap()); + assert_eq!(order.span_count().unwrap(), 1); + assert_eq!(flatten(&order), inserted); + } + fn entry(stable_id: u32) -> OrderEntry { OrderEntry { stable_id, diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs new file mode 100644 index 00000000..20d94c56 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -0,0 +1,1948 @@ +//! Retained stable-indirect physical storage and chunked logical-order plan compilation. +//! +//! Semantic identities retain physical record slots across edits. Logical order is a separate +//! fixed-chunk `u32` index buffer, so an insertion writes one new physical record and only the +//! affected order chunks. Deleted storage remains quarantined until the renderer acknowledges the +//! publication fence that made it unreachable. + +use alloc::vec::Vec; +use core::mem; + +use super::{ + plan_input::{PlanInputError, span_bounds, validate_glyph, validate_input}, + plan_packing::{ + MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, + align_record_range, align_up, apply_writes, coalesce_ranges, execute_run, grown_capacity, + record_alignment, take_allocation, + }, + policy::{ + ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, + BufferId, BufferSchema, CapabilitySetId, PolicyExecutionError, ScalarType, TechniqueId, + ValidatedPolicy, + }, + render_plan::{ + BUFFER_STABLE_INDIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, + POLICY_BUFFER_ORDER, PRIMITIVE_GLYPH, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, + RESOURCE_ACTION_RETAIN, RETIRE_BUFFER, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, + ResourceRecord, RetirementRecord, + }, + stable_order::{ + ChunkedOrder, ChunkedOrderError, ORDER_CHUNK_RECORDS, OrderEntry, PendingChunk, + }, + stable_pool::{SlotIdentity, StablePoolError, StableSlotPool}, +}; + +pub use super::plan_input::{PlanGlyph as StableGlyph, PlanInput as StablePlanInput}; + +const NONE: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StablePlanError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + CapabilitySetMissing, + ProgramMissing, + UnsupportedStrategy, + InvalidInputShape, + InvalidIdentity, + DuplicateIdentity, + InvalidResource, + CapacityExceeded, + IdentifierExhausted, + ArithmeticOverflow, + PolicyExecution(PolicyExecutionError), +} + +impl From for StablePlanError { + fn from(error: PlanInputError) -> Self { + match error { + PlanInputError::InvalidShape => Self::InvalidInputShape, + PlanInputError::InvalidIdentity => Self::InvalidIdentity, + PlanInputError::InvalidResource => Self::InvalidResource, + } + } +} + +impl From for StablePlanError { + fn from(error: PackingError) -> Self { + match error { + PackingError::AllocationFailed => Self::AllocationFailed, + PackingError::ArithmeticOverflow => Self::ArithmeticOverflow, + PackingError::CapacityExceeded => Self::CapacityExceeded, + PackingError::InvalidIdentity => Self::InvalidIdentity, + PackingError::Policy(error) => Self::PolicyExecution(error), + } + } +} + +impl From for StablePlanError { + fn from(error: StablePoolError) -> Self { + match error { + StablePoolError::AllocationFailed => Self::AllocationFailed, + StablePoolError::AlreadyPrepared => Self::AlreadyPrepared, + StablePoolError::NotPrepared => Self::NotPrepared, + StablePoolError::InvalidIdentity => Self::InvalidIdentity, + StablePoolError::DuplicateIdentity => Self::DuplicateIdentity, + StablePoolError::IdentifierExhausted => Self::IdentifierExhausted, + StablePoolError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} + +impl From for StablePlanError { + fn from(error: ChunkedOrderError) -> Self { + match error { + ChunkedOrderError::AllocationFailed => Self::AllocationFailed, + ChunkedOrderError::AlreadyPrepared => Self::AlreadyPrepared, + ChunkedOrderError::NotPrepared => Self::NotPrepared, + ChunkedOrderError::ArithmeticOverflow => Self::ArithmeticOverflow, + ChunkedOrderError::InvalidIdentity => Self::InvalidIdentity, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BatchKey { + technique: TechniqueId, + program_variant: u16, + program_id: u32, + resource_id: u32, + resource_generation: u32, + resource_kind: u16, + resource_reference: u32, + material_id: u32, + clip_id: u32, + depth_key: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct QuarantinedChunk { + slot: u32, + after_publication_generation: u32, +} + +struct StableBatch { + key: BatchKey, + slots: StableSlotPool, + order: ChunkedOrder, + buffers: Vec, + spare_buffers: Vec, + order_buffer: Option, + quarantined_chunks: Vec, + pending_retired_chunks: Vec, + reclaim_scratch: Vec, + active: bool, +} + +impl StableBatch { + fn new(key: BatchKey) -> Self { + Self { + key, + slots: StableSlotPool::default(), + order: ChunkedOrder::default(), + buffers: Vec::new(), + spare_buffers: Vec::new(), + order_buffer: None, + quarantined_chunks: Vec::new(), + pending_retired_chunks: Vec::new(), + reclaim_scratch: Vec::new(), + active: false, + } + } + + fn acknowledge(&mut self, through_generation: u32) -> Result<(), StablePlanError> { + self.slots.acknowledge(through_generation)?; + self.reclaim_scratch.clear(); + let count = self + .quarantined_chunks + .iter() + .filter(|entry| entry.after_publication_generation <= through_generation) + .count(); + reserve(&mut self.reclaim_scratch, count)?; + self.reclaim_scratch.extend( + self.quarantined_chunks + .iter() + .filter(|entry| entry.after_publication_generation <= through_generation) + .map(|entry| entry.slot), + ); + self.order.reclaim_chunks(&self.reclaim_scratch)?; + self.quarantined_chunks + .retain(|entry| entry.after_publication_generation > through_generation); + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PendingBatch { + batch_index: u32, + item_start: u32, + item_count: u32, + cursor: u32, + capacity: u32, + buffer_start: u32, + buffer_count: u16, + buffer_ids: [u32; MAX_PHYSICAL_BUFFERS], + buffer_generations: [u32; MAX_PHYSICAL_BUFFERS], + order_buffer_id: u32, + order_buffer_generation: u32, + order_capacity: u32, +} + +impl PendingBatch { + fn new(batch_index: u32) -> Self { + Self { + batch_index, + item_start: 0, + item_count: 0, + cursor: 0, + capacity: 0, + buffer_start: 0, + buffer_count: 0, + buffer_ids: [0; MAX_PHYSICAL_BUFFERS], + buffer_generations: [0; MAX_PHYSICAL_BUFFERS], + order_buffer_id: 0, + order_buffer_generation: 0, + order_capacity: 0, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct SlotWrite { + slot: u32, + input_index: u32, + changed: bool, +} + +#[derive(Clone, Copy)] +struct PrepareContext<'a> { + policy: &'a ValidatedPolicy, + capability_set: CapabilitySetId, + capability: &'a super::policy::CapabilitySet, + input: StablePlanInput<'a>, + checkpoint: bool, + publication_generation: u32, +} + +#[derive(Default)] +pub struct StablePlanCompiler { + batches: Vec, + committed_batch_count: usize, + pending_batches: Vec, + batch_pending_indices: Vec, + input_batches: Vec, + input_slots: Vec, + input_order_records: Vec, + batch_input_indices: Vec, + batch_identities: Vec, + order_entries: Vec, + order_chunk_scratch: Vec, + slot_writes: Vec, + changed_ranges: Vec, + identity_keys: Vec, + identity_epochs: Vec, + identity_epoch: u32, + pending_allocations: Vec, + resources: Vec, + plan_buffers: Vec, + primitives: Vec, + draws: Vec, + live_primitives: Vec, + live_draws: Vec, + patches: Vec, + retirements: Vec, + payload: Vec, + next_buffer_id: u32, + pending_next_buffer_id: u32, + pending_publication_generation: u32, + publish_bindings: bool, + prepared: bool, +} + +impl StablePlanCompiler { + #[allow(clippy::too_many_arguments)] + pub fn prepare( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), StablePlanError> { + if self.prepared { + return Err(StablePlanError::AlreadyPrepared); + } + if publication_generation == 0 + || acknowledged_publication_generation >= publication_generation + { + return Err(StablePlanError::InvalidIdentity); + } + let result = self.prepare_inner( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ); + if result.is_err() { + self.abort(); + } + result + } + + fn prepare_inner( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), StablePlanError> { + let capability = policy + .capability_set(capability_set) + .ok_or(StablePlanError::CapabilitySetMissing)?; + validate_input(input)?; + for batch in &mut self.batches { + batch.acknowledge(acknowledged_publication_generation)?; + } + self.reset_pending(); + self.committed_batch_count = self.batches.len(); + self.pending_publication_generation = publication_generation; + self.prepare_identity_set(input.glyphs.len())?; + reserve(&mut self.input_batches, input.glyphs.len())?; + reserve(&mut self.input_slots, input.glyphs.len())?; + reserve(&mut self.input_order_records, input.glyphs.len())?; + self.input_batches.resize(input.glyphs.len(), 0); + self.input_slots.resize(input.glyphs.len(), 0); + self.input_order_records.resize(input.glyphs.len(), 0); + self.batch_pending_indices.resize(self.batches.len(), NONE); + + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + validate_glyph(glyph)?; + if !self.insert_identity(glyph.stable_id) { + return Err(StablePlanError::DuplicateIdentity); + } + let program = policy + .program(capability_set, glyph.technique, glyph.program_variant) + .ok_or(StablePlanError::ProgramMissing)?; + if program.allocation_strategy != ALLOCATION_STABLE_INDIRECT { + return Err(StablePlanError::UnsupportedStrategy); + } + let resource_bit = 1_u32 + .checked_shl(u32::from(glyph.resource_kind - 1)) + .ok_or(StablePlanError::InvalidResource)?; + if program.resource_kind_mask & resource_bit == 0 { + return Err(StablePlanError::InvalidResource); + } + let key = BatchKey { + technique: glyph.technique, + program_variant: glyph.program_variant, + program_id: program.id.0, + resource_id: glyph.resource_id, + resource_generation: glyph.resource_generation, + resource_kind: glyph.resource_kind, + resource_reference: glyph.resource_reference, + material_id: if program.storage_key_mask & BATCH_MATERIAL != 0 { + glyph.material_id + } else { + 0 + }, + clip_id: if program.storage_key_mask & super::policy::BATCH_CLIP != 0 { + glyph.clip_id + } else { + 0 + }, + depth_key: if program.storage_key_mask & super::policy::BATCH_DEPTH != 0 { + glyph.depth_key + } else { + 0 + }, + }; + let batch_index = match self.batches.iter().position(|batch| batch.key == key) { + Some(index) => index, + None => { + reserve(&mut self.batches, 1)?; + self.batches.push(StableBatch::new(key)); + reserve(&mut self.batch_pending_indices, 1)?; + self.batch_pending_indices.push(NONE); + self.batches.len() - 1 + } + }; + let pending_index = if self.batch_pending_indices[batch_index] == NONE { + reserve(&mut self.pending_batches, 1)?; + let index = self.pending_batches.len(); + self.pending_batches.push(PendingBatch::new( + u32::try_from(batch_index).map_err(|_| StablePlanError::ArithmeticOverflow)?, + )); + self.batch_pending_indices[batch_index] = + u32::try_from(index).map_err(|_| StablePlanError::ArithmeticOverflow)?; + index + } else { + self.batch_pending_indices[batch_index] as usize + }; + self.pending_batches[pending_index].item_count = self.pending_batches[pending_index] + .item_count + .checked_add(1) + .ok_or(StablePlanError::ArithmeticOverflow)?; + self.input_batches[input_index] = + u32::try_from(pending_index).map_err(|_| StablePlanError::ArithmeticOverflow)?; + } + + self.layout_batch_inputs(input)?; + let context = PrepareContext { + policy, + capability_set, + capability, + input, + checkpoint, + publication_generation, + }; + self.pending_next_buffer_id = self.next_buffer_id; + for batch_index in 0..self.batches.len() { + self.prepare_batch_identity( + batch_index, + publication_generation, + capability.fragmentation_budget, + )?; + if self.batch_pending_indices[batch_index] != NONE { + let pending_index = self.batch_pending_indices[batch_index] as usize; + self.prepare_batch_storage(context, pending_index)?; + } else if self.batches[batch_index].active { + self.prepare_removed_batch(batch_index, publication_generation)?; + } + } + self.compile_bindings(context)?; + self.prepared = true; + Ok(()) + } + + pub fn plan_view( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, StablePlanError> { + if !self.prepared { + return Err(StablePlanError::NotPrepared); + } + Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + resources: if self.publish_bindings { + &self.resources + } else { + &[] + }, + buffers: if self.publish_bindings { + &self.plan_buffers + } else { + &[] + }, + patches: &self.patches, + retirements: &self.retirements, + primitives: if self.publish_bindings { + &self.primitives + } else { + &[] + }, + draws: if self.publish_bindings { + &self.draws + } else { + &[] + }, + payload: &self.payload, + ..RenderPlanView::default() + }) + } + + pub fn commit(&mut self) -> Result<(), StablePlanError> { + if !self.prepared { + return Err(StablePlanError::NotPrepared); + } + for batch_index in 0..self.batches.len() { + let pending_index = self.batch_pending_indices[batch_index]; + self.commit_batch_storage(batch_index, pending_index)?; + let batch = &mut self.batches[batch_index]; + batch.slots.commit()?; + let order_rebased = batch.order.rebased()?; + if order_rebased { + batch.quarantined_chunks.clear(); + } + batch + .quarantined_chunks + .extend( + batch + .pending_retired_chunks + .iter() + .map(|&slot| QuarantinedChunk { + slot, + after_publication_generation: self.pending_publication_generation, + }), + ); + batch.pending_retired_chunks.clear(); + batch.order.commit()?; + batch.active = pending_index != NONE; + } + mem::swap(&mut self.live_primitives, &mut self.primitives); + self.primitives.clear(); + mem::swap(&mut self.live_draws, &mut self.draws); + self.draws.clear(); + self.next_buffer_id = self.pending_next_buffer_id; + self.prepared = false; + Ok(()) + } + + pub fn abort(&mut self) { + for batch in &mut self.batches { + batch.slots.abort(); + batch.order.abort(); + batch.pending_retired_chunks.clear(); + } + self.batches.truncate(self.committed_batch_count); + self.pending_allocations.clear(); + self.prepared = false; + } + + pub fn buffer_bytes(&self, id: u32) -> Option<&[u8]> { + self.batches.iter().find_map(|batch| { + batch + .buffers + .iter() + .find(|buffer| buffer.id == id) + .or_else(|| batch.order_buffer.as_ref().filter(|buffer| buffer.id == id)) + .map(|buffer| buffer.bytes.as_slice()) + }) + } + + fn reset_pending(&mut self) { + self.pending_batches.clear(); + self.batch_pending_indices.clear(); + self.batch_input_indices.clear(); + self.batch_identities.clear(); + self.order_entries.clear(); + self.order_chunk_scratch.clear(); + self.slot_writes.clear(); + self.changed_ranges.clear(); + self.pending_allocations.clear(); + self.resources.clear(); + self.plan_buffers.clear(); + self.primitives.clear(); + self.draws.clear(); + self.patches.clear(); + self.retirements.clear(); + self.payload.clear(); + self.publish_bindings = false; + } + + fn layout_batch_inputs(&mut self, input: StablePlanInput<'_>) -> Result<(), StablePlanError> { + reserve(&mut self.batch_input_indices, input.glyphs.len())?; + reserve(&mut self.batch_identities, input.glyphs.len())?; + self.batch_input_indices.resize(input.glyphs.len(), 0); + self.batch_identities.resize( + input.glyphs.len(), + SlotIdentity { + stable_id: 0, + content_revision: 0, + }, + ); + let mut cursor = 0_u32; + for pending in &mut self.pending_batches { + pending.item_start = cursor; + pending.cursor = cursor; + cursor = cursor + .checked_add(pending.item_count) + .ok_or(StablePlanError::ArithmeticOverflow)?; + } + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + let pending_index = self.input_batches[input_index] as usize; + let destination = self.pending_batches[pending_index].cursor as usize; + self.batch_input_indices[destination] = + u32::try_from(input_index).map_err(|_| StablePlanError::ArithmeticOverflow)?; + self.batch_identities[destination] = SlotIdentity { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + }; + self.pending_batches[pending_index].cursor = self.pending_batches[pending_index] + .cursor + .checked_add(1) + .ok_or(StablePlanError::ArithmeticOverflow)?; + } + Ok(()) + } + + fn prepare_batch_identity( + &mut self, + batch_index: usize, + publication_generation: u32, + fragmentation_budget: u16, + ) -> Result<(), StablePlanError> { + let pending_index = self.batch_pending_indices[batch_index]; + let identities = if pending_index == NONE { + &[][..] + } else { + let pending = self.pending_batches[pending_index as usize]; + self.batch_identities + .get(range(pending.item_start, pending.item_count)?) + .ok_or(StablePlanError::InvalidIdentity)? + }; + let batch = &mut self.batches[batch_index]; + batch.slots.prepare(identities, publication_generation)?; + let assignments = batch.slots.assignments()?; + self.order_entries.clear(); + reserve(&mut self.order_entries, assignments.len())?; + for assignment in assignments { + self.order_entries.push(OrderEntry { + stable_id: assignment.stable_id, + record_slot: assignment.slot, + }); + } + batch.order.prepare(&self.order_entries)?; + if batch.order.span_count()? > u32::from(fragmentation_budget) { + batch.order.rebase(&self.order_entries)?; + } + batch.pending_retired_chunks.clear(); + reserve( + &mut batch.pending_retired_chunks, + batch.order.retired_chunks()?.len(), + )?; + batch + .pending_retired_chunks + .extend_from_slice(batch.order.retired_chunks()?); + reserve( + &mut batch.quarantined_chunks, + batch.pending_retired_chunks.len(), + )?; + + if pending_index != NONE { + let pending = self.pending_batches[pending_index as usize]; + for (offset, assignment) in assignments.iter().enumerate() { + let item = pending.item_start as usize + offset; + let input_index = self.batch_input_indices[item] as usize; + self.input_slots[input_index] = assignment.slot; + } + let mut item_offset = 0_usize; + for chunk in batch.order.pending_chunks()?.iter().copied() { + for offset in 0..usize::from(chunk.len) { + let input_index = + self.batch_input_indices[pending.item_start as usize + item_offset]; + self.input_order_records[input_index as usize] = chunk + .record_start() + .checked_add(offset as u32) + .ok_or(StablePlanError::ArithmeticOverflow)?; + item_offset += 1; + } + } + if item_offset != assignments.len() { + return Err(StablePlanError::InvalidIdentity); + } + } + Ok(()) + } + + fn prepare_batch_storage( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch_index = pending.batch_index as usize; + let key = self.batches[batch_index].key; + let program = context + .policy + .program(context.capability_set, key.technique, key.program_variant) + .ok_or(StablePlanError::ProgramMissing)?; + let required_slots = self.batches[batch_index].slots.required_slots()?; + let previous_capacity = self.batches[batch_index] + .buffers + .first() + .map_or(0, |buffer| buffer.capacity); + let capacity = align_up( + if previous_capacity >= required_slots { + previous_capacity + } else { + grown_capacity(previous_capacity.max(1), required_slots)? + }, + record_alignment(program, context.capability.update_alignment)?, + )?; + for schema in &program.buffers { + if capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)? + > context.capability.max_buffer_bytes + { + return Err(StablePlanError::CapacityExceeded); + } + } + self.pending_batches[pending_index].capacity = capacity; + let resized = self.batches[batch_index].buffers.is_empty() || capacity != previous_capacity; + let replace = context.checkpoint || resized; + reserve( + &mut self.batches[batch_index].spare_buffers, + program.buffers.len(), + )?; + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + let previous = self.batches[batch_index] + .buffers + .get(schema_index) + .map(|buffer| (buffer.id, buffer.generation, buffer.bytes.len())); + let (id, generation) = self.next_buffer_identity(previous, resized)?; + self.pending_batches[pending_index].buffer_ids[schema_index] = id; + self.pending_batches[pending_index].buffer_generations[schema_index] = generation; + if replace { + self.allocate_buffer(id, generation, key.program_id, schema, capacity)?; + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_ALLOCATE_OR_RESIZE, + buffer_id: id, + buffer_generation: generation, + byte_length: capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + if let Some((old_id, old_generation, old_length)) = previous + && (generation != old_generation || id != old_id) + { + self.retire_buffer( + old_id, + old_generation, + old_length, + context.publication_generation, + )?; + } + } + } + self.pending_batches[pending_index].buffer_count = u16::try_from(program.buffers.len()) + .map_err(|_| StablePlanError::ArithmeticOverflow)?; + self.write_physical_records(context, pending_index, program, replace)?; + self.prepare_order_buffer(context, pending_index)?; + self.retire_removed_slots(context, pending_index)?; + Ok(()) + } + + fn write_physical_records( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + program: &super::policy::ProgramDescriptor, + replace: bool, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let assignments = batch.slots.assignments()?; + self.slot_writes.clear(); + reserve(&mut self.slot_writes, assignments.len())?; + for (offset, assignment) in assignments.iter().copied().enumerate() { + self.slot_writes.push(SlotWrite { + slot: assignment.slot, + input_index: self.batch_input_indices[pending.item_start as usize + offset], + changed: assignment.changed, + }); + } + self.slot_writes.sort_unstable_by_key(|write| write.slot); + self.changed_ranges.clear(); + reserve(&mut self.changed_ranges, self.slot_writes.len())?; + for write in &self.slot_writes { + if replace || write.changed { + self.changed_ranges.push(RecordRange { + start: write.slot, + end: write + .slot + .checked_add(1) + .ok_or(StablePlanError::ArithmeticOverflow)?, + }); + } + } + let required_slots = batch.slots.required_slots()?; + coalesce_ranges( + &mut self.changed_ranges, + program, + context.capability, + required_slots, + )?; + let record_alignment = record_alignment(program, context.capability.update_alignment)?; + for range_index in 0..self.changed_ranges.len() { + let changed = align_record_range(self.changed_ranges[range_index], record_alignment)?; + let count = changed.end - changed.start; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + for (schema_index, schema) in program.buffers.iter().enumerate() { + let byte_count = count as usize * schema.stride(); + let payload_start = self.payload.len(); + reserve(&mut self.payload, byte_count)?; + self.payload.resize(payload_start + byte_count, 0); + payload_starts[schema_index] = payload_start; + if !replace { + let old = self.batches[pending.batch_index as usize] + .buffers + .get(schema_index) + .ok_or(StablePlanError::InvalidIdentity)?; + let source_start = changed.start as usize * schema.stride(); + let source_end = source_start + byte_count; + let source = old + .bytes + .get(source_start..source_end) + .ok_or(StablePlanError::InvalidIdentity)?; + self.payload[payload_start..payload_start + byte_count].copy_from_slice(source); + } + } + let mut write_index = self + .slot_writes + .partition_point(|write| write.slot < changed.start); + while write_index < self.slot_writes.len() + && self.slot_writes[write_index].slot < changed.end + { + if !replace && !self.slot_writes[write_index].changed { + write_index += 1; + continue; + } + let first = self.slot_writes[write_index]; + let mut end = write_index + 1; + while end < self.slot_writes.len() + && self.slot_writes[end].slot == first.slot + (end - write_index) as u32 + && self.slot_writes[end].input_index + == first.input_index + (end - write_index) as u32 + && (replace || self.slot_writes[end].changed) + && self.slot_writes[end].slot < changed.end + { + end += 1; + } + execute_run( + context.policy, + context.capability_set, + program, + context.input, + first.input_index as usize, + (end - write_index) as u32, + first.slot - changed.start, + &mut self.payload, + &payload_starts, + count, + )?; + write_index = end; + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_WRITE, + buffer_id: pending.buffer_ids[schema_index], + buffer_generation: pending.buffer_generations[schema_index], + destination_offset: changed + .start + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: count + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + payload_start: u32::try_from(payload_starts[schema_index]) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + } + } + Ok(()) + } + + fn prepare_order_buffer( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch_index = pending.batch_index as usize; + let order_capacity = self.batches[batch_index].order.capacity_records()?; + if order_capacity + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)? + > context.capability.max_buffer_bytes + { + return Err(StablePlanError::CapacityExceeded); + } + let previous = self.batches[batch_index] + .order_buffer + .as_ref() + .map(|buffer| { + ( + buffer.id, + buffer.generation, + buffer.capacity, + buffer.bytes.len(), + ) + }); + let regenerated = previous.is_none_or(|(_, _, capacity, _)| capacity != order_capacity) + || self.batches[batch_index].order.rebased()?; + let replace = context.checkpoint || regenerated; + let identity = previous.map(|(id, generation, _, length)| (id, generation, length)); + let (id, generation) = self.next_buffer_identity(identity, regenerated)?; + self.pending_batches[pending_index].order_buffer_id = id; + self.pending_batches[pending_index].order_buffer_generation = generation; + self.pending_batches[pending_index].order_capacity = order_capacity; + if replace { + let key = self.batches[batch_index].key; + self.allocate_buffer( + id, + generation, + key.program_id, + order_schema(), + order_capacity, + )?; + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_ALLOCATE_OR_RESIZE, + buffer_id: id, + buffer_generation: generation, + byte_length: order_capacity + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + if let Some((old_id, old_generation, _, old_length)) = previous + && generation != old_generation + { + self.retire_buffer( + old_id, + old_generation, + old_length, + context.publication_generation, + )?; + } + } + self.order_chunk_scratch.clear(); + let chunk_count = self.batches[batch_index].order.pending_chunks()?.len(); + reserve(&mut self.order_chunk_scratch, chunk_count)?; + self.order_chunk_scratch.extend( + self.batches[batch_index] + .order + .pending_chunks()? + .iter() + .copied() + .filter(|chunk| replace || chunk.changed()), + ); + for chunk_index in 0..self.order_chunk_scratch.len() { + let chunk = self.order_chunk_scratch[chunk_index]; + let entries = self.batches[batch_index].order.entries(chunk)?; + let payload_start = self.payload.len(); + let byte_length = usize::from(chunk.len) * 4; + reserve(&mut self.payload, byte_length)?; + for entry in entries { + self.payload + .extend_from_slice(&entry.record_slot.to_le_bytes()); + } + reserve(&mut self.patches, 1)?; + self.patches.push(PatchRecord { + opcode: PATCH_WRITE, + buffer_id: id, + buffer_generation: generation, + destination_offset: chunk + .record_start() + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: u32::try_from(byte_length) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + payload_start: u32::try_from(payload_start) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + ..PatchRecord::default() + }); + } + Ok(()) + } + + fn retire_removed_slots( + &mut self, + context: PrepareContext<'_>, + pending_index: usize, + ) -> Result<(), StablePlanError> { + let pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let slot_retirements = batch + .slots + .retired_slots()? + .len() + .checked_mul(batch.buffers.len()) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let order_retirements = if batch.order_buffer.is_some() { + batch.order.retired_chunks()?.len() + } else { + 0 + }; + let retirement_count = slot_retirements + .checked_add(order_retirements) + .ok_or(StablePlanError::ArithmeticOverflow)?; + reserve(&mut self.retirements, retirement_count)?; + for &slot in batch.slots.retired_slots()? { + for buffer in &batch.buffers { + self.retirements.push(RetirementRecord { + kind: RETIRE_SLOT_RANGE, + id: buffer.id, + generation: buffer.generation, + after_publication_generation: context.publication_generation, + byte_offset: slot + .checked_mul(u32::from(buffer.schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: u32::from(buffer.schema.stride), + ..RetirementRecord::default() + }); + } + } + if let Some(order_buffer) = &batch.order_buffer { + for &slot in batch.order.retired_chunks()? { + self.retirements.push(RetirementRecord { + kind: RETIRE_SLOT_RANGE, + id: order_buffer.id, + generation: order_buffer.generation, + after_publication_generation: context.publication_generation, + byte_offset: slot + .checked_mul(ORDER_CHUNK_RECORDS) + .and_then(|value| value.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + byte_length: ORDER_CHUNK_RECORDS * 4, + ..RetirementRecord::default() + }); + } + } + Ok(()) + } + + fn prepare_removed_batch( + &mut self, + batch_index: usize, + publication_generation: u32, + ) -> Result<(), StablePlanError> { + let key = self.batches[batch_index].key; + if !self.batches.iter().enumerate().any(|(index, candidate)| { + index != batch_index + && self.batch_pending_indices[index] != NONE + && candidate.key.resource_id == key.resource_id + && candidate.key.resource_generation == key.resource_generation + }) && !self.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == key.resource_id + && retirement.generation == key.resource_generation + }) { + reserve(&mut self.retirements, 1)?; + self.retirements.push(RetirementRecord { + kind: RETIRE_RESOURCE, + id: key.resource_id, + generation: key.resource_generation, + after_publication_generation: publication_generation, + ..RetirementRecord::default() + }); + } + for buffer_index in 0..self.batches[batch_index].buffers.len() { + let buffer = &self.batches[batch_index].buffers[buffer_index]; + let (id, generation, byte_length) = (buffer.id, buffer.generation, buffer.bytes.len()); + self.retire_buffer(id, generation, byte_length, publication_generation)?; + } + if let Some((id, generation, byte_length)) = self.batches[batch_index] + .order_buffer + .as_ref() + .map(|buffer| (buffer.id, buffer.generation, buffer.bytes.len())) + { + self.retire_buffer(id, generation, byte_length, publication_generation)?; + } + Ok(()) + } + + fn compile_bindings(&mut self, context: PrepareContext<'_>) -> Result<(), StablePlanError> { + for pending_index in 0..self.pending_batches.len() { + let mut pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let program = context + .policy + .program( + context.capability_set, + batch.key.technique, + batch.key.program_variant, + ) + .ok_or(StablePlanError::ProgramMissing)?; + let binding_count = program.buffers.len() + 1; + if binding_count > usize::from(context.capability.max_buffers_per_draw) { + return Err(StablePlanError::CapacityExceeded); + } + pending.buffer_start = u32::try_from(self.plan_buffers.len()) + .map_err(|_| StablePlanError::ArithmeticOverflow)?; + reserve(&mut self.plan_buffers, binding_count)?; + for (schema_index, schema) in program.buffers.iter().copied().enumerate() { + self.plan_buffers.push(BufferRecord { + id: pending.buffer_ids[schema_index], + generation: pending.buffer_generations[schema_index], + program_id: batch.key.program_id, + policy_buffer_id: schema.id.0, + scalar_type: schema.scalar as u8, + vector_width: schema.vector_width, + strategy: BUFFER_STABLE_INDIRECT, + flags: schema.usage as u16, + live_records: pending.item_count, + capacity_records: pending.capacity, + byte_length: pending + .capacity + .checked_mul(u32::from(schema.stride)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + order_buffer_id: pending.order_buffer_id, + }); + } + self.plan_buffers.push(BufferRecord { + id: pending.order_buffer_id, + generation: pending.order_buffer_generation, + program_id: batch.key.program_id, + policy_buffer_id: POLICY_BUFFER_ORDER, + scalar_type: ScalarType::U32 as u8, + vector_width: 1, + strategy: BUFFER_STABLE_INDIRECT, + flags: (BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST) as u16, + live_records: pending.item_count, + capacity_records: pending.order_capacity, + byte_length: pending + .order_capacity + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + order_buffer_id: 0, + }); + pending.buffer_count = + u16::try_from(binding_count).map_err(|_| StablePlanError::ArithmeticOverflow)?; + self.pending_batches[pending_index] = pending; + } + self.compile_resources(context)?; + self.compile_draws(context)?; + self.publish_bindings = context.checkpoint + || !self.patches.is_empty() + || !self.retirements.is_empty() + || self.primitives != self.live_primitives + || self.draws != self.live_draws; + Ok(()) + } + + fn compile_resources(&mut self, context: PrepareContext<'_>) -> Result<(), StablePlanError> { + for glyph in context.input.glyphs.iter().copied() { + if let Some(resource) = self.resources.iter().find(|resource| { + resource.id == glyph.resource_id && resource.generation == glyph.resource_generation + }) { + if resource.technique_id != glyph.technique.0 + || resource.resource_kind != glyph.resource_kind + || resource.reference_id != glyph.resource_reference + { + return Err(StablePlanError::InvalidResource); + } + continue; + } + let existing = self.batches.iter().any(|batch| { + batch.active + && batch.key.resource_id == glyph.resource_id + && batch.key.resource_generation == glyph.resource_generation + }); + reserve(&mut self.resources, 1)?; + self.resources.push(ResourceRecord { + id: glyph.resource_id, + generation: glyph.resource_generation, + technique_id: glyph.technique.0, + resource_kind: glyph.resource_kind, + action: if context.checkpoint || !existing { + RESOURCE_ACTION_CREATE + } else { + RESOURCE_ACTION_RETAIN + }, + reference_id: glyph.resource_reference, + ..ResourceRecord::default() + }); + } + Ok(()) + } + + fn compile_draws(&mut self, context: PrepareContext<'_>) -> Result<(), StablePlanError> { + if context.capability.max_resources_per_draw < 1 { + return Err(StablePlanError::CapacityExceeded); + } + let mut input_index = 0_usize; + while input_index < context.input.glyphs.len() { + let first = context.input.glyphs[input_index]; + let pending_index = self.input_batches[input_index] as usize; + let pending = self.pending_batches[pending_index]; + let batch = &self.batches[pending.batch_index as usize]; + let program = context + .policy + .program( + context.capability_set, + first.technique, + first.program_variant, + ) + .ok_or(StablePlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let first_record = self.input_order_records[input_index]; + let mut end = input_index + 1; + while end < context.input.glyphs.len() + && end - input_index < usize::from(u16::MAX) + && self.same_draw_span( + context.input.glyphs, + input_index, + end, + pending_index, + first_record, + split_material, + ) + { + end += 1; + } + let count = u16::try_from(end - input_index) + .map_err(|_| StablePlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = + span_bounds(&context.input.glyphs[input_index..end])?; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(StablePlanError::InvalidResource)?; + let primitive_start = self.primitives.len(); + reserve(&mut self.primitives, 1)?; + self.primitives.push(PrimitiveRecord { + id: first.stable_id, + kind: PRIMITIVE_GLYPH, + technique_id: first.technique.0, + resource_id: first.resource_id, + resource_generation: first.resource_generation, + program_id: batch.key.program_id, + program_variant: first.program_variant, + record_count: count, + buffer_id: pending.order_buffer_id, + record_index: first_record, + logical_order: u32::try_from(input_index) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + clip_id: first.clip_id, + semantic_id: first.semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + ..PrimitiveRecord::default() + }); + reserve(&mut self.draws, 1)?; + self.draws.push(DrawRecord { + id: first.stable_id, + program_id: batch.key.program_id, + program_variant: first.program_variant, + material_id: if split_material { first.material_id } else { 0 }, + clip_id: first.clip_id, + depth_key: first.depth_key, + primitive_start: u32::try_from(primitive_start) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + primitive_count: 1, + buffer_start: pending.buffer_start, + buffer_count: u32::from(pending.buffer_count), + resource_start: u32::try_from(resource_start) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + resource_count: 1, + order_token: u32::try_from(input_index) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + indirect_buffer_id: pending.order_buffer_id, + indirect_offset: first_record + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + ..DrawRecord::default() + }); + input_index = end; + } + Ok(()) + } + + fn same_draw_span( + &self, + glyphs: &[StableGlyph], + start: usize, + next: usize, + pending_index: usize, + first_record: u32, + split_material: bool, + ) -> bool { + let first = glyphs[start]; + let glyph = glyphs[next]; + self.input_batches[next] as usize == pending_index + && self.input_order_records[next] == first_record + (next - start) as u32 + && glyph.technique == first.technique + && glyph.program_variant == first.program_variant + && glyph.resource_id == first.resource_id + && glyph.resource_generation == first.resource_generation + && (!split_material || glyph.material_id == first.material_id) + && glyph.clip_id == first.clip_id + && glyph.depth_key == first.depth_key + && glyph.semantic_id == first.semantic_id + } + + fn next_buffer_identity( + &mut self, + previous: Option<(u32, u32, usize)>, + replace: bool, + ) -> Result<(u32, u32), StablePlanError> { + if let Some((id, generation, _)) = previous { + return Ok(( + id, + if replace { + generation + .checked_add(1) + .ok_or(StablePlanError::IdentifierExhausted)? + } else { + generation + }, + )); + } + self.pending_next_buffer_id = self + .pending_next_buffer_id + .checked_add(1) + .ok_or(StablePlanError::IdentifierExhausted)?; + Ok((self.pending_next_buffer_id, 1)) + } + + fn allocate_buffer( + &mut self, + id: u32, + generation: u32, + program_id: u32, + schema: BufferSchema, + capacity: u32, + ) -> Result<(), StablePlanError> { + reserve(&mut self.pending_allocations, 1)?; + self.pending_allocations.push(PendingAllocation { + state: PhysicalBufferState::new(id, generation, program_id, schema, capacity)?, + }); + Ok(()) + } + + fn retire_buffer( + &mut self, + id: u32, + generation: u32, + byte_length: usize, + publication_generation: u32, + ) -> Result<(), StablePlanError> { + reserve(&mut self.retirements, 1)?; + self.retirements.push(RetirementRecord { + kind: RETIRE_BUFFER, + id, + generation, + after_publication_generation: publication_generation, + byte_length: u32::try_from(byte_length) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + ..RetirementRecord::default() + }); + Ok(()) + } + + fn commit_batch_storage( + &mut self, + batch_index: usize, + pending_index: u32, + ) -> Result<(), StablePlanError> { + if pending_index == NONE { + self.batches[batch_index].buffers.clear(); + self.batches[batch_index].order_buffer = None; + return Ok(()); + } + let pending = self.pending_batches[pending_index as usize]; + let batch = &mut self.batches[batch_index]; + mem::swap(&mut batch.buffers, &mut batch.spare_buffers); + batch.buffers.clear(); + for index in 0..usize::from(pending.buffer_count.saturating_sub(1)) { + let id = pending.buffer_ids[index]; + let generation = pending.buffer_generations[index]; + let mut buffer = if let Some(allocation) = + take_allocation(&mut self.pending_allocations, id, generation) + { + allocation.state + } else { + let position = batch + .spare_buffers + .iter() + .position(|buffer| buffer.id == id && buffer.generation == generation) + .ok_or(StablePlanError::InvalidIdentity)?; + batch.spare_buffers.swap_remove(position) + }; + apply_writes(&mut buffer, &self.patches, &self.payload)?; + batch.buffers.push(buffer); + } + batch.spare_buffers.clear(); + let mut order_buffer = if let Some(allocation) = take_allocation( + &mut self.pending_allocations, + pending.order_buffer_id, + pending.order_buffer_generation, + ) { + allocation.state + } else { + batch + .order_buffer + .take() + .filter(|buffer| { + buffer.id == pending.order_buffer_id + && buffer.generation == pending.order_buffer_generation + }) + .ok_or(StablePlanError::InvalidIdentity)? + }; + apply_writes(&mut order_buffer, &self.patches, &self.payload)?; + batch.order_buffer = Some(order_buffer); + Ok(()) + } + + fn prepare_identity_set(&mut self, count: usize) -> Result<(), StablePlanError> { + let required = count + .checked_mul(2) + .and_then(usize::checked_next_power_of_two) + .unwrap_or(usize::MAX) + .max(8); + if required == usize::MAX { + return Err(StablePlanError::ArithmeticOverflow); + } + if self.identity_keys.len() < required { + let additional_keys = required - self.identity_keys.len(); + let additional_epochs = required - self.identity_epochs.len(); + reserve(&mut self.identity_keys, additional_keys)?; + reserve(&mut self.identity_epochs, additional_epochs)?; + self.identity_keys.resize(required, 0); + self.identity_epochs.resize(required, 0); + } + self.identity_epoch = match self.identity_epoch.checked_add(1) { + Some(epoch) => epoch, + None => { + self.identity_epochs.fill(0); + 1 + } + }; + Ok(()) + } + + fn insert_identity(&mut self, identity: u32) -> bool { + let mask = self.identity_keys.len() - 1; + let mut slot = (identity.wrapping_mul(0x9e37_79b1) as usize) & mask; + loop { + if self.identity_epochs[slot] != self.identity_epoch { + self.identity_epochs[slot] = self.identity_epoch; + self.identity_keys[slot] = identity; + return true; + } + if self.identity_keys[slot] == identity { + return false; + } + slot = (slot + 1) & mask; + } + } +} + +fn order_schema() -> BufferSchema { + BufferSchema::packed( + BufferId(POLICY_BUFFER_ORDER), + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + ) +} + +fn range(start: u32, count: u32) -> Result, StablePlanError> { + let end = start + .checked_add(count) + .ok_or(StablePlanError::ArithmeticOverflow)?; + Ok(start as usize..end as usize) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), StablePlanError> { + values + .try_reserve(additional) + .map_err(|_| StablePlanError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::policy::{ + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, CAP_STABLE_INDIRECT, + CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, + ProgramId, + }; + use crate::engine::render_plan_wire::plan_layout; + use alloc::vec; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + const TECHNIQUE: TechniqueId = TechniqueId(1); + + #[test] + fn insertion_writes_one_new_physical_record_and_one_order_chunk() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0], + true, + 1, + 0, + ); + let first = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(first.buffers.len(), 2); + assert_eq!(first.patches.len(), 4); + assert_eq!(first.primitives.len(), 1); + assert_eq!(first.primitives[0].record_count, 3); + assert_eq!( + first.primitives[0].buffer_id, + first.draws[0].indirect_buffer_id + ); + assert_eq!(first.draws[0].buffer_count, 2); + assert!(plan_layout(first).is_ok(), "{:?}", plan_layout(first)); + compiler.commit().unwrap(); + + let inserted = [glyph(1, 1), glyph(4, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &inserted, + &[1.0, 4.0, 2.0, 3.0], + false, + 2, + 0, + ); + let delta = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(delta.patches.len(), 2); + assert_eq!(delta.patches[0].destination_offset, 12); + assert_eq!(delta.patches[0].byte_length, 4); + assert_eq!(delta.patches[1].destination_offset, 0); + assert_eq!(delta.patches[1].byte_length, 16); + assert_eq!(delta.payload.len(), 20); + assert_eq!(compiler.input_slots, [0, 3, 1, 2]); + compiler.commit().unwrap(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 12), 4.0); + assert_eq!(read_u32(compiler.buffer_bytes(2).unwrap(), 4), 3); + } + + #[test] + fn reorder_changes_only_the_indirection_buffer() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + let reordered = [glyph(3, 1), glyph(1, 1), glyph(2, 1)]; + prepare( + &mut compiler, + &policy, + &reordered, + &[3.0, 1.0, 2.0], + false, + 2, + 0, + ); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches.len(), 1); + assert_eq!(plan.patches[0].buffer_id, 2); + assert_eq!(plan.payload.len(), 12); + } + + #[test] + fn no_op_publishes_nothing_and_abort_preserves_physical_bytes() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0], true, 1, 0); + compiler.commit().unwrap(); + prepare(&mut compiler, &policy, &initial, &[99.0, 99.0], false, 2, 0); + let no_op = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(no_op.resources.is_empty()); + assert!(no_op.buffers.is_empty()); + assert!(no_op.patches.is_empty()); + assert!(no_op.primitives.is_empty()); + assert!(no_op.draws.is_empty()); + compiler.commit().unwrap(); + + let changed = [glyph(1, 2), glyph(2, 1)]; + prepare(&mut compiler, &policy, &changed, &[10.0, 2.0], false, 3, 0); + compiler.abort(); + assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 0), 1.0); + } + + #[test] + fn deleted_slots_wait_for_an_explicit_renderer_fence() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + prepare( + &mut compiler, + &policy, + &initial[..2], + &[1.0, 2.0], + false, + 2, + 0, + ); + assert!( + compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .retirements + .iter() + .any(|retirement| retirement.kind == RETIRE_SLOT_RANGE) + ); + compiler.commit().unwrap(); + + let added = [glyph(1, 1), glyph(2, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &added, + &[1.0, 2.0, 4.0], + false, + 3, + 0, + ); + assert_eq!(compiler.input_slots[2], 3); + compiler.abort(); + prepare( + &mut compiler, + &policy, + &added, + &[1.0, 2.0, 4.0], + false, + 3, + 2, + ); + assert_eq!(compiler.input_slots[2], 2); + } + + #[test] + fn interleaved_resources_keep_ordered_draws_over_separate_stable_pools() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 2.0, 3.0, 4.0], + true, + 1, + 0, + ); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.resources.len(), 2); + assert_eq!(plan.buffers.len(), 4); + assert_eq!(plan.primitives.len(), 3); + assert_eq!(plan.draws.len(), 3); + assert_eq!(plan.primitives[0].record_count, 2); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert_eq!(plan.draws[2].order_token, 3); + assert!(plan_layout(plan).is_ok(), "{:?}", plan_layout(plan)); + } + + #[test] + fn material_splits_draws_without_splitting_stable_storage() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.draws[0].material_id, 1); + assert_eq!(plan.draws[1].material_id, 2); + assert_eq!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + } + + #[test] + fn policy_can_partition_stable_storage_by_material() { + let policy = policy(true); + let mut compiler = StablePlanCompiler::default(); + let first = glyph(1, 1); + let mut second = glyph(2, 1); + second.material_id = 2; + let glyphs = [first, second]; + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.buffers.len(), 4); + assert_eq!(plan.draws.len(), 2); + assert_ne!(plan.draws[0].buffer_start, plan.draws[1].buffer_start); + } + + #[test] + fn repeated_warm_updates_keep_all_glyph_scaled_scratch_capacity() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &initial, + &[1.0, 2.0, 3.0, 4.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + let changed = [glyph(1, 1), glyph(2, 2), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed, + &[1.0, 20.0, 3.0, 4.0], + false, + 2, + 0, + ); + compiler.commit().unwrap(); + let settled = capacities(&compiler); + let changed_again = [glyph(1, 1), glyph(2, 3), glyph(3, 1), glyph(4, 1)]; + prepare( + &mut compiler, + &policy, + &changed_again, + &[1.0, 21.0, 3.0, 4.0], + false, + 3, + 0, + ); + compiler.commit().unwrap(); + assert_eq!(capacities(&compiler), settled); + } + + #[test] + fn fragmentation_budget_rebases_only_the_order_buffer() { + let policy = policy_with_budget(false, 1); + let mut compiler = StablePlanCompiler::default(); + let seeded: Vec<_> = (1..=129).map(|id| glyph(id, 1)).collect(); + let seeded_x: Vec<_> = seeded.iter().map(|glyph| glyph.stable_id as f32).collect(); + prepare(&mut compiler, &policy, &seeded, &seeded_x, true, 1, 0); + compiler.commit().unwrap(); + let initial = &seeded[..127]; + let initial_x = &seeded_x[..127]; + prepare(&mut compiler, &policy, initial, initial_x, false, 2, 0); + compiler.commit().unwrap(); + + let mut inserted = initial.to_vec(); + inserted.insert(64, glyph(200, 1)); + let inserted_x: Vec<_> = inserted + .iter() + .map(|glyph| glyph.stable_id as f32) + .collect(); + prepare(&mut compiler, &policy, &inserted, &inserted_x, false, 3, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.primitives.len(), 1); + assert_eq!(plan.primitives[0].record_count, 128); + assert_eq!(plan.buffers[0].generation, 1); + assert_eq!(plan.buffers[1].generation, 2); + assert!(plan.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_BUFFER + && retirement.id == plan.buffers[1].id + && retirement.generation == 1 + })); + assert!(!plan.patches.iter().any(|patch| { + patch.opcode == PATCH_ALLOCATE_OR_RESIZE && patch.buffer_id == plan.buffers[0].id + })); + } + + #[allow(clippy::too_many_arguments)] + fn prepare( + compiler: &mut StablePlanCompiler, + policy: &ValidatedPolicy, + glyphs: &[StableGlyph], + x: &[f32], + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) { + compiler + .prepare( + policy, + CAPABILITY, + StablePlanInput { + glyphs, + f32_fields: &[x], + u32_fields: &[], + }, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ) + .unwrap(); + } + + fn glyph(stable_id: u32, content_revision: u32) -> StableGlyph { + StableGlyph { + stable_id, + content_revision, + technique: TECHNIQUE, + program_variant: 0, + resource_id: 11, + resource_generation: 1, + resource_kind: 1, + resource_reference: 99, + semantic_id: 1, + material_id: 1, + clip_id: 0, + depth_key: 0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 1.0, + block_extent: 1.0, + } + } + + fn policy(partition_materials: bool) -> ValidatedPolicy { + policy_with_budget(partition_materials, 8) + } + + fn policy_with_budget(partition_materials: bool, fragmentation_budget: u16) -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![CapabilitySet { + id: CAPABILITY, + flags: CAP_STABLE_INDIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 2, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget, + whole_buffer_threshold_basis_points: 10_000, + }], + programs: vec![ProgramDescriptor { + technique: TECHNIQUE, + variant: 0, + id: ProgramId(5), + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | if partition_materials { + BATCH_MATERIAL + } else { + 0 + }, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_MATERIAL + | BATCH_ORDER, + allocation_strategy: ALLOCATION_STABLE_INDIRECT, + f32_input_count: 1, + u32_input_count: 0, + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } + + fn read_f32(bytes: &[u8], offset: usize) -> f32 { + f32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn read_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn capacities(compiler: &StablePlanCompiler) -> Vec { + let mut values = vec![ + compiler.batches.capacity(), + compiler.pending_batches.capacity(), + compiler.batch_pending_indices.capacity(), + compiler.input_batches.capacity(), + compiler.input_slots.capacity(), + compiler.input_order_records.capacity(), + compiler.batch_input_indices.capacity(), + compiler.batch_identities.capacity(), + compiler.order_entries.capacity(), + compiler.order_chunk_scratch.capacity(), + compiler.slot_writes.capacity(), + compiler.changed_ranges.capacity(), + compiler.identity_keys.capacity(), + compiler.identity_epochs.capacity(), + compiler.pending_allocations.capacity(), + compiler.resources.capacity(), + compiler.plan_buffers.capacity(), + compiler.primitives.capacity(), + compiler.draws.capacity(), + compiler.live_primitives.capacity(), + compiler.live_draws.capacity(), + compiler.patches.capacity(), + compiler.retirements.capacity(), + compiler.payload.capacity(), + ]; + for batch in &compiler.batches { + values.extend_from_slice(&batch.slots.scratch_capacities()); + values.extend_from_slice(&batch.order.scratch_capacities()); + values.extend_from_slice(&[ + batch.buffers.capacity(), + batch.spare_buffers.capacity(), + batch.quarantined_chunks.capacity(), + batch.pending_retired_chunks.capacity(), + batch.reclaim_scratch.capacity(), + ]); + } + values + } +} diff --git a/packages/text/rust/shaper/src/engine/stable_pool.rs b/packages/text/rust/shaper/src/engine/stable_pool.rs index cccb2014..bc56b1f2 100644 --- a/packages/text/rust/shaper/src/engine/stable_pool.rs +++ b/packages/text/rust/shaper/src/engine/stable_pool.rs @@ -224,6 +224,22 @@ impl StableSlotPool { self.finish_transaction(); } + #[cfg(test)] + pub fn scratch_capacities(&self) -> [usize; 10] { + [ + self.slots.capacity(), + self.free_slots.capacity(), + self.quarantine.capacity(), + self.assignments.capacity(), + self.retired_slots.capacity(), + self.allocated_free_slots.capacity(), + self.identity_keys.capacity(), + self.identity_slots.capacity(), + self.identity_epochs.capacity(), + self.seen_slots.capacity(), + ] + } + fn finish_transaction(&mut self) { self.assignments.clear(); self.retired_slots.clear(); diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 3fdb69f3..37d24c80 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -38,6 +38,9 @@ export const textShaperAbi = { "orderedDirect": 1, "stableIndirect": 2 }, + "internalBufferBindings": { + "order": 65535 + }, "patchOpcodes": { "allocateOrResize": 1, "copy": 4, From a4840d321d1ec05338bab070aabc38890a31c00f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 09:54:57 -0400 Subject: [PATCH 014/128] feat(text): compose mixed render plans --- docs/log.md | 10 + docs/packages/text.md | 14 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 13 +- packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/ordered_plan.rs | 108 ++- .../shaper/src/engine/render_plan_compiler.rs | 886 ++++++++++++++++++ .../rust/shaper/src/engine/stable_plan.rs | 122 ++- 8 files changed, 1133 insertions(+), 22 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/render_plan_compiler.rs diff --git a/docs/log.md b/docs/log.md index 687ee169..dbcd9f67 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-08 +- **Compiled mixed allocation strategies without semantic partitions** — Added a retained dispatcher that keeps the + homogeneous ordered-direct or stable-indirect path as one compiler and one direct plan view. A heterogeneous frame + lets both compilers filter the same borrowed glyph/field slices, then merges only resource, buffer, patch, primitive, + draw, retirement, and payload records. Disjoint low/high buffer-ID namespaces make the merged bindings unambiguous; + shared resources are validated and retained across an allocation-strategy transition; draws recover original global + order. Alternating-strategy, transition, mixed no-op, and settled-capacity tests pass. The dispatcher remains + unreachable from `text_update`; optimized Wasm stays 739,909 raw / 214,395 Brotli bytes. The unchanged shipping path's + 25,515-glyph cold/font-size/width/text medians are 57.18/12.44/8.58/39.28 ms, so session integration and target timing + remain open rather than inferred. + - **Completed ordered-direct display-list compilation** — Dirty retained updates now publish complete compact binding and command tables while keeping physical payloads revision-directed. Consecutive compatible glyphs compile into one primitive span and draw packet; interleaved `A, A, B, A` resources preserve three ordered spans over two deduplicated diff --git a/docs/packages/text.md b/docs/packages/text.md index ec0e72a2..7c2ff59c 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:afa202c4e5724631c1e78a72458aed0c6300b0c4bf7aa455fbcbbfac3cb0af60' +source_digest: 'sha256:83743db2511596234438802099b7746463b60b41d7c1515c235f833a3f911a61' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -458,6 +458,18 @@ and the ABI acknowledgment field remain open. The planner remains LTO-stripped: reachable binding identity shifts gzip 272,607→272,624 and Brotli 214,288→214,395 bytes. No planner-latency or end-to-end claim is attached yet. +The allocation-strategy dispatcher now compiles one frame containing both ordered-direct and stable-indirect policy +programs without first copying glyphs or semantic fields into strategy-specific arrays. Homogeneous frames delegate +directly to one compiler; only mixed frames merge renderer-facing tables. Ordered buffers occupy the low `u32` ID half +and stable physical/order buffers the high half. The merge rebases patch payload offsets, validates/deduplicates shared +resources, removes resource retirements when another strategy keeps the same generation live, and restores the original +global draw order. Focused tests cover alternating strategies, allocation-strategy transitions, zero-output mixed +no-ops, and settled merge capacities. This dispatcher remains unreachable from `text_update`; it adds no end-to-end +timing claim before session integration. The required unchanged-path 25,515-glyph benchmark reports +57.18/12.44/8.58/39.28 ms median and 72.41/15.53/11.32/41.81 ms p95 for cold/font-size/width/text versus the prior +recorded 54.42/12.15/8.31/38.98 ms medians. The optimized Wasm remains 739,909 raw and 214,395 Brotli bytes, so the +dispatcher is still LTO-stripped and the table is baseline run variance rather than a planner performance result. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 3e8aa3e9..415d1b86 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -234,6 +234,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | | D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | | D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | +| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 0222d3b3..2ac7740f 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -733,9 +733,16 @@ fragmentation budget bounds accumulated draw spans: when an edit would exceed it dense chunks, increments and retires that buffer generation, and preserves the physical glyph-buffer generation. Order-buffer growth likewise republishes every live chunk because a replacement allocation cannot assume prior bytes. No-op, abort, mixed-resource order, shared/partitioned material storage, fence-gated reuse, fragmentation rebasing, and -settled nested scratch capacities have focused tests. Session integration, a dedicated renderer-fence acknowledgment in -the request, and target-hardware timing remain open in Stage 2; `consumed_plan_revision` cannot substitute because host -application does not prove GPU completion. The planners are still unreachable from `text_update` and removed by LTO. +settled nested scratch capacities have focused tests. One frame may contain programs using both allocation strategies: +the dispatcher filters the same borrowed glyph and semantic-field slices in each compiler instead of allocating copied +partitions, assigns ordered-direct buffers to the low `u32` ID half and stable-indirect buffers to the high half, rebases +patch payload spans, deduplicates shared resources, and merges draws by their original global order token. A program may +therefore change allocation strategy without retiring a resource that remains live through the other compiler. A +homogeneous frame delegates its plan view directly to one compiler and does not populate merge scratch; a mixed no-op +publishes nothing, and repeated same-shape mixed edits retain settled vector capacities. Session integration, a +dedicated renderer-fence acknowledgment in the request, and target-hardware timing remain open in Stage 2; +`consumed_plan_revision` cannot substitute because host application does not prove GPU completion. The planners are +still unreachable from `text_update` and removed by LTO. Adding the reachable reserved-binding ABI identity leaves the optimized artifact at 739,909 raw bytes and changes only compression from 272,607 to 272,624 gzip bytes and 214,288 to 214,395 Brotli bytes. This is not end-to-end latency evidence. diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index c5313f68..34cb170a 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -20,6 +20,7 @@ pub mod plan_input; mod plan_packing; pub mod policy; pub mod render_plan; +pub mod render_plan_compiler; pub(crate) mod render_plan_wire; #[cfg_attr(not(test), allow(dead_code))] mod stable_order; diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index c608d960..000a539d 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -28,6 +28,8 @@ use super::{ pub use super::plan_input::{PlanGlyph as OrderedGlyph, PlanInput as OrderedPlanInput}; +const NONE: u32 = u32::MAX; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum OrderedPlanError { AllocationFailed, @@ -145,11 +147,19 @@ pub struct OrderedPlanCompiler { payload: Vec, next_buffer_id: u32, pending_next_buffer_id: u32, + buffer_id_limit: u32, publish_bindings: bool, prepared: bool, } impl OrderedPlanCompiler { + pub(crate) fn with_buffer_id_limit(buffer_id_limit: u32) -> Self { + Self { + buffer_id_limit, + ..Self::default() + } + } + pub fn prepare( &mut self, policy: &ValidatedPolicy, @@ -157,6 +167,43 @@ impl OrderedPlanCompiler { input: OrderedPlanInput<'_>, checkpoint: bool, publication_generation: u32, + ) -> Result<(), OrderedPlanError> { + self.prepare_internal( + policy, + capability_set, + input, + checkpoint, + publication_generation, + true, + ) + } + + pub(crate) fn prepare_filtered( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + ) -> Result<(), OrderedPlanError> { + self.prepare_internal( + policy, + capability_set, + input, + checkpoint, + publication_generation, + false, + ) + } + + fn prepare_internal( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + strict_strategy: bool, ) -> Result<(), OrderedPlanError> { if self.prepared { return Err(OrderedPlanError::AlreadyPrepared); @@ -172,7 +219,8 @@ impl OrderedPlanCompiler { reserve(&mut self.input_batches, input.glyphs.len())?; reserve(&mut self.input_slots, input.glyphs.len())?; reserve(&mut self.pending_instances, input.glyphs.len())?; - self.input_batches.resize(input.glyphs.len(), 0); + self.input_batches.resize(input.glyphs.len(), NONE); + self.input_batches.fill(NONE); self.input_slots.resize(input.glyphs.len(), 0); self.prepare_identity_set(input.glyphs.len())?; @@ -185,7 +233,10 @@ impl OrderedPlanCompiler { .program(capability_set, glyph.technique, glyph.program_variant) .ok_or(OrderedPlanError::ProgramMissing)?; if program.allocation_strategy != ALLOCATION_ORDERED_DIRECT { - return Err(OrderedPlanError::UnsupportedStrategy); + if strict_strategy { + return Err(OrderedPlanError::UnsupportedStrategy); + } + continue; } let resource_bit = 1_u32 .checked_shl(u32::from(glyph.resource_kind - 1)) @@ -275,31 +326,58 @@ impl OrderedPlanCompiler { Ok(()) } + pub(crate) fn has_state(&self) -> bool { + !self.batches.is_empty() + } + + pub(crate) fn publishes_bindings(&self) -> bool { + self.publish_bindings + } + pub fn plan_view( &self, policy_handle: u32, capability_set: CapabilitySetId, policy_fingerprint: u64, + ) -> Result, OrderedPlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, false) + } + + pub(crate) fn plan_view_forced( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, OrderedPlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, true) + } + + fn plan_view_internal( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + force_bindings: bool, ) -> Result, OrderedPlanError> { if !self.prepared { return Err(OrderedPlanError::NotPrepared); } - let resources = if self.publish_bindings { + let resources = if self.publish_bindings || force_bindings { self.resources.as_slice() } else { &[] }; - let buffers = if self.publish_bindings { + let buffers = if self.publish_bindings || force_bindings { self.plan_buffers.as_slice() } else { &[] }; - let primitives = if self.publish_bindings { + let primitives = if self.publish_bindings || force_bindings { self.primitives.as_slice() } else { &[] }; - let draws = if self.publish_bindings { + let draws = if self.publish_bindings || force_bindings { self.draws.as_slice() } else { &[] @@ -460,8 +538,11 @@ impl OrderedPlanCompiler { self.batch_cursors.push(batch.state.instance_start); } self.pending_instances - .resize(input.glyphs.len(), InstanceState::default()); + .resize(cursor as usize, InstanceState::default()); for (input_index, glyph) in input.glyphs.iter().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } let batch = self.input_batches[input_index] as usize; let destination = self.batch_cursors[batch] as usize; self.pending_instances[destination] = InstanceState { @@ -556,6 +637,10 @@ impl OrderedPlanCompiler { }, ) } else { + if self.buffer_id_limit != 0 && self.pending_next_buffer_id >= self.buffer_id_limit + { + return Err(OrderedPlanError::IdentifierExhausted); + } self.pending_next_buffer_id = self .pending_next_buffer_id .checked_add(1) @@ -805,7 +890,10 @@ impl OrderedPlanCompiler { u32::try_from(buffer_start).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; } - for glyph in context.input.glyphs.iter().copied() { + for (input_index, glyph) in context.input.glyphs.iter().copied().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } if let Some(resource) = self.resources.iter().find(|resource| { resource.id == glyph.resource_id && resource.generation == glyph.resource_generation }) { @@ -842,6 +930,10 @@ impl OrderedPlanCompiler { } let mut input_index = 0_usize; while input_index < context.input.glyphs.len() { + if self.input_batches[input_index] == NONE { + input_index += 1; + continue; + } let first = context.input.glyphs[input_index]; let batch_index = self.input_batches[input_index] as usize; let first_slot = self.input_slots[input_index]; diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs new file mode 100644 index 00000000..85d08a07 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -0,0 +1,886 @@ +//! Allocation-strategy dispatcher for retained render-plan compilation. +//! +//! Homogeneous frames delegate directly to one compiler. Mixed frames let both compilers filter +//! the same borrowed semantic inputs, then merge only renderer-facing plan records. No glyph or +//! policy input is copied into a strategy-specific partition. + +use alloc::vec::Vec; + +use super::{ + ordered_plan::{OrderedPlanCompiler, OrderedPlanError}, + plan_input::{PlanInput, PlanInputError, validate_input}, + policy::{ + ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, CapabilitySetId, ValidatedPolicy, + }, + render_plan::{ + BufferRecord, DrawRecord, PATCH_WRITE, PatchRecord, PrimitiveRecord, + RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_RESOURCE, RenderPlanView, + ResourceRecord, RetirementRecord, + }, + stable_plan::{StablePlanCompiler, StablePlanError}, +}; + +const ORDERED_BUFFER_ID_LIMIT: u32 = 0x7fff_ffff; +const STABLE_BUFFER_ID_FLOOR: u32 = ORDERED_BUFFER_ID_LIMIT; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RenderPlanCompilerError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + CapabilitySetMissing, + ProgramMissing, + UnsupportedStrategy, + InvalidInputShape, + InvalidIdentity, + InvalidResource, + InvalidPlan, + ArithmeticOverflow, + Ordered(OrderedPlanError), + Stable(StablePlanError), +} + +impl From for RenderPlanCompilerError { + fn from(error: PlanInputError) -> Self { + match error { + PlanInputError::InvalidShape => Self::InvalidInputShape, + PlanInputError::InvalidIdentity => Self::InvalidIdentity, + PlanInputError::InvalidResource => Self::InvalidResource, + } + } +} + +impl From for RenderPlanCompilerError { + fn from(error: OrderedPlanError) -> Self { + Self::Ordered(error) + } +} + +impl From for RenderPlanCompilerError { + fn from(error: StablePlanError) -> Self { + Self::Stable(error) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +enum PreparedStrategy { + #[default] + None, + Empty, + Ordered, + Stable, + Mixed, +} + +pub struct RenderPlanCompiler { + ordered: OrderedPlanCompiler, + stable: StablePlanCompiler, + resources: Vec, + buffers: Vec, + patches: Vec, + primitives: Vec, + draws: Vec, + retirements: Vec, + payload: Vec, + prepared_strategy: PreparedStrategy, +} + +impl Default for RenderPlanCompiler { + fn default() -> Self { + Self { + ordered: OrderedPlanCompiler::with_buffer_id_limit(ORDERED_BUFFER_ID_LIMIT), + stable: StablePlanCompiler::with_buffer_id_floor(STABLE_BUFFER_ID_FLOOR), + resources: Vec::new(), + buffers: Vec::new(), + patches: Vec::new(), + primitives: Vec::new(), + draws: Vec::new(), + retirements: Vec::new(), + payload: Vec::new(), + prepared_strategy: PreparedStrategy::None, + } + } +} + +impl RenderPlanCompiler { + #[allow(clippy::too_many_arguments)] + pub fn prepare( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: PlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), RenderPlanCompilerError> { + if self.prepared_strategy != PreparedStrategy::None { + return Err(RenderPlanCompilerError::AlreadyPrepared); + } + if publication_generation == 0 + || acknowledged_publication_generation >= publication_generation + { + return Err(RenderPlanCompilerError::InvalidIdentity); + } + if policy.capability_set(capability_set).is_none() { + return Err(RenderPlanCompilerError::CapabilitySetMissing); + } + validate_input(input)?; + self.clear_merged_plan(); + + let mut ordered_input = false; + let mut stable_input = false; + for glyph in input.glyphs { + let program = policy + .program(capability_set, glyph.technique, glyph.program_variant) + .ok_or(RenderPlanCompilerError::ProgramMissing)?; + match program.allocation_strategy { + ALLOCATION_ORDERED_DIRECT => ordered_input = true, + ALLOCATION_STABLE_INDIRECT => stable_input = true, + _ => return Err(RenderPlanCompilerError::UnsupportedStrategy), + } + } + + let prepare_ordered = ordered_input || self.ordered.has_state(); + let prepare_stable = stable_input || self.stable.has_state(); + match (prepare_ordered, prepare_stable) { + (false, false) => { + self.prepared_strategy = PreparedStrategy::Empty; + Ok(()) + } + (true, false) => { + self.ordered.prepare( + policy, + capability_set, + input, + checkpoint, + publication_generation, + )?; + self.prepared_strategy = PreparedStrategy::Ordered; + Ok(()) + } + (false, true) => { + self.stable.prepare( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + )?; + self.prepared_strategy = PreparedStrategy::Stable; + Ok(()) + } + (true, true) => self.prepare_mixed( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ), + } + } + + pub fn plan_view( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, RenderPlanCompilerError> { + match self.prepared_strategy { + PreparedStrategy::None => Err(RenderPlanCompilerError::NotPrepared), + PreparedStrategy::Empty => Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + ..RenderPlanView::default() + }), + PreparedStrategy::Ordered => self + .ordered + .plan_view(policy_handle, capability_set, policy_fingerprint) + .map_err(Into::into), + PreparedStrategy::Stable => self + .stable + .plan_view(policy_handle, capability_set, policy_fingerprint) + .map_err(Into::into), + PreparedStrategy::Mixed => Ok(RenderPlanView { + policy_handle, + capability_set: capability_set.0, + policy_fingerprint, + resources: &self.resources, + buffers: &self.buffers, + patches: &self.patches, + primitives: &self.primitives, + draws: &self.draws, + retirements: &self.retirements, + payload: &self.payload, + ..RenderPlanView::default() + }), + } + } + + pub fn commit(&mut self) -> Result<(), RenderPlanCompilerError> { + match self.prepared_strategy { + PreparedStrategy::None => return Err(RenderPlanCompilerError::NotPrepared), + PreparedStrategy::Empty => {} + PreparedStrategy::Ordered => self.ordered.commit()?, + PreparedStrategy::Stable => self.stable.commit()?, + PreparedStrategy::Mixed => { + self.ordered.commit()?; + self.stable.commit()?; + } + } + self.prepared_strategy = PreparedStrategy::None; + Ok(()) + } + + pub fn abort(&mut self) { + match self.prepared_strategy { + PreparedStrategy::Ordered => self.ordered.abort(), + PreparedStrategy::Stable => self.stable.abort(), + PreparedStrategy::Mixed => { + self.ordered.abort(); + self.stable.abort(); + } + PreparedStrategy::None | PreparedStrategy::Empty => {} + } + self.prepared_strategy = PreparedStrategy::None; + } + + pub fn buffer_bytes(&self, id: u32) -> Option<&[u8]> { + if id <= ORDERED_BUFFER_ID_LIMIT { + self.ordered.buffer_bytes(id) + } else { + self.stable.buffer_bytes(id) + } + } + + #[allow(clippy::too_many_arguments)] + fn prepare_mixed( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: PlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), RenderPlanCompilerError> { + if let Err(error) = self.ordered.prepare_filtered( + policy, + capability_set, + input, + checkpoint, + publication_generation, + ) { + self.ordered.abort(); + return Err(error.into()); + } + if let Err(error) = self.stable.prepare_filtered( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ) { + self.ordered.abort(); + self.stable.abort(); + return Err(error.into()); + } + if let Err(error) = self.merge_prepared(capability_set, policy.fingerprint()) { + self.ordered.abort(); + self.stable.abort(); + return Err(error); + } + self.prepared_strategy = PreparedStrategy::Mixed; + Ok(()) + } + + fn merge_prepared( + &mut self, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result<(), RenderPlanCompilerError> { + let publish_bindings = + self.ordered.publishes_bindings() || self.stable.publishes_bindings(); + if !publish_bindings { + return Ok(()); + } + let ordered = self + .ordered + .plan_view_forced(0, capability_set, policy_fingerprint)?; + let stable = self + .stable + .plan_view_forced(0, capability_set, policy_fingerprint)?; + + append_resources(&mut self.resources, ordered.resources)?; + append_resources(&mut self.resources, stable.resources)?; + append_buffers(&mut self.buffers, ordered.buffers)?; + let stable_buffer_base = self.buffers.len(); + append_buffers(&mut self.buffers, stable.buffers)?; + append_patches( + &mut self.patches, + &mut self.payload, + ordered.patches, + ordered.payload, + )?; + append_patches( + &mut self.patches, + &mut self.payload, + stable.patches, + stable.payload, + )?; + append_retirements(&mut self.retirements, ordered.retirements)?; + append_retirements(&mut self.retirements, stable.retirements)?; + reconcile_live_resource_retirements(&mut self.resources, &mut self.retirements); + merge_draws( + &mut self.primitives, + &mut self.draws, + &self.resources, + ordered, + stable, + stable_buffer_base, + )?; + Ok(()) + } + + fn clear_merged_plan(&mut self) { + self.resources.clear(); + self.buffers.clear(); + self.patches.clear(); + self.primitives.clear(); + self.draws.clear(); + self.retirements.clear(); + self.payload.clear(); + } +} + +fn append_resources( + destination: &mut Vec, + source: &[ResourceRecord], +) -> Result<(), RenderPlanCompilerError> { + reserve(destination, source.len())?; + for &resource in source { + if let Some(existing) = destination.iter_mut().find(|existing| { + existing.id == resource.id && existing.generation == resource.generation + }) { + if !same_resource(*existing, resource) { + return Err(RenderPlanCompilerError::InvalidResource); + } + existing.action = merge_resource_action(existing.action, resource.action); + } else { + destination.push(resource); + } + } + Ok(()) +} + +fn same_resource(left: ResourceRecord, right: ResourceRecord) -> bool { + ResourceRecord { action: 0, ..left } == ResourceRecord { action: 0, ..right } +} + +fn merge_resource_action(left: u16, right: u16) -> u16 { + if left == RESOURCE_ACTION_UPDATE || right == RESOURCE_ACTION_UPDATE { + RESOURCE_ACTION_UPDATE + } else if left == RESOURCE_ACTION_RETAIN || right == RESOURCE_ACTION_RETAIN { + RESOURCE_ACTION_RETAIN + } else { + left + } +} + +fn append_buffers( + destination: &mut Vec, + source: &[BufferRecord], +) -> Result<(), RenderPlanCompilerError> { + reserve(destination, source.len())?; + for &buffer in source { + if destination.iter().any(|existing| existing.id == buffer.id) { + return Err(RenderPlanCompilerError::InvalidPlan); + } + destination.push(buffer); + } + Ok(()) +} + +fn append_patches( + destination: &mut Vec, + payload: &mut Vec, + source: &[PatchRecord], + source_payload: &[u8], +) -> Result<(), RenderPlanCompilerError> { + let payload_base = + u32::try_from(payload.len()).map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?; + reserve(payload, source_payload.len())?; + payload.extend_from_slice(source_payload); + reserve(destination, source.len())?; + for &patch in source { + let mut rebased = patch; + if patch.opcode == PATCH_WRITE { + rebased.payload_start = patch + .payload_start + .checked_add(payload_base) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + } + destination.push(rebased); + } + Ok(()) +} + +fn append_retirements( + destination: &mut Vec, + source: &[RetirementRecord], +) -> Result<(), RenderPlanCompilerError> { + reserve(destination, source.len())?; + for &retirement in source { + if !destination.contains(&retirement) { + destination.push(retirement); + } + } + Ok(()) +} + +fn reconcile_live_resource_retirements( + resources: &mut [ResourceRecord], + retirements: &mut Vec, +) { + for resource in &mut *resources { + let remained_live = retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == resource.id + && retirement.generation == resource.generation + }); + if remained_live && resource.action != RESOURCE_ACTION_UPDATE { + resource.action = RESOURCE_ACTION_RETAIN; + } + } + retirements.retain(|retirement| { + retirement.kind != RETIRE_RESOURCE + || !resources.iter().any(|resource| { + resource.id == retirement.id && resource.generation == retirement.generation + }) + }); +} + +fn merge_draws( + primitives: &mut Vec, + draws: &mut Vec, + resources: &[ResourceRecord], + ordered: RenderPlanView<'_>, + stable: RenderPlanView<'_>, + stable_buffer_base: usize, +) -> Result<(), RenderPlanCompilerError> { + reserve( + primitives, + ordered.primitives.len() + stable.primitives.len(), + )?; + reserve(draws, ordered.draws.len() + stable.draws.len())?; + let mut ordered_index = 0_usize; + let mut stable_index = 0_usize; + while ordered_index < ordered.draws.len() || stable_index < stable.draws.len() { + let take_ordered = match ( + ordered.draws.get(ordered_index), + stable.draws.get(stable_index), + ) { + (Some(left), Some(right)) if left.order_token == right.order_token => { + return Err(RenderPlanCompilerError::InvalidPlan); + } + (Some(left), Some(right)) => left.order_token < right.order_token, + (Some(_), None) => true, + (None, Some(_)) => false, + (None, None) => break, + }; + if take_ordered { + append_draw(primitives, draws, resources, ordered, ordered_index, 0)?; + ordered_index += 1; + } else { + append_draw( + primitives, + draws, + resources, + stable, + stable_index, + stable_buffer_base, + )?; + stable_index += 1; + } + } + Ok(()) +} + +fn append_draw( + primitives: &mut Vec, + draws: &mut Vec, + resources: &[ResourceRecord], + source: RenderPlanView<'_>, + draw_index: usize, + buffer_base: usize, +) -> Result<(), RenderPlanCompilerError> { + let source_draw = *source + .draws + .get(draw_index) + .ok_or(RenderPlanCompilerError::InvalidPlan)?; + if source_draw.resource_count != 1 { + return Err(RenderPlanCompilerError::InvalidPlan); + } + let source_resource = source + .resources + .get(source_draw.resource_start as usize) + .ok_or(RenderPlanCompilerError::InvalidPlan)?; + let resource_start = resources + .iter() + .position(|resource| { + resource.id == source_resource.id && resource.generation == source_resource.generation + }) + .ok_or(RenderPlanCompilerError::InvalidResource)?; + let primitive_start = source_draw.primitive_start as usize; + let primitive_end = primitive_start + .checked_add(source_draw.primitive_count as usize) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + let source_primitives = source + .primitives + .get(primitive_start..primitive_end) + .ok_or(RenderPlanCompilerError::InvalidPlan)?; + let destination_primitive_start = + u32::try_from(primitives.len()).map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?; + primitives.extend_from_slice(source_primitives); + let buffer_start = buffer_base + .checked_add(source_draw.buffer_start as usize) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + let buffer_end = buffer_start + .checked_add(source_draw.buffer_count as usize) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + let source_buffer_end = source_draw + .buffer_start + .checked_add(source_draw.buffer_count) + .ok_or(RenderPlanCompilerError::ArithmeticOverflow)?; + if source_buffer_end as usize > source.buffers.len() + || buffer_end > buffer_base + source.buffers.len() + { + return Err(RenderPlanCompilerError::InvalidPlan); + } + draws.push(DrawRecord { + primitive_start: destination_primitive_start, + buffer_start: u32::try_from(buffer_start) + .map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?, + resource_start: u32::try_from(resource_start) + .map_err(|_| RenderPlanCompilerError::ArithmeticOverflow)?, + ..source_draw + }); + Ok(()) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), RenderPlanCompilerError> { + values + .try_reserve(additional) + .map_err(|_| RenderPlanCompilerError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use alloc::{vec, vec::Vec}; + + use super::*; + use crate::engine::{ + plan_input::PlanGlyph, + policy::{ + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, + BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, + CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, + ProgramId, ScalarType, TechniqueId, + }, + render_plan::{BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT}, + render_plan_wire::plan_layout, + }; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + const ORDERED: TechniqueId = TechniqueId(1); + const STABLE: TechniqueId = TechniqueId(2); + + #[test] + fn homogeneous_frames_delegate_without_allocating_merge_tables() { + let policy = policy(); + let glyphs = [glyph(1, ORDERED, 0)]; + let x = [1.0]; + let mut compiler = RenderPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &x, true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(compiler.prepared_strategy, PreparedStrategy::Ordered); + assert!(compiler.resources.is_empty()); + assert_eq!(plan.buffers[0].strategy, BUFFER_ORDERED_DIRECT); + assert_eq!(plan.draws.len(), 1); + } + + #[test] + fn mixed_frames_preserve_global_draw_order_and_disjoint_buffer_namespaces() { + let policy = policy(); + let glyphs = [ + glyph(1, ORDERED, 0), + glyph(2, STABLE, 0), + glyph(3, ORDERED, 0), + glyph(4, STABLE, 0), + ]; + let x = [1.0, 2.0, 3.0, 4.0]; + let mut compiler = RenderPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &x, true, 1, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(compiler.prepared_strategy, PreparedStrategy::Mixed); + assert_eq!(plan.draws.len(), 4); + assert_eq!( + plan.draws + .iter() + .map(|draw| draw.order_token) + .collect::>(), + vec![0, 1, 2, 3] + ); + assert!( + plan.buffers + .iter() + .any(|buffer| buffer.strategy == BUFFER_ORDERED_DIRECT + && buffer.id <= ORDERED_BUFFER_ID_LIMIT) + ); + assert!( + plan.buffers + .iter() + .filter(|buffer| buffer.strategy == BUFFER_STABLE_INDIRECT) + .all(|buffer| buffer.id > ORDERED_BUFFER_ID_LIMIT) + ); + assert_eq!(plan.resources.len(), 2); + assert!(plan_layout(plan).is_ok()); + } + + #[test] + fn changing_allocation_strategy_keeps_a_shared_resource_live() { + let policy = policy_with_shared_technique_variants(); + let mut compiler = RenderPlanCompiler::default(); + let first = [glyph(1, ORDERED, 1)]; + prepare(&mut compiler, &policy, &first, &[1.0], true, 1, 0); + compiler.commit().unwrap(); + + let second = [glyph(1, ORDERED, 0)]; + prepare(&mut compiler, &policy, &second, &[1.0], false, 2, 0); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(compiler.prepared_strategy, PreparedStrategy::Mixed); + assert_eq!(plan.resources.len(), 1); + assert_eq!(plan.resources[0].action, RESOURCE_ACTION_RETAIN); + assert!(!plan.retirements.iter().any(|retirement| { + retirement.kind == RETIRE_RESOURCE + && retirement.id == plan.resources[0].id + && retirement.generation == plan.resources[0].generation + })); + } + + #[test] + fn repeated_mixed_updates_publish_no_op_frames_and_settle_merge_capacity() { + let policy = policy(); + let mut glyphs = [ + glyph(1, ORDERED, 0), + glyph(2, STABLE, 0), + glyph(3, ORDERED, 0), + glyph(4, STABLE, 0), + ]; + let mut compiler = RenderPlanCompiler::default(); + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 2.0, 3.0, 4.0], + true, + 1, + 0, + ); + compiler.commit().unwrap(); + + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 2.0, 3.0, 4.0], + false, + 2, + 0, + ); + let no_op = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(no_op.resources.is_empty()); + assert!(no_op.buffers.is_empty()); + assert!(no_op.patches.is_empty()); + assert!(no_op.primitives.is_empty()); + assert!(no_op.draws.is_empty()); + assert!(no_op.retirements.is_empty()); + assert!(no_op.payload.is_empty()); + compiler.commit().unwrap(); + + glyphs[1].content_revision = 2; + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 20.0, 3.0, 4.0], + false, + 3, + 0, + ); + let settled = merged_capacities(&compiler); + compiler.commit().unwrap(); + + glyphs[1].content_revision = 3; + prepare( + &mut compiler, + &policy, + &glyphs, + &[1.0, 21.0, 3.0, 4.0], + false, + 4, + 0, + ); + assert_eq!(merged_capacities(&compiler), settled); + } + + #[allow(clippy::too_many_arguments)] + fn prepare( + compiler: &mut RenderPlanCompiler, + policy: &ValidatedPolicy, + glyphs: &[PlanGlyph], + x: &[f32], + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) { + compiler + .prepare( + policy, + CAPABILITY, + PlanInput { + glyphs, + f32_fields: &[x], + u32_fields: &[], + }, + checkpoint, + publication_generation, + acknowledged_publication_generation, + ) + .unwrap(); + } + + fn glyph(stable_id: u32, technique: TechniqueId, variant: u16) -> PlanGlyph { + PlanGlyph { + stable_id, + content_revision: 1, + technique, + program_variant: variant, + resource_id: technique.0 + 10, + resource_generation: 1, + resource_kind: 1, + resource_reference: technique.0 + 90, + semantic_id: 1, + material_id: 1, + clip_id: 0, + depth_key: 0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 1.0, + block_extent: 1.0, + } + } + + fn policy() -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![capability()], + programs: vec![ + program(ORDERED, 0, ProgramId(1), ALLOCATION_ORDERED_DIRECT), + program(STABLE, 0, ProgramId(2), ALLOCATION_STABLE_INDIRECT), + ], + }) + .unwrap() + } + + fn policy_with_shared_technique_variants() -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![capability()], + programs: vec![ + program(ORDERED, 0, ProgramId(1), ALLOCATION_ORDERED_DIRECT), + program(ORDERED, 1, ProgramId(2), ALLOCATION_STABLE_INDIRECT), + ], + }) + .unwrap() + } + + fn capability() -> CapabilitySet { + CapabilitySet { + id: CAPABILITY, + flags: CAP_ORDERED_DIRECT | CAP_STABLE_INDIRECT, + max_buffer_bytes: 4096, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 2, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 10_000, + } + } + + fn program( + technique: TechniqueId, + variant: u16, + id: ProgramId, + allocation_strategy: u16, + ) -> ProgramDescriptor { + ProgramDescriptor { + technique, + variant, + id, + capability_set: CapabilitySetId(0), + resource_kind_mask: 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + allocation_strategy, + f32_input_count: 1, + u32_input_count: 0, + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema::packed( + BufferId(1), + ScalarType::F32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + } + } + + fn merged_capacities(compiler: &RenderPlanCompiler) -> [usize; 7] { + [ + compiler.resources.capacity(), + compiler.buffers.capacity(), + compiler.patches.capacity(), + compiler.primitives.capacity(), + compiler.draws.capacity(), + compiler.retirements.capacity(), + compiler.payload.capacity(), + ] + } +} diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 20d94c56..a05ae1b0 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -261,6 +261,14 @@ pub struct StablePlanCompiler { } impl StablePlanCompiler { + pub(crate) fn with_buffer_id_floor(last_assigned_id: u32) -> Self { + Self { + next_buffer_id: last_assigned_id, + pending_next_buffer_id: last_assigned_id, + ..Self::default() + } + } + #[allow(clippy::too_many_arguments)] pub fn prepare( &mut self, @@ -270,6 +278,49 @@ impl StablePlanCompiler { checkpoint: bool, publication_generation: u32, acknowledged_publication_generation: u32, + ) -> Result<(), StablePlanError> { + self.prepare_with_strategy_filter( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + true, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn prepare_filtered( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + ) -> Result<(), StablePlanError> { + self.prepare_with_strategy_filter( + policy, + capability_set, + input, + checkpoint, + publication_generation, + acknowledged_publication_generation, + false, + ) + } + + #[allow(clippy::too_many_arguments)] + fn prepare_with_strategy_filter( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: StablePlanInput<'_>, + checkpoint: bool, + publication_generation: u32, + acknowledged_publication_generation: u32, + strict_strategy: bool, ) -> Result<(), StablePlanError> { if self.prepared { return Err(StablePlanError::AlreadyPrepared); @@ -279,6 +330,7 @@ impl StablePlanCompiler { { return Err(StablePlanError::InvalidIdentity); } + self.committed_batch_count = self.batches.len(); let result = self.prepare_inner( policy, capability_set, @@ -286,6 +338,7 @@ impl StablePlanCompiler { checkpoint, publication_generation, acknowledged_publication_generation, + strict_strategy, ); if result.is_err() { self.abort(); @@ -293,6 +346,7 @@ impl StablePlanCompiler { result } + #[allow(clippy::too_many_arguments)] fn prepare_inner( &mut self, policy: &ValidatedPolicy, @@ -301,6 +355,7 @@ impl StablePlanCompiler { checkpoint: bool, publication_generation: u32, acknowledged_publication_generation: u32, + strict_strategy: bool, ) -> Result<(), StablePlanError> { let capability = policy .capability_set(capability_set) @@ -310,13 +365,13 @@ impl StablePlanCompiler { batch.acknowledge(acknowledged_publication_generation)?; } self.reset_pending(); - self.committed_batch_count = self.batches.len(); self.pending_publication_generation = publication_generation; self.prepare_identity_set(input.glyphs.len())?; reserve(&mut self.input_batches, input.glyphs.len())?; reserve(&mut self.input_slots, input.glyphs.len())?; reserve(&mut self.input_order_records, input.glyphs.len())?; - self.input_batches.resize(input.glyphs.len(), 0); + self.input_batches.resize(input.glyphs.len(), NONE); + self.input_batches.fill(NONE); self.input_slots.resize(input.glyphs.len(), 0); self.input_order_records.resize(input.glyphs.len(), 0); self.batch_pending_indices.resize(self.batches.len(), NONE); @@ -330,7 +385,10 @@ impl StablePlanCompiler { .program(capability_set, glyph.technique, glyph.program_variant) .ok_or(StablePlanError::ProgramMissing)?; if program.allocation_strategy != ALLOCATION_STABLE_INDIRECT { - return Err(StablePlanError::UnsupportedStrategy); + if strict_strategy { + return Err(StablePlanError::UnsupportedStrategy); + } + continue; } let resource_bit = 1_u32 .checked_shl(u32::from(glyph.resource_kind - 1)) @@ -420,11 +478,38 @@ impl StablePlanCompiler { Ok(()) } + pub(crate) fn has_state(&self) -> bool { + self.batches.iter().any(|batch| batch.active) + } + + pub(crate) fn publishes_bindings(&self) -> bool { + self.publish_bindings + } + pub fn plan_view( &self, policy_handle: u32, capability_set: CapabilitySetId, policy_fingerprint: u64, + ) -> Result, StablePlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, false) + } + + pub(crate) fn plan_view_forced( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + ) -> Result, StablePlanError> { + self.plan_view_internal(policy_handle, capability_set, policy_fingerprint, true) + } + + fn plan_view_internal( + &self, + policy_handle: u32, + capability_set: CapabilitySetId, + policy_fingerprint: u64, + force_bindings: bool, ) -> Result, StablePlanError> { if !self.prepared { return Err(StablePlanError::NotPrepared); @@ -433,24 +518,24 @@ impl StablePlanCompiler { policy_handle, capability_set: capability_set.0, policy_fingerprint, - resources: if self.publish_bindings { + resources: if self.publish_bindings || force_bindings { &self.resources } else { &[] }, - buffers: if self.publish_bindings { + buffers: if self.publish_bindings || force_bindings { &self.plan_buffers } else { &[] }, patches: &self.patches, retirements: &self.retirements, - primitives: if self.publish_bindings { + primitives: if self.publish_bindings || force_bindings { &self.primitives } else { &[] }, - draws: if self.publish_bindings { + draws: if self.publish_bindings || force_bindings { &self.draws } else { &[] @@ -542,9 +627,16 @@ impl StablePlanCompiler { fn layout_batch_inputs(&mut self, input: StablePlanInput<'_>) -> Result<(), StablePlanError> { reserve(&mut self.batch_input_indices, input.glyphs.len())?; reserve(&mut self.batch_identities, input.glyphs.len())?; - self.batch_input_indices.resize(input.glyphs.len(), 0); + let item_count = self + .pending_batches + .iter() + .try_fold(0_usize, |total, pending| { + total.checked_add(pending.item_count as usize) + }) + .ok_or(StablePlanError::ArithmeticOverflow)?; + self.batch_input_indices.resize(item_count, 0); self.batch_identities.resize( - input.glyphs.len(), + item_count, SlotIdentity { stable_id: 0, content_revision: 0, @@ -559,6 +651,9 @@ impl StablePlanCompiler { .ok_or(StablePlanError::ArithmeticOverflow)?; } for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } let pending_index = self.input_batches[input_index] as usize; let destination = self.pending_batches[pending_index].cursor as usize; self.batch_input_indices[destination] = @@ -1117,7 +1212,10 @@ impl StablePlanCompiler { } fn compile_resources(&mut self, context: PrepareContext<'_>) -> Result<(), StablePlanError> { - for glyph in context.input.glyphs.iter().copied() { + for (input_index, glyph) in context.input.glyphs.iter().copied().enumerate() { + if self.input_batches[input_index] == NONE { + continue; + } if let Some(resource) = self.resources.iter().find(|resource| { resource.id == glyph.resource_id && resource.generation == glyph.resource_generation }) { @@ -1158,6 +1256,10 @@ impl StablePlanCompiler { } let mut input_index = 0_usize; while input_index < context.input.glyphs.len() { + if self.input_batches[input_index] == NONE { + input_index += 1; + continue; + } let first = context.input.glyphs[input_index]; let pending_index = self.input_batches[input_index] as usize; let pending = self.pending_batches[pending_index]; From e2a037f83dde614cbabf44acd12415427f14b611 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 10:20:38 -0400 Subject: [PATCH 015/128] feat(text): publish session-owned render plans --- docs/log.md | 9 + docs/packages/text.md | 16 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 24 ++- packages/text/rust/shaper/src/abi_contract.rs | 7 + packages/text/rust/shaper/src/engine/frame.rs | 10 + .../text/rust/shaper/src/engine/frame_wire.rs | 7 +- .../shaper/src/engine/render_plan_compiler.rs | 28 ++- packages/text/rust/shaper/src/engine/state.rs | 183 ++++++++++++++++-- packages/text/rust/shaper/src/wasm.rs | 82 +++++--- .../text/src/generated/text-shaper-abi.ts | 53 ++--- .../render-plan-frame-abi.test.mjs | 32 ++- packages/text/tests/support/engine-abi.d.mts | 1 + packages/text/tests/support/engine-abi.mjs | 6 +- 14 files changed, 382 insertions(+), 77 deletions(-) diff --git a/docs/log.md b/docs/log.md index dbcd9f67..8efcaa6b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Made Rust render-plan state session-owned and fence-safe** — The 124-byte compiler-derived update request now carries + a monotonic renderer-fence acknowledgment distinct from consumed plan revision. Each session owns the Rust mixed-plan + dispatcher and pins its committed policy identity. Wasm prepares, validates, stages, and only then commits planner and + revision state; failure aborts the planner while preserving a valid already-completed fence acknowledgment. Host and + compiled-Wasm tests cover future/stale fences, abort/retry, policy replacement, and A/B preservation. Reachability + raises optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Mutation + sections still reject nonempty semantic input, so this publishes an empty Rust plan and makes no shaping/layout + latency claim; the shared-runtime size increase is now a measured optimization target. + - **Compiled mixed allocation strategies without semantic partitions** — Added a retained dispatcher that keeps the homogeneous ordered-direct or stable-indirect path as one compiler and one direct plan view. A heterogeneous frame lets both compilers filter the same borrowed glyph/field slices, then merges only resource, buffer, patch, primitive, diff --git a/docs/packages/text.md b/docs/packages/text.md index 7c2ff59c..86535083 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:83743db2511596234438802099b7746463b60b41d7c1515c235f833a3f911a61' +source_digest: 'sha256:0a8d6555832935707d801b778ebe21b577bbf355aa39249f8e14148dc5681e93' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -386,7 +386,7 @@ remains within measurement variance: baseline-to-current cold/font-size/layout-w The retained frame shell gives each engine session one 16-byte-aligned request arena and two 16-byte-aligned result arenas. Cold creation and reservation may resize them; a warm update reads the already-pinned request and returns the -selected result pointer in that single call. The compiler-derived request header is 120 bytes. Its section offsets cover +selected result pointer in that single call. The initial Stage 1 compiler-derived request header was 120 bytes. Its section offsets cover text/style mutations, constraints, regions, exclusions, inline objects, and policy parameters; Stage 1 accepts only the canonical empty transaction and rejects a nonempty section until its Rust consumer exists. The 144-byte aligned result header fixes revisions, base requirements, capacity watermarks, output slot and generation, policy handle, capability @@ -470,6 +470,18 @@ timing claim before session integration. The required unchanged-path 25,515-glyp recorded 54.42/12.15/8.31/38.98 ms medians. The optimized Wasm remains 739,909 raw and 214,395 Brotli bytes, so the dispatcher is still LTO-stripped and the table is baseline run variance rather than a planner performance result. +Engine sessions now own the Rust allocation-strategy dispatcher. The compiler-derived request grows from 120 to 124 +bytes for `acknowledgedPublicationGeneration`, a renderer-fence field independent from `consumedPlanRevision`. Rust +requires acknowledgment to be monotonic and no newer than the last successful publication. The update path prepares and +views the session plan, stages it in the inactive result arena, commits planner and revision state only after successful +serialization, and aborts the planner on every failure. A session pins its first committed policy handle/fingerprint; +capability sets may change within that policy, but replacing the policy beneath retained buffers fails before mutation. +Focused host and compiled-Wasm tests cover future/stale fences, abort/retry, unchanged A/B publication, and policy +identity. The now-reachable planners increase the optimized artifact from 739,909 / 272,624 / 214,395 to +822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. This is a measured shared-runtime cost and a pending optimization +target. Nonempty mutation sections remain rejected, so sessions currently publish an empty Rust plan; there is no Rust +shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 415d1b86..3f3f1bc3 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -235,6 +235,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | | D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | | D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | +| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 2ac7740f..a581e080 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -739,13 +739,23 @@ partitions, assigns ordered-direct buffers to the low `u32` ID half and stable-i patch payload spans, deduplicates shared resources, and merges draws by their original global order token. A program may therefore change allocation strategy without retiring a resource that remains live through the other compiler. A homogeneous frame delegates its plan view directly to one compiler and does not populate merge scratch; a mixed no-op -publishes nothing, and repeated same-shape mixed edits retain settled vector capacities. Session integration, a -dedicated renderer-fence acknowledgment in the request, and target-hardware timing remain open in Stage 2; -`consumed_plan_revision` cannot substitute because host application does not prove GPU completion. The planners are -still unreachable from `text_update` and removed by LTO. -Adding the reachable reserved-binding ABI identity leaves the optimized artifact at 739,909 raw bytes and changes only -compression from 272,607 to 272,624 gzip bytes and 214,288 to 214,395 Brotli bytes. This is not end-to-end latency -evidence. +publishes nothing, and repeated same-shape mixed edits retain settled vector capacities. + +Each engine session now owns that dispatcher and pins the first committed policy handle/fingerprint while allowing +capability-set changes within the same validated policy. The compiler-derived update header is 124 bytes and carries a +dedicated `acknowledged_publication_generation`; it must advance monotonically and cannot name the publication currently +being prepared. `consumed_plan_revision` remains independent because host application does not prove GPU completion. +The Wasm update prepares the Rust plan, validates and serializes it into the inactive arena, commits compiler/session +state only after staging succeeds, and aborts preparation on every intervening failure. The acknowledgment itself +survives an aborted publication because it reports an already-completed renderer fence. Compiled-Wasm tests exercise +accepted and future acknowledgments, A/B preservation, and retry after abort. + +This makes the full retained plan compiler reachable: the optimized artifact changes from 739,909 / 272,624 / 214,395 +to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. The 82,534 raw / 35,409 gzip / 28,052 Brotli increase is shared +runtime code, not font-local shaping data, and is now an explicit size-optimization target. Mutation sections still reject +nonempty data and the session currently supplies an empty semantic input, so the Wasm path emits an empty Rust plan. +Rust shaping/layout → nonempty plan connection and its 25,515-glyph end-to-end timing remain open; the TypeScript layout +benchmark is baseline evidence only. ## Performance contract diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index ff018498..d9191ca8 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -137,6 +137,7 @@ struct EngineUpdateRequestHeader { session_id: u32, expected_engine_revision: u32, consumed_plan_revision: u32, + acknowledged_publication_generation: u32, policy_handle: u32, capability_set: u32, flags: u32, @@ -594,6 +595,11 @@ field_offset!( EngineUpdateRequestHeader, consumed_plan_revision ); +field_offset!( + ENGINE_UPDATE_ACKNOWLEDGED_PUBLICATION_GENERATION, + EngineUpdateRequestHeader, + acknowledged_publication_generation +); field_offset!( ENGINE_UPDATE_POLICY_HANDLE, EngineUpdateRequestHeader, @@ -1177,6 +1183,7 @@ pub fn json() -> String { "sessionId": ENGINE_UPDATE_SESSION_ID, "expectedEngineRevision": ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, "consumedPlanRevision": ENGINE_UPDATE_CONSUMED_PLAN_REVISION, + "acknowledgedPublicationGeneration": ENGINE_UPDATE_ACKNOWLEDGED_PUBLICATION_GENERATION, "policyHandle": ENGINE_UPDATE_POLICY_HANDLE, "capabilitySet": ENGINE_UPDATE_CAPABILITY_SET, "flags": ENGINE_UPDATE_FLAGS, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index 900b3a7a..a9ff689d 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -5,6 +5,7 @@ pub(crate) struct UpdateRequest { pub session_id: u32, pub expected_engine_revision: u32, pub consumed_plan_revision: u32, + pub acknowledged_publication_generation: u32, pub policy_handle: u32, pub capability_set: u32, pub limits: UpdateLimits, @@ -46,6 +47,15 @@ pub(crate) struct PreparedUpdate { pub(super) next: SessionRevision, pub(super) required_base_revision: u32, pub(super) checkpoint: bool, + pub(super) policy_handle: u32, + pub(super) capability_set: u32, + pub(super) policy_fingerprint: u64, +} + +impl PreparedUpdate { + pub(crate) fn session_id(self) -> u32 { + self.session_id + } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 75f333fc..68463868 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -8,7 +8,8 @@ use crate::{ STATUS_INVALID_REQUEST, abi_contract::{ ABI_VERSION, ENGINE_RESULT_HEADER_SIZE, ENGINE_UPDATE_ABI_VERSION, - ENGINE_UPDATE_BYTE_LENGTH, ENGINE_UPDATE_CAPABILITY_SET, ENGINE_UPDATE_CONSTRAINT_COUNT, + ENGINE_UPDATE_ACKNOWLEDGED_PUBLICATION_GENERATION, ENGINE_UPDATE_BYTE_LENGTH, + ENGINE_UPDATE_CAPABILITY_SET, ENGINE_UPDATE_CONSTRAINT_COUNT, ENGINE_UPDATE_CONSTRAINTS_OFFSET, ENGINE_UPDATE_CONSUMED_PLAN_REVISION, ENGINE_UPDATE_EXCLUSION_COUNT, ENGINE_UPDATE_EXCLUSIONS_OFFSET, ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, ENGINE_UPDATE_FLAGS, @@ -95,6 +96,10 @@ pub(crate) fn parse_update_request(bytes: &[u8], session_id: u32) -> Result for RenderPlanCompilerError { } } +impl RenderPlanCompilerError { + pub(crate) fn is_result_too_large(self) -> bool { + match self { + Self::AllocationFailed | Self::ArithmeticOverflow => true, + Self::Ordered(error) => matches!( + error, + OrderedPlanError::AllocationFailed + | OrderedPlanError::CapacityExceeded + | OrderedPlanError::IdentifierExhausted + | OrderedPlanError::ArithmeticOverflow + | OrderedPlanError::PolicyExecution(PolicyExecutionError::OutputCapacity) + ), + Self::Stable(error) => matches!( + error, + StablePlanError::AllocationFailed + | StablePlanError::CapacityExceeded + | StablePlanError::IdentifierExhausted + | StablePlanError::ArithmeticOverflow + | StablePlanError::PolicyExecution(PolicyExecutionError::OutputCapacity) + ), + _ => false, + } + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum PreparedStrategy { #[default] diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 18e035e9..c6097fa4 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -2,7 +2,10 @@ use alloc::collections::BTreeMap; use super::{ frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, + plan_input::PlanInput, policy::{CapabilitySetId, ValidatedPolicy}, + render_plan::RenderPlanView, + render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -15,6 +18,7 @@ pub enum EngineError { RevisionConflict, RevisionExhausted, InvalidRequest, + ResultTooLarge, } #[derive(Default)] @@ -26,6 +30,15 @@ pub struct TextEngine { #[derive(Default)] struct EngineSession { revision: SessionRevision, + acknowledged_publication_generation: u32, + policy_binding: Option, + plan: RenderPlanCompiler, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct PolicyBinding { + handle: u32, + fingerprint: u64, } impl TextEngine { @@ -93,25 +106,39 @@ impl TextEngine { } pub(crate) fn prepare_update( - &self, + &mut self, request: UpdateRequest, + publication_generation: u32, ) -> Result { if !request.limits.all_nonzero() { return Err(EngineError::InvalidRequest); } - let session = self - .sessions - .get(&request.session_id) - .ok_or(EngineError::SessionMissing)?; - let policy = self.policy(request.policy_handle)?; + let policy = self + .policies + .get(&request.policy_handle) + .ok_or(EngineError::PolicyMissing)?; if policy .capability_set(CapabilitySetId(request.capability_set)) .is_none() { return Err(EngineError::InvalidRequest); } + let policy_fingerprint = policy.fingerprint(); + let session = self + .sessions + .get_mut(&request.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.policy_binding.is_some_and(|binding| { + binding.handle != request.policy_handle || binding.fingerprint != policy_fingerprint + }) { + return Err(EngineError::InvalidRequest); + } if request.expected_engine_revision != session.revision.engine || request.consumed_plan_revision > session.revision.plan + || publication_generation == 0 + || request.acknowledged_publication_generation + < session.acknowledged_publication_generation + || request.acknowledged_publication_generation >= publication_generation { return Err(EngineError::RevisionConflict); } @@ -129,15 +156,67 @@ impl TextEngine { }; let checkpoint = session.revision.plan == 0 || request.consumed_plan_revision != session.revision.plan; + session.acknowledged_publication_generation = request.acknowledged_publication_generation; + session + .plan + .prepare( + policy, + CapabilitySetId(request.capability_set), + PlanInput { + glyphs: &[], + f32_fields: &[], + u32_fields: &[], + }, + checkpoint, + publication_generation, + request.acknowledged_publication_generation, + ) + .map_err(plan_error)?; Ok(PreparedUpdate { session_id: request.session_id, previous: session.revision, next, required_base_revision: if checkpoint { 0 } else { session.revision.plan }, checkpoint, + policy_handle: request.policy_handle, + capability_set: request.capability_set, + policy_fingerprint, }) } + pub(crate) fn prepared_plan( + &self, + prepared: PreparedUpdate, + ) -> Result, EngineError> { + let session = self + .sessions + .get(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + session + .plan + .plan_view( + prepared.policy_handle, + CapabilitySetId(prepared.capability_set), + prepared.policy_fingerprint, + ) + .map_err(plan_error) + } + + pub(crate) fn abort_update(&mut self, prepared: PreparedUpdate) -> Result<(), EngineError> { + let session = self + .sessions + .get_mut(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + session.plan.abort(); + Ok(()) + } + pub(crate) fn commit_update( &mut self, prepared: PreparedUpdate, @@ -149,6 +228,11 @@ impl TextEngine { if session.revision != prepared.previous { return Err(EngineError::RevisionConflict); } + session.plan.commit().map_err(plan_error)?; + session.policy_binding = Some(PolicyBinding { + handle: prepared.policy_handle, + fingerprint: prepared.policy_fingerprint, + }); session.revision = prepared.next; Ok(CommittedUpdate { session_id: prepared.session_id, @@ -159,6 +243,14 @@ impl TextEngine { } } +fn plan_error(error: RenderPlanCompilerError) -> EngineError { + if error.is_result_too_large() { + EngineError::ResultTooLarge + } else { + EngineError::InvalidRequest + } +} + #[cfg(test)] mod tests { use super::*; @@ -210,7 +302,10 @@ mod tests { .unwrap(); engine.create_session(4).unwrap(); - let first = engine.prepare_update(update(0, 0)).unwrap(); + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + let first_plan = engine.prepared_plan(first).unwrap(); + assert_eq!(first_plan.policy_handle, 9); + assert_eq!(first_plan.capability_set, 1); assert_eq!( engine.session_revision(4).unwrap(), SessionRevision::default() @@ -220,13 +315,13 @@ mod tests { assert_eq!(first.required_base_revision, 0); assert_eq!(first.revision, SessionRevision { engine: 1, plan: 1 }); - let second = engine.prepare_update(update(1, 1)).unwrap(); + let second = engine.prepare_update(update(1, 1, 1), 2).unwrap(); let second = engine.commit_update(second).unwrap(); assert!(!second.checkpoint); assert_eq!(second.required_base_revision, 1); assert_eq!( - engine.prepare_update(update(1, 2)), + engine.prepare_update(update(1, 2, 1), 3), Err(EngineError::RevisionConflict) ); assert_eq!(engine.session_count(), 1); @@ -241,10 +336,10 @@ mod tests { .register_policy(9, validated_policy(TechniqueId(1))) .unwrap(); engine.create_session(4).unwrap(); - let mut request = update(0, 0); + let mut request = update(0, 0, 0); request.capability_set = 2; assert_eq!( - engine.prepare_update(request), + engine.prepare_update(request, 1), Err(EngineError::InvalidRequest) ); assert_eq!( @@ -253,6 +348,65 @@ mod tests { ); } + #[test] + fn renderer_fence_acknowledgment_is_monotonic_and_cannot_name_the_pending_publication() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(first).unwrap(); + let second = engine.prepare_update(update(1, 1, 1), 2).unwrap(); + engine.commit_update(second).unwrap(); + + assert_eq!( + engine.prepare_update(update(2, 2, 3), 3), + Err(EngineError::RevisionConflict) + ); + assert_eq!( + engine.prepare_update(update(2, 2, 0), 3), + Err(EngineError::RevisionConflict) + ); + } + + #[test] + fn aborting_a_prepared_plan_preserves_revisions_and_allows_retry() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let prepared = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.abort_update(prepared).unwrap(); + assert_eq!( + engine.session_revision(4).unwrap(), + SessionRevision::default() + ); + let retry = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(retry).unwrap(); + } + + #[test] + fn a_committed_session_rejects_rebinding_its_policy_identity() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(first).unwrap(); + + engine.dispose_policy(9).unwrap(); + engine + .register_policy(9, validated_policy(TechniqueId(2))) + .unwrap(); + assert_eq!( + engine.prepare_update(update(1, 1, 1), 2), + Err(EngineError::InvalidRequest) + ); + } + fn validated_policy(technique: TechniqueId) -> ValidatedPolicy { ValidatedPolicy::new(PolicyDescriptor { capability_sets: vec![CapabilitySet { @@ -304,11 +458,16 @@ mod tests { .unwrap() } - fn update(expected_engine_revision: u32, consumed_plan_revision: u32) -> UpdateRequest { + fn update( + expected_engine_revision: u32, + consumed_plan_revision: u32, + acknowledged_publication_generation: u32, + ) -> UpdateRequest { UpdateRequest { session_id: 4, expected_engine_revision, consumed_plan_revision, + acknowledged_publication_generation, policy_handle: 9, capability_set: 1, limits: super::super::frame::UpdateLimits { diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 58bbe817..89efcb9e 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -7,8 +7,7 @@ use crate::{ STATUS_SESSION_MISSING, ShaperRegistry, bidi, engine::{ EngineError, TextEngine, frame::SessionRevision, frame_wire::parse_update_request, - render_plan::RenderPlanView, render_plan_wire::plan_layout, transport::FrameTransport, - wire::parse_policy, + render_plan_wire::plan_layout, transport::FrameTransport, wire::parse_policy, }, wire::{ pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, @@ -393,32 +392,45 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( } } }; - let prepared = match state.engine.prepare_update(request) { + let publication_generation = match state + .frames + .get(&session_id) + .and_then(|transport| transport.next_publication_generation().ok()) + { + Some(generation) => generation, + None => { + return publish_failure(state, session_id, revision, STATUS_RESULT_TOO_LARGE, 0, 0); + } + }; + let prepared = match state.engine.prepare_update(request, publication_generation) { Ok(prepared) => prepared, Err(error) => { return publish_failure(state, session_id, revision, engine_status(error), 0, 0); } }; - let policy_fingerprint = match state.engine.policy(request.policy_handle) { - Ok(policy) => policy.fingerprint(), + let plan = match state.engine.prepared_plan(prepared) { + Ok(plan) => plan, Err(error) => { - return publish_failure(state, session_id, revision, engine_status(error), 0, 0); + return publish_prepared_failure( + state, + prepared, + revision, + engine_status(error), + 0, + 0, + ); } }; - let plan = RenderPlanView { - policy_handle: request.policy_handle, - capability_set: request.capability_set, - policy_fingerprint, - ..RenderPlanView::default() - }; let required_output = match plan_layout(plan) { Ok(layout) => layout.byte_length, - Err(status) => return publish_failure(state, session_id, revision, status, 0, 0), + Err(status) => { + return publish_prepared_failure(state, prepared, revision, status, 0, 0); + } }; if required_output > request.limits.max_output_bytes { - return publish_failure( + return publish_prepared_failure( state, - session_id, + prepared, revision, STATUS_RESULT_TOO_LARGE, 0, @@ -426,13 +438,11 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( ); } let Some(transport) = state.frames.get(&session_id) else { + let _ = state.engine.abort_update(prepared); return 0; }; if let Err(status) = transport.ensure_publish_capacity(required_output) { - return publish_failure(state, session_id, revision, status, 0, required_output); - } - if let Err(status) = transport.next_publication_generation() { - return publish_failure(state, session_id, revision, status, 0, 0); + return publish_prepared_failure(state, prepared, revision, status, 0, required_output); } let staged = match state .frames @@ -441,9 +451,9 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( { Some(staged) => staged, None => { - return publish_failure( + return publish_prepared_failure( state, - session_id, + prepared, revision, STATUS_RESULT_TOO_LARGE, 0, @@ -454,7 +464,14 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( let commit = match state.engine.commit_update(prepared) { Ok(commit) => commit, Err(error) => { - return publish_failure(state, session_id, revision, engine_status(error), 0, 0); + return publish_prepared_failure( + state, + prepared, + revision, + engine_status(error), + 0, + 0, + ); } }; let Some(transport) = state.frames.get_mut(&session_id) else { @@ -642,9 +659,30 @@ fn engine_status(error: EngineError) -> u32 { EngineError::RevisionConflict => STATUS_REVISION_CONFLICT, EngineError::RevisionExhausted => STATUS_RESULT_TOO_LARGE, EngineError::InvalidRequest => STATUS_INVALID_REQUEST, + EngineError::ResultTooLarge => STATUS_RESULT_TOO_LARGE, } } +fn publish_prepared_failure( + state: &mut WasmState, + prepared: crate::engine::frame::PreparedUpdate, + revision: SessionRevision, + status: u32, + required_request_capacity: u32, + required_result_capacity: u32, +) -> u32 { + let session_id = prepared.session_id(); + let _ = state.engine.abort_update(prepared); + publish_failure( + state, + session_id, + revision, + status, + required_request_capacity, + required_result_capacity, + ) +} + fn publish_failure( state: &mut WasmState, session_id: u32, diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 37d24c80..613748a0 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -289,37 +289,38 @@ export const textShaperAbi = { }, "engineUpdateRequest": { "abiVersion": 0, + "acknowledgedPublicationGeneration": 20, "alignment": 4, "byteLength": 4, - "capabilitySet": 24, - "constraintCount": 84, - "constraintsOffset": 80, + "capabilitySet": 28, + "constraintCount": 88, + "constraintsOffset": 84, "consumedPlanRevision": 16, - "exclusionCount": 100, - "exclusionsOffset": 96, + "exclusionCount": 104, + "exclusionsOffset": 100, "expectedEngineRevision": 12, - "flags": 28, - "inlineObjectCount": 108, - "inlineObjectsOffset": 104, - "maxClusters": 36, - "maxExclusions": 48, - "maxInlineObjects": 52, - "maxLines": 40, - "maxOutputBytes": 60, - "maxRegions": 44, - "maxSlotsPerBand": 56, - "policyHandle": 20, - "policyParametersLength": 116, - "policyParametersOffset": 112, - "regionCount": 92, - "regionsOffset": 88, - "semanticViewMask": 32, + "flags": 32, + "inlineObjectCount": 112, + "inlineObjectsOffset": 108, + "maxClusters": 40, + "maxExclusions": 52, + "maxInlineObjects": 56, + "maxLines": 44, + "maxOutputBytes": 64, + "maxRegions": 48, + "maxSlotsPerBand": 60, + "policyHandle": 24, + "policyParametersLength": 120, + "policyParametersOffset": 116, + "regionCount": 96, + "regionsOffset": 92, + "semanticViewMask": 36, "sessionId": 8, - "size": 120, - "styleMutationCount": 76, - "styleMutationsOffset": 72, - "textMutationCount": 68, - "textMutationsOffset": 64 + "size": 124, + "styleMutationCount": 80, + "styleMutationsOffset": 76, + "textMutationCount": 72, + "textMutationsOffset": 68 }, "feature": { "alignment": 4, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 7a4c5a2a..d0d722a4 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -26,6 +26,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as const requestLayout = abi.layouts.engineUpdateRequest; const resultLayout = abi.layouts.engineResult; assert.equal(resultLayout.size, 144); + assert.equal(requestLayout.size, 124); assert.equal(resultLayout.alignment, 16); assert.equal(abi.layouts.engineBuffer.size, 36); assert.equal(abi.layouts.enginePatch.size, 36); @@ -53,7 +54,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as const firstHeader = resultBytes(memory, firstPointer, resultLayout).slice(); const warmBuffer = memory.buffer; - writeRequest(memory, requestPointer, abi, 1, 1); + writeRequest(memory, requestPointer, abi, 1, 1, 1); const secondPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); assert.strictEqual(memory.buffer, warmBuffer, 'a warm empty transaction must not grow Wasm memory'); assert.notEqual(secondPointer, firstPointer); @@ -69,7 +70,20 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.deepEqual(resultBytes(memory, firstPointer, resultLayout), firstHeader); const secondHeader = resultBytes(memory, secondPointer, resultLayout).slice(); - writeRequest(memory, requestPointer, abi, 1, 2); + writeRequest(memory, requestPointer, abi, 2, 2, 3); + const futureFencePointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); + assertResult(memory, futureFencePointer, abi, { + status: abi.status.revisionConflict, + engineRevision: 2, + planRevision: 2, + requiredBaseRevision: 2, + publicationGeneration: 2, + outputSlot: 0, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, secondPointer, resultLayout), secondHeader); + + writeRequest(memory, requestPointer, abi, 1, 2, 1); const failedPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); assert.notEqual(failedPointer, secondPointer); assertResult(memory, failedPointer, abi, { @@ -83,7 +97,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as }); assert.deepEqual(resultBytes(memory, secondPointer, resultLayout), secondHeader); - writeRequest(memory, requestPointer, abi, 2, 0); + writeRequest(memory, requestPointer, abi, 2, 0, 2); const checkpointPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); assertResult(memory, checkpointPointer, abi, { status: abi.status.ok, @@ -96,7 +110,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as }); const checkpointHeader = resultBytes(memory, checkpointPointer, resultLayout).slice(); - writeRequest(memory, requestPointer, abi, 3, 3); + writeRequest(memory, requestPointer, abi, 3, 3, 3); new DataView(memory.buffer, requestPointer, requestLayout.size).setUint32(requestLayout.regionCount, 1, true); const unsupportedPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); assertResult(memory, unsupportedPointer, abi, { @@ -126,12 +140,20 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.equal(fn.disposePolicy(policyHandle), abi.status.ok); }); -function writeRequest(memory, pointer, abi, expectedEngineRevision, consumedPlanRevision) { +function writeRequest( + memory, + pointer, + abi, + expectedEngineRevision, + consumedPlanRevision, + acknowledgedPublicationGeneration = 0, +) { const bytes = engineUpdateBytes(abi, { sessionId, policyHandle, expectedEngineRevision, consumedPlanRevision, + acknowledgedPublicationGeneration, }); new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); } diff --git a/packages/text/tests/support/engine-abi.d.mts b/packages/text/tests/support/engine-abi.d.mts index 25743401..6ce88895 100644 --- a/packages/text/tests/support/engine-abi.d.mts +++ b/packages/text/tests/support/engine-abi.d.mts @@ -3,6 +3,7 @@ export interface EngineUpdateFields { readonly policyHandle: number; readonly expectedEngineRevision: number; readonly consumedPlanRevision: number; + readonly acknowledgedPublicationGeneration?: number; } export function renderPolicyBytes(abi: object): Uint8Array; diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 76bb93a7..9f83fd1b 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -50,7 +50,10 @@ export function kernelPolicyBytes(abi) { ]); } -export function engineUpdateBytes(abi, { sessionId, policyHandle, expectedEngineRevision, consumedPlanRevision }) { +export function engineUpdateBytes( + abi, + { sessionId, policyHandle, expectedEngineRevision, consumedPlanRevision, acknowledgedPublicationGeneration = 0 }, +) { const layout = abi.layouts.engineUpdateRequest; const bytes = new Uint8Array(layout.size); const view = new DataView(bytes.buffer); @@ -59,6 +62,7 @@ export function engineUpdateBytes(abi, { sessionId, policyHandle, expectedEngine view.setUint32(layout.sessionId, sessionId, true); view.setUint32(layout.expectedEngineRevision, expectedEngineRevision, true); view.setUint32(layout.consumedPlanRevision, consumedPlanRevision, true); + view.setUint32(layout.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); view.setUint32(layout.policyHandle, policyHandle, true); view.setUint32(layout.capabilitySet, 1, true); for (const field of [ From 7d566ed46dc4d6bbd0ca9a74ffd0f710e37730d2 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 10:29:48 -0400 Subject: [PATCH 016/128] fix(text): preserve render-plan fence invariants --- docs/log.md | 3 +- docs/packages/text.md | 13 ++-- docs/planning/rust-layout-engine.md | 4 +- .../shaper/src/engine/render_plan_compiler.rs | 70 ++++++++++++++----- .../rust/shaper/src/engine/stable_plan.rs | 25 ++++++- .../rust/shaper/src/engine/stable_pool.rs | 4 ++ packages/text/rust/shaper/src/engine/state.rs | 62 ++++++++++++---- packages/text/rust/shaper/src/wasm.rs | 4 ++ 8 files changed, 146 insertions(+), 39 deletions(-) diff --git a/docs/log.md b/docs/log.md index 8efcaa6b..e6ca77cc 100644 --- a/docs/log.md +++ b/docs/log.md @@ -6,7 +6,8 @@ a monotonic renderer-fence acknowledgment distinct from consumed plan revision. Each session owns the Rust mixed-plan dispatcher and pins its committed policy identity. Wasm prepares, validates, stages, and only then commits planner and revision state; failure aborts the planner while preserving a valid already-completed fence acknowledgment. Host and - compiled-Wasm tests cover future/stale fences, abort/retry, policy replacement, and A/B preservation. Reachability + compiled-Wasm tests cover accepted/future fences and A/B preservation; host tests cover stale fences, abort/retry, + capability changes, and policy replacement. Post-prepare Wasm abort coverage waits on nonempty semantic input. Reachability raises optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Mutation sections still reject nonempty semantic input, so this publishes an empty Rust plan and makes no shaping/layout latency claim; the shared-runtime size increase is now a measured optimization target. diff --git a/docs/packages/text.md b/docs/packages/text.md index 86535083..c2a753e5 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:0a8d6555832935707d801b778ebe21b577bbf355aa39249f8e14148dc5681e93' +source_digest: 'sha256:47777c53e70e358e43385ae0100ca532d9f81297e57815a7547eca00f35cd311' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -386,7 +386,8 @@ remains within measurement variance: baseline-to-current cold/font-size/layout-w The retained frame shell gives each engine session one 16-byte-aligned request arena and two 16-byte-aligned result arenas. Cold creation and reservation may resize them; a warm update reads the already-pinned request and returns the -selected result pointer in that single call. The initial Stage 1 compiler-derived request header was 120 bytes. Its section offsets cover +selected result pointer in that single call. The compiler-derived request header was 120 bytes in Stage 1 and is now 124 +bytes after adding renderer-fence acknowledgment. Its section offsets cover text/style mutations, constraints, regions, exclusions, inline objects, and policy parameters; Stage 1 accepts only the canonical empty transaction and rejects a nonempty section until its Rust consumer exists. The 144-byte aligned result header fixes revisions, base requirements, capacity watermarks, output slot and generation, policy handle, capability @@ -470,14 +471,16 @@ timing claim before session integration. The required unchanged-path 25,515-glyp recorded 54.42/12.15/8.31/38.98 ms medians. The optimized Wasm remains 739,909 raw and 214,395 Brotli bytes, so the dispatcher is still LTO-stripped and the table is baseline run variance rather than a planner performance result. -Engine sessions now own the Rust allocation-strategy dispatcher. The compiler-derived request grows from 120 to 124 +Engine sessions now own the Rust allocation-strategy dispatcher. The current compiler-derived request header is 124 bytes for `acknowledgedPublicationGeneration`, a renderer-fence field independent from `consumedPlanRevision`. Rust requires acknowledgment to be monotonic and no newer than the last successful publication. The update path prepares and views the session plan, stages it in the inactive result arena, commits planner and revision state only after successful serialization, and aborts the planner on every failure. A session pins its first committed policy handle/fingerprint; capability sets may change within that policy, but replacing the policy beneath retained buffers fails before mutation. -Focused host and compiled-Wasm tests cover future/stale fences, abort/retry, unchanged A/B publication, and policy -identity. The now-reachable planners increase the optimized artifact from 739,909 / 272,624 / 214,395 to +Focused compiled-Wasm tests cover accepted/future fences and unchanged A/B publication; host tests cover stale fences, +abort/retry, capability-set changes, and policy identity. A post-prepare Wasm abort cannot be induced until nonempty +semantic input exists, so that exact ordering remains an explicit test gap. The now-reachable planners increase the +optimized artifact from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. This is a measured shared-runtime cost and a pending optimization target. Nonempty mutation sections remain rejected, so sessions currently publish an empty Rust plan; there is no Rust shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index a581e080..40cd0191 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -748,7 +748,9 @@ being prepared. `consumed_plan_revision` remains independent because host applic The Wasm update prepares the Rust plan, validates and serializes it into the inactive arena, commits compiler/session state only after staging succeeds, and aborts preparation on every intervening failure. The acknowledgment itself survives an aborted publication because it reports an already-completed renderer fence. Compiled-Wasm tests exercise -accepted and future acknowledgments, A/B preservation, and retry after abort. +accepted and future acknowledgments plus A/B preservation. Host tests exercise compiler abort/retry directly. A +post-prepare Wasm failure is not constructible while semantic input is empty and the encoded plan is the minimum-size +header; that ABI ordering requires a regression test once nonempty Rust semantic input can exceed a request limit. This makes the full retained plan compiler reachable: the optimized artifact changes from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. The 82,534 raw / 35,409 gzip / 28,052 Brotli increase is shared diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index e04cc83e..a0a42872 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -67,27 +67,63 @@ impl RenderPlanCompilerError { pub(crate) fn is_result_too_large(self) -> bool { match self { Self::AllocationFailed | Self::ArithmeticOverflow => true, - Self::Ordered(error) => matches!( - error, - OrderedPlanError::AllocationFailed - | OrderedPlanError::CapacityExceeded - | OrderedPlanError::IdentifierExhausted - | OrderedPlanError::ArithmeticOverflow - | OrderedPlanError::PolicyExecution(PolicyExecutionError::OutputCapacity) - ), - Self::Stable(error) => matches!( - error, - StablePlanError::AllocationFailed - | StablePlanError::CapacityExceeded - | StablePlanError::IdentifierExhausted - | StablePlanError::ArithmeticOverflow - | StablePlanError::PolicyExecution(PolicyExecutionError::OutputCapacity) - ), - _ => false, + Self::AlreadyPrepared + | Self::NotPrepared + | Self::CapabilitySetMissing + | Self::ProgramMissing + | Self::UnsupportedStrategy + | Self::InvalidInputShape + | Self::InvalidIdentity + | Self::InvalidResource + | Self::InvalidPlan => false, + Self::Ordered(error) => ordered_result_too_large(error), + Self::Stable(error) => stable_result_too_large(error), } } } +fn policy_result_too_large(error: PolicyExecutionError) -> bool { + match error { + PolicyExecutionError::OutputCapacity => true, + PolicyExecutionError::CapabilitySetMissing + | PolicyExecutionError::ProgramMissing + | PolicyExecutionError::InputFieldCount + | PolicyExecutionError::InputLength + | PolicyExecutionError::OutputBufferCount + | PolicyExecutionError::OutputSchema + | PolicyExecutionError::NonFiniteOutput => false, + } +} + +macro_rules! classify_plan_error { + ($error:expr, $error_type:ident) => { + match $error { + $error_type::AllocationFailed + | $error_type::CapacityExceeded + | $error_type::IdentifierExhausted + | $error_type::ArithmeticOverflow => true, + $error_type::AlreadyPrepared + | $error_type::NotPrepared + | $error_type::CapabilitySetMissing + | $error_type::ProgramMissing + | $error_type::UnsupportedStrategy + | $error_type::InvalidInputShape + | $error_type::InvalidIdentity + | $error_type::DuplicateIdentity + | $error_type::InvalidResource => false, + $error_type::PolicyExecution(error) => policy_result_too_large(error), + } + }; +} + +fn ordered_result_too_large(error: OrderedPlanError) -> bool { + classify_plan_error!(error, OrderedPlanError) +} + +fn stable_result_too_large(error: StablePlanError) -> bool { + classify_plan_error!(error, StablePlanError) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] enum PreparedStrategy { #[default] diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index a05ae1b0..718dc091 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -362,6 +362,8 @@ impl StablePlanCompiler { .ok_or(StablePlanError::CapabilitySetMissing)?; validate_input(input)?; for batch in &mut self.batches { + // GPU completion is external monotonic state, not part of the publication transaction. + // Reclamation therefore intentionally survives a later prepare failure or abort. batch.acknowledge(acknowledged_publication_generation)?; } self.reset_pending(); @@ -479,7 +481,11 @@ impl StablePlanCompiler { } pub(crate) fn has_state(&self) -> bool { - self.batches.iter().any(|batch| batch.active) + self.batches.iter().any(|batch| { + batch.active + || batch.slots.has_quarantined_slots() + || !batch.quarantined_chunks.is_empty() + }) } pub(crate) fn publishes_bindings(&self) -> bool { @@ -1738,6 +1744,23 @@ mod tests { assert_eq!(compiler.input_slots[2], 2); } + #[test] + fn an_inactive_batch_stays_live_only_until_its_quarantine_is_acknowledged() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let initial = [glyph(1, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0], true, 1, 0); + compiler.commit().unwrap(); + + prepare(&mut compiler, &policy, &[], &[], false, 2, 0); + compiler.commit().unwrap(); + assert!(compiler.has_state()); + + prepare(&mut compiler, &policy, &[], &[], false, 3, 2); + compiler.commit().unwrap(); + assert!(!compiler.has_state()); + } + #[test] fn interleaved_resources_keep_ordered_draws_over_separate_stable_pools() { let policy = policy(false); diff --git a/packages/text/rust/shaper/src/engine/stable_pool.rs b/packages/text/rust/shaper/src/engine/stable_pool.rs index bc56b1f2..98fca5f7 100644 --- a/packages/text/rust/shaper/src/engine/stable_pool.rs +++ b/packages/text/rust/shaper/src/engine/stable_pool.rs @@ -86,6 +86,10 @@ impl StableSlotPool { Ok(()) } + pub fn has_quarantined_slots(&self) -> bool { + !self.quarantine.is_empty() + } + pub fn prepare( &mut self, desired: &[SlotIdentity], diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index c6097fa4..6d3925ba 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -156,6 +156,8 @@ impl TextEngine { }; let checkpoint = session.revision.plan == 0 || request.consumed_plan_revision != session.revision.plan; + // A completed renderer fence is external monotonic state. It remains accepted even if + // plan preparation or publication later aborts. session.acknowledged_publication_generation = request.acknowledged_publication_generation; session .plan @@ -337,7 +339,7 @@ mod tests { .unwrap(); engine.create_session(4).unwrap(); let mut request = update(0, 0, 0); - request.capability_set = 2; + request.capability_set = 3; assert_eq!( engine.prepare_update(request, 1), Err(EngineError::InvalidRequest) @@ -348,6 +350,23 @@ mod tests { ); } + #[test] + fn a_committed_session_accepts_another_capability_set_from_the_same_policy() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let first = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + engine.commit_update(first).unwrap(); + + let mut request = update(1, 1, 1); + request.capability_set = 2; + let second = engine.prepare_update(request, 2).unwrap(); + assert_eq!(engine.prepared_plan(second).unwrap().capability_set, 2); + engine.commit_update(second).unwrap(); + } + #[test] fn renderer_fence_acknowledgment_is_monotonic_and_cannot_name_the_pending_publication() { let mut engine = TextEngine::default(); @@ -409,19 +428,34 @@ mod tests { fn validated_policy(technique: TechniqueId) -> ValidatedPolicy { ValidatedPolicy::new(PolicyDescriptor { - capability_sets: vec![CapabilitySet { - id: CapabilitySetId(1), - flags: CAP_ORDERED_DIRECT, - max_buffer_bytes: 1024, - update_alignment: 4, - coalesce_gap_bytes: 0, - range_call_penalty_bytes: 0, - max_buffers_per_draw: 1, - max_resources_per_draw: 1, - max_indirect_draws: 0, - fragmentation_budget: 1, - whole_buffer_threshold_basis_points: 10_000, - }], + capability_sets: vec![ + CapabilitySet { + id: CapabilitySetId(1), + flags: CAP_ORDERED_DIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 1, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 1, + whole_buffer_threshold_basis_points: 10_000, + }, + CapabilitySet { + id: CapabilitySetId(2), + flags: CAP_ORDERED_DIRECT, + max_buffer_bytes: 1024, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 0, + max_buffers_per_draw: 1, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 1, + whole_buffer_threshold_basis_points: 10_000, + }, + ], programs: vec![ProgramDescriptor { technique, variant: 0, diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 89efcb9e..e53d8524 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -477,6 +477,10 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( let Some(transport) = state.frames.get_mut(&session_id) else { return 0; }; + debug_assert_eq!( + transport.next_publication_generation().ok(), + Some(publication_generation) + ); u32::try_from(transport.publish_success(commit, staged)).unwrap_or(0) }) } From 5c4382a4f4c0a2a312a6a2e6799f7030462b6c44 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 10:41:28 -0400 Subject: [PATCH 017/128] feat(text): define semantic update records --- docs/log.md | 6 + docs/packages/text.md | 10 +- docs/planning/decision-register.md | 169 ++-- docs/planning/rust-layout-engine.md | 8 + packages/text/rust/shaper/src/abi_contract.rs | 726 +++++++++++++++++- packages/text/rust/shaper/src/engine/frame.rs | 9 + .../text/src/generated/text-shaper-abi.ts | 147 ++++ .../render-plan-frame-abi.test.mjs | 19 + 8 files changed, 1008 insertions(+), 86 deletions(-) diff --git a/docs/log.md b/docs/log.md index e6ca77cc..0075e394 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-08 +- **Fixed the semantic update record grammar in the compiler-derived ABI** — Added exact UTF-16 text replacement, + stable style, constraint, flow-vertex, region, exclusion, and inline-object layouts. Rectangle and bounded-polygon + geometry resolve inside the same request; style records carry shaping, spacing, material/color, and decoration data. + Generated ABI tests pin the record sizes and tags. Nonempty sections remain rejected until the Rust decoder lands, so + this checkpoint makes no layout or performance claim. + - **Made Rust render-plan state session-owned and fence-safe** — The 124-byte compiler-derived update request now carries a monotonic renderer-fence acknowledgment distinct from consumed plan revision. Each session owns the Rust mixed-plan dispatcher and pins its committed policy identity. Wasm prepares, validates, stages, and only then commits planner and diff --git a/docs/packages/text.md b/docs/packages/text.md index c2a753e5..c05abd2e 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:47777c53e70e358e43385ae0100ca532d9f81297e57815a7547eca00f35cd311' +source_digest: 'sha256:1f44b1ea6bb9e562a7a941e0f8a5cc3e224ba6273230a4c0afdcdd3dc958a1c4' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -485,6 +485,14 @@ optimized artifact from 739,909 / 272,624 / 214,395 to target. Nonempty mutation sections remain rejected, so sessions currently publish an empty Rust plan; there is no Rust shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. +The semantic request now has compiler-derived record layouts without a handwritten TypeScript mirror: 24-byte UTF-16 +text replacements, 80-byte stable style mutations, 52-byte constraints, 8-byte flow vertices, 56-byte regions, 48-byte +exclusions, and 56-byte inline objects. Region/exclusion rectangles use inline bounds, while bounded polygons reference +vertices inside the same request. Styles include current shaping fields plus word spacing, material/color, and +decoration inputs. The generated ABI and compiled-Wasm test pin every size, tag, and the inline-object +`baselineAlignment` offset; the semantic decoder still rejects nonempty sections, so this checkpoint changes no runtime +layout behavior and has no performance result. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 3f3f1bc3..fe2dc8c5 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -152,90 +152,91 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. ## Raster -| ID | Decision | Status | -| ----- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | -| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | -| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | -| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | -| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | -| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | -| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | -| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | -| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | -| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | -| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | -| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | -| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | -| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | -| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | -| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | -| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | -| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | -| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | -| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | -| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | -| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | -| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | -| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | -| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | -| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | -| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | -| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | -| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | -| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | -| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | -| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | -| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | -| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | -| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | -| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | -| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | -| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | -| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | -| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | -| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | -| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | -| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | -| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | -| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | -| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | -| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | -| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | -| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | -| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | -| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | -| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | -| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | -| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | -| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | -| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | -| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | -| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | -| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | -| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | -| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | -| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | -| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | -| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | -| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | -| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | -| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | -| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | -| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | -| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | -| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | -| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | -| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | -| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | -| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | -| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | -| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | -| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | -| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | -| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | -| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | -| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | -| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | +| ID | Decision | Status | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | +| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | +| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | +| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | +| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | +| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | +| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | +| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | +| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | +| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | +| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | +| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | +| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | +| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | +| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | +| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | +| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | +| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | +| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | +| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | +| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | +| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | +| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | +| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | +| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | +| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | +| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | +| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | +| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | +| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | +| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | +| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | +| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | +| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | +| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | +| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | +| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | +| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | +| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | +| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | +| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | +| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | +| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | +| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | +| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | +| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | +| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | +| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | +| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | +| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | +| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | +| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | +| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | +| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | +| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | +| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | +| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | +| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | +| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | +| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | +| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | +| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | +| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | +| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | +| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | +| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | +| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | +| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | +| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | +| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | +| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | +| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | +| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | +| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | +| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | +| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | +| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | +| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | +| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | +| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | +| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | +| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | +| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | +| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 40cd0191..b63e7d7c 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -267,6 +267,14 @@ The versioned request contains offsets to packed sections for: Stable fonts, policies, and capabilities are referenced by IDs. Repeating a large descriptor every frame would merely move host work into serialization. +The V0 compiler-mapped section records are 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, +52-byte flow constraints, 8-byte inline/block vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline +objects. UTF-16 payload preserves the public cluster coordinate without a host UTF-8 conversion. Styles carry current +shaping fields plus word spacing, baseline shift, material, color, and decoration inputs; checked language and feature +payloads are offset-addressed. Constraint records carry the complete region range, viewport, and resume cursor for the +call. Rectangle bounds are inline; bounded polygons reference vertex records in the same request. Defining this wire +grammar does not make a section valid until its Rust decoder and retained transaction land. + All offsets and lengths are range-checked before use. Enum tags, alignment, multiplication, and revision relationships are validated at the Wasm boundary. Failure returns a typed result without exposing partially mutated state. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index d9191ca8..e44ca129 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -2,7 +2,11 @@ use alloc::string::{String, ToString}; use core::mem::{align_of, offset_of, size_of}; use serde_json::json; -use crate::engine::frame::RESULT_FLAG_CHECKPOINT; +use crate::engine::frame::{ + RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_MUTATION_REMOVE, + STYLE_MUTATION_UPSERT, TEXT_MUTATION_REPLACE_UTF16, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, + WRITING_VERTICAL_RL, +}; use crate::engine::policy::{ ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, @@ -165,6 +169,137 @@ struct EngineUpdateRequestHeader { policy_parameters_length: u32, } +#[repr(C)] +struct EngineTextMutationRecord { + opcode: u8, + encoding: u8, + reserved0: u16, + text_start: u32, + delete_count: u32, + insert_offset: u32, + insert_count: u32, + reserved1: u32, +} + +#[repr(C)] +struct EngineStyleMutationRecord { + opcode: u8, + direction: u8, + decoration_style: u8, + flags: u8, + style_id: u32, + field_mask: u32, + text_start: u32, + text_end: u32, + font_stack_handle: u32, + material_id: u32, + language_offset: u32, + language_length: u16, + feature_count: u16, + features_offset: u32, + font_size: f32, + line_height: f32, + letter_spacing: f32, + word_spacing: f32, + baseline_shift: f32, + foreground_rgba: u32, + decoration_rgba: u32, + decoration_flags: u32, + decoration_thickness: f32, + decoration_offset: f32, +} + +#[repr(C)] +struct EngineConstraintRecord { + flow_thread_id: u32, + geometry_revision: u32, + width: f32, + height: f32, + viewport_block_start: f32, + viewport_block_end: f32, + resume_block_offset: f32, + max_lines: u32, + region_start: u32, + resume_cluster: u32, + region_count: u16, + resume_region: u16, + width_mode: u8, + height_mode: u8, + wrap: u8, + align: u8, + overflow: u8, + block_align: u8, + flags: u16, +} + +#[repr(C)] +struct EngineFlowVertexRecord { + inline: f32, + block: f32, +} + +#[repr(C)] +struct EngineRegionRecord { + id: u32, + geometry_revision: u32, + vertices_offset: u32, + vertex_count: u16, + exclusion_start: u16, + exclusion_count: u16, + flags: u16, + shape: u8, + writing_mode: u8, + text_orientation: u8, + reserved0: u8, + inline_start: f32, + block_start: f32, + inline_end: f32, + block_end: f32, + clip_inline_start: f32, + clip_block_start: f32, + clip_inline_end: f32, + clip_block_end: f32, +} + +#[repr(C)] +struct EngineExclusionRecord { + id: u32, + region_id: u32, + geometry_revision: u32, + vertices_offset: u32, + vertex_count: u16, + flags: u16, + shape: u8, + wrap_side: u8, + reserved0: u16, + inline_start: f32, + block_start: f32, + inline_end: f32, + block_end: f32, + margin_inline: f32, + margin_block: f32, +} + +#[repr(C)] +struct EngineInlineObjectRecord { + id: u32, + content_revision: u32, + text_offset: u32, + material_id: u32, + resource_id: u32, + resource_generation: u32, + inline_extent: f32, + block_extent: f32, + baseline_offset: f32, + margin_inline_start: f32, + margin_inline_end: f32, + margin_block_start: f32, + margin_block_end: f32, + baseline_alignment: u8, + flags: u8, + reserved0: u16, +} + #[repr(C, align(16))] struct EngineResultHeader { abi_version: u32, @@ -318,6 +453,41 @@ layout!( ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, EngineUpdateRequestHeader ); +layout!( + ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, + EngineTextMutationRecord +); +layout!( + ENGINE_STYLE_MUTATION_RECORD_SIZE, + ENGINE_STYLE_MUTATION_RECORD_ALIGNMENT, + EngineStyleMutationRecord +); +layout!( + ENGINE_CONSTRAINT_RECORD_SIZE, + ENGINE_CONSTRAINT_RECORD_ALIGNMENT, + EngineConstraintRecord +); +layout!( + ENGINE_FLOW_VERTEX_RECORD_SIZE, + ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + EngineFlowVertexRecord +); +layout!( + ENGINE_REGION_RECORD_SIZE, + ENGINE_REGION_RECORD_ALIGNMENT, + EngineRegionRecord +); +layout!( + ENGINE_EXCLUSION_RECORD_SIZE, + ENGINE_EXCLUSION_RECORD_ALIGNMENT, + EngineExclusionRecord +); +layout!( + ENGINE_INLINE_OBJECT_RECORD_SIZE, + ENGINE_INLINE_OBJECT_RECORD_ALIGNMENT, + EngineInlineObjectRecord +); layout!( ENGINE_RESULT_HEADER_SIZE, ENGINE_RESULT_HEADER_ALIGNMENT, @@ -721,6 +891,413 @@ field_offset!( EngineUpdateRequestHeader, policy_parameters_length ); +field_offset!( + ENGINE_TEXT_MUTATION_OPCODE, + EngineTextMutationRecord, + opcode +); +field_offset!( + ENGINE_TEXT_MUTATION_ENCODING, + EngineTextMutationRecord, + encoding +); +field_offset!( + ENGINE_TEXT_MUTATION_RESERVED0, + EngineTextMutationRecord, + reserved0 +); +field_offset!( + ENGINE_TEXT_MUTATION_TEXT_START, + EngineTextMutationRecord, + text_start +); +field_offset!( + ENGINE_TEXT_MUTATION_DELETE_COUNT, + EngineTextMutationRecord, + delete_count +); +field_offset!( + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + EngineTextMutationRecord, + insert_offset +); +field_offset!( + ENGINE_TEXT_MUTATION_INSERT_COUNT, + EngineTextMutationRecord, + insert_count +); +field_offset!( + ENGINE_TEXT_MUTATION_RESERVED1, + EngineTextMutationRecord, + reserved1 +); +field_offset!( + ENGINE_STYLE_MUTATION_OPCODE, + EngineStyleMutationRecord, + opcode +); +field_offset!( + ENGINE_STYLE_MUTATION_DIRECTION, + EngineStyleMutationRecord, + direction +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_STYLE, + EngineStyleMutationRecord, + decoration_style +); +field_offset!( + ENGINE_STYLE_MUTATION_FLAGS, + EngineStyleMutationRecord, + flags +); +field_offset!( + ENGINE_STYLE_MUTATION_STYLE_ID, + EngineStyleMutationRecord, + style_id +); +field_offset!( + ENGINE_STYLE_MUTATION_FIELD_MASK, + EngineStyleMutationRecord, + field_mask +); +field_offset!( + ENGINE_STYLE_MUTATION_TEXT_START, + EngineStyleMutationRecord, + text_start +); +field_offset!( + ENGINE_STYLE_MUTATION_TEXT_END, + EngineStyleMutationRecord, + text_end +); +field_offset!( + ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + EngineStyleMutationRecord, + font_stack_handle +); +field_offset!( + ENGINE_STYLE_MUTATION_MATERIAL_ID, + EngineStyleMutationRecord, + material_id +); +field_offset!( + ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + EngineStyleMutationRecord, + language_offset +); +field_offset!( + ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + EngineStyleMutationRecord, + language_length +); +field_offset!( + ENGINE_STYLE_MUTATION_FEATURE_COUNT, + EngineStyleMutationRecord, + feature_count +); +field_offset!( + ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + EngineStyleMutationRecord, + features_offset +); +field_offset!( + ENGINE_STYLE_MUTATION_FONT_SIZE, + EngineStyleMutationRecord, + font_size +); +field_offset!( + ENGINE_STYLE_MUTATION_LINE_HEIGHT, + EngineStyleMutationRecord, + line_height +); +field_offset!( + ENGINE_STYLE_MUTATION_LETTER_SPACING, + EngineStyleMutationRecord, + letter_spacing +); +field_offset!( + ENGINE_STYLE_MUTATION_WORD_SPACING, + EngineStyleMutationRecord, + word_spacing +); +field_offset!( + ENGINE_STYLE_MUTATION_BASELINE_SHIFT, + EngineStyleMutationRecord, + baseline_shift +); +field_offset!( + ENGINE_STYLE_MUTATION_FOREGROUND_RGBA, + EngineStyleMutationRecord, + foreground_rgba +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_RGBA, + EngineStyleMutationRecord, + decoration_rgba +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_FLAGS, + EngineStyleMutationRecord, + decoration_flags +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_THICKNESS, + EngineStyleMutationRecord, + decoration_thickness +); +field_offset!( + ENGINE_STYLE_MUTATION_DECORATION_OFFSET, + EngineStyleMutationRecord, + decoration_offset +); +field_offset!( + ENGINE_CONSTRAINT_FLOW_THREAD_ID, + EngineConstraintRecord, + flow_thread_id +); +field_offset!( + ENGINE_CONSTRAINT_GEOMETRY_REVISION, + EngineConstraintRecord, + geometry_revision +); +field_offset!(ENGINE_CONSTRAINT_WIDTH, EngineConstraintRecord, width); +field_offset!(ENGINE_CONSTRAINT_HEIGHT, EngineConstraintRecord, height); +field_offset!( + ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START, + EngineConstraintRecord, + viewport_block_start +); +field_offset!( + ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END, + EngineConstraintRecord, + viewport_block_end +); +field_offset!( + ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET, + EngineConstraintRecord, + resume_block_offset +); +field_offset!( + ENGINE_CONSTRAINT_MAX_LINES, + EngineConstraintRecord, + max_lines +); +field_offset!( + ENGINE_CONSTRAINT_REGION_START, + EngineConstraintRecord, + region_start +); +field_offset!( + ENGINE_CONSTRAINT_RESUME_CLUSTER, + EngineConstraintRecord, + resume_cluster +); +field_offset!( + ENGINE_CONSTRAINT_REGION_COUNT, + EngineConstraintRecord, + region_count +); +field_offset!( + ENGINE_CONSTRAINT_RESUME_REGION, + EngineConstraintRecord, + resume_region +); +field_offset!( + ENGINE_CONSTRAINT_WIDTH_MODE, + EngineConstraintRecord, + width_mode +); +field_offset!( + ENGINE_CONSTRAINT_HEIGHT_MODE, + EngineConstraintRecord, + height_mode +); +field_offset!(ENGINE_CONSTRAINT_WRAP, EngineConstraintRecord, wrap); +field_offset!(ENGINE_CONSTRAINT_ALIGN, EngineConstraintRecord, align); +field_offset!(ENGINE_CONSTRAINT_OVERFLOW, EngineConstraintRecord, overflow); +field_offset!( + ENGINE_CONSTRAINT_BLOCK_ALIGN, + EngineConstraintRecord, + block_align +); +field_offset!(ENGINE_CONSTRAINT_FLAGS, EngineConstraintRecord, flags); +field_offset!(ENGINE_FLOW_VERTEX_INLINE, EngineFlowVertexRecord, inline); +field_offset!(ENGINE_FLOW_VERTEX_BLOCK, EngineFlowVertexRecord, block); +field_offset!(ENGINE_REGION_ID, EngineRegionRecord, id); +field_offset!( + ENGINE_REGION_GEOMETRY_REVISION, + EngineRegionRecord, + geometry_revision +); +field_offset!( + ENGINE_REGION_VERTICES_OFFSET, + EngineRegionRecord, + vertices_offset +); +field_offset!(ENGINE_REGION_VERTEX_COUNT, EngineRegionRecord, vertex_count); +field_offset!( + ENGINE_REGION_EXCLUSION_START, + EngineRegionRecord, + exclusion_start +); +field_offset!( + ENGINE_REGION_EXCLUSION_COUNT, + EngineRegionRecord, + exclusion_count +); +field_offset!(ENGINE_REGION_FLAGS, EngineRegionRecord, flags); +field_offset!(ENGINE_REGION_SHAPE, EngineRegionRecord, shape); +field_offset!(ENGINE_REGION_WRITING_MODE, EngineRegionRecord, writing_mode); +field_offset!( + ENGINE_REGION_TEXT_ORIENTATION, + EngineRegionRecord, + text_orientation +); +field_offset!(ENGINE_REGION_RESERVED0, EngineRegionRecord, reserved0); +field_offset!(ENGINE_REGION_INLINE_START, EngineRegionRecord, inline_start); +field_offset!(ENGINE_REGION_BLOCK_START, EngineRegionRecord, block_start); +field_offset!(ENGINE_REGION_INLINE_END, EngineRegionRecord, inline_end); +field_offset!(ENGINE_REGION_BLOCK_END, EngineRegionRecord, block_end); +field_offset!( + ENGINE_REGION_CLIP_INLINE_START, + EngineRegionRecord, + clip_inline_start +); +field_offset!( + ENGINE_REGION_CLIP_BLOCK_START, + EngineRegionRecord, + clip_block_start +); +field_offset!( + ENGINE_REGION_CLIP_INLINE_END, + EngineRegionRecord, + clip_inline_end +); +field_offset!( + ENGINE_REGION_CLIP_BLOCK_END, + EngineRegionRecord, + clip_block_end +); +field_offset!(ENGINE_EXCLUSION_ID, EngineExclusionRecord, id); +field_offset!(ENGINE_EXCLUSION_REGION_ID, EngineExclusionRecord, region_id); +field_offset!( + ENGINE_EXCLUSION_GEOMETRY_REVISION, + EngineExclusionRecord, + geometry_revision +); +field_offset!( + ENGINE_EXCLUSION_VERTICES_OFFSET, + EngineExclusionRecord, + vertices_offset +); +field_offset!( + ENGINE_EXCLUSION_VERTEX_COUNT, + EngineExclusionRecord, + vertex_count +); +field_offset!(ENGINE_EXCLUSION_FLAGS, EngineExclusionRecord, flags); +field_offset!(ENGINE_EXCLUSION_SHAPE, EngineExclusionRecord, shape); +field_offset!(ENGINE_EXCLUSION_WRAP_SIDE, EngineExclusionRecord, wrap_side); +field_offset!(ENGINE_EXCLUSION_RESERVED0, EngineExclusionRecord, reserved0); +field_offset!( + ENGINE_EXCLUSION_INLINE_START, + EngineExclusionRecord, + inline_start +); +field_offset!( + ENGINE_EXCLUSION_BLOCK_START, + EngineExclusionRecord, + block_start +); +field_offset!( + ENGINE_EXCLUSION_INLINE_END, + EngineExclusionRecord, + inline_end +); +field_offset!(ENGINE_EXCLUSION_BLOCK_END, EngineExclusionRecord, block_end); +field_offset!( + ENGINE_EXCLUSION_MARGIN_INLINE, + EngineExclusionRecord, + margin_inline +); +field_offset!( + ENGINE_EXCLUSION_MARGIN_BLOCK, + EngineExclusionRecord, + margin_block +); +field_offset!(ENGINE_INLINE_OBJECT_ID, EngineInlineObjectRecord, id); +field_offset!( + ENGINE_INLINE_OBJECT_CONTENT_REVISION, + EngineInlineObjectRecord, + content_revision +); +field_offset!( + ENGINE_INLINE_OBJECT_TEXT_OFFSET, + EngineInlineObjectRecord, + text_offset +); +field_offset!( + ENGINE_INLINE_OBJECT_MATERIAL_ID, + EngineInlineObjectRecord, + material_id +); +field_offset!( + ENGINE_INLINE_OBJECT_RESOURCE_ID, + EngineInlineObjectRecord, + resource_id +); +field_offset!( + ENGINE_INLINE_OBJECT_RESOURCE_GENERATION, + EngineInlineObjectRecord, + resource_generation +); +field_offset!( + ENGINE_INLINE_OBJECT_INLINE_EXTENT, + EngineInlineObjectRecord, + inline_extent +); +field_offset!( + ENGINE_INLINE_OBJECT_BLOCK_EXTENT, + EngineInlineObjectRecord, + block_extent +); +field_offset!( + ENGINE_INLINE_OBJECT_BASELINE_OFFSET, + EngineInlineObjectRecord, + baseline_offset +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_INLINE_START, + EngineInlineObjectRecord, + margin_inline_start +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_INLINE_END, + EngineInlineObjectRecord, + margin_inline_end +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_BLOCK_START, + EngineInlineObjectRecord, + margin_block_start +); +field_offset!( + ENGINE_INLINE_OBJECT_MARGIN_BLOCK_END, + EngineInlineObjectRecord, + margin_block_end +); +field_offset!( + ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT, + EngineInlineObjectRecord, + baseline_alignment +); +field_offset!(ENGINE_INLINE_OBJECT_FLAGS, EngineInlineObjectRecord, flags); +field_offset!( + ENGINE_INLINE_OBJECT_RESERVED0, + EngineInlineObjectRecord, + reserved0 +); field_offset!(ENGINE_RESULT_ABI_VERSION, EngineResultHeader, abi_version); field_offset!(ENGINE_RESULT_BYTE_LENGTH, EngineResultHeader, byte_length); field_offset!(ENGINE_RESULT_STATUS, EngineResultHeader, status); @@ -1210,6 +1787,137 @@ pub fn json() -> String { "policyParametersOffset": ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, "policyParametersLength": ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH }, + "engineTextMutation": { + "size": ENGINE_TEXT_MUTATION_RECORD_SIZE, + "alignment": ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, + "opcode": ENGINE_TEXT_MUTATION_OPCODE, + "encoding": ENGINE_TEXT_MUTATION_ENCODING, + "reserved0": ENGINE_TEXT_MUTATION_RESERVED0, + "textStart": ENGINE_TEXT_MUTATION_TEXT_START, + "deleteCount": ENGINE_TEXT_MUTATION_DELETE_COUNT, + "insertOffset": ENGINE_TEXT_MUTATION_INSERT_OFFSET, + "insertCount": ENGINE_TEXT_MUTATION_INSERT_COUNT, + "reserved1": ENGINE_TEXT_MUTATION_RESERVED1 + }, + "engineStyleMutation": { + "size": ENGINE_STYLE_MUTATION_RECORD_SIZE, + "alignment": ENGINE_STYLE_MUTATION_RECORD_ALIGNMENT, + "opcode": ENGINE_STYLE_MUTATION_OPCODE, + "direction": ENGINE_STYLE_MUTATION_DIRECTION, + "decorationStyle": ENGINE_STYLE_MUTATION_DECORATION_STYLE, + "flags": ENGINE_STYLE_MUTATION_FLAGS, + "styleId": ENGINE_STYLE_MUTATION_STYLE_ID, + "fieldMask": ENGINE_STYLE_MUTATION_FIELD_MASK, + "textStart": ENGINE_STYLE_MUTATION_TEXT_START, + "textEnd": ENGINE_STYLE_MUTATION_TEXT_END, + "fontStackHandle": ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + "materialId": ENGINE_STYLE_MUTATION_MATERIAL_ID, + "languageOffset": ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + "languageLength": ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + "featureCount": ENGINE_STYLE_MUTATION_FEATURE_COUNT, + "featuresOffset": ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + "fontSize": ENGINE_STYLE_MUTATION_FONT_SIZE, + "lineHeight": ENGINE_STYLE_MUTATION_LINE_HEIGHT, + "letterSpacing": ENGINE_STYLE_MUTATION_LETTER_SPACING, + "wordSpacing": ENGINE_STYLE_MUTATION_WORD_SPACING, + "baselineShift": ENGINE_STYLE_MUTATION_BASELINE_SHIFT, + "foregroundRgba": ENGINE_STYLE_MUTATION_FOREGROUND_RGBA, + "decorationRgba": ENGINE_STYLE_MUTATION_DECORATION_RGBA, + "decorationFlags": ENGINE_STYLE_MUTATION_DECORATION_FLAGS, + "decorationThickness": ENGINE_STYLE_MUTATION_DECORATION_THICKNESS, + "decorationOffset": ENGINE_STYLE_MUTATION_DECORATION_OFFSET + }, + "engineConstraint": { + "size": ENGINE_CONSTRAINT_RECORD_SIZE, + "alignment": ENGINE_CONSTRAINT_RECORD_ALIGNMENT, + "flowThreadId": ENGINE_CONSTRAINT_FLOW_THREAD_ID, + "geometryRevision": ENGINE_CONSTRAINT_GEOMETRY_REVISION, + "width": ENGINE_CONSTRAINT_WIDTH, + "height": ENGINE_CONSTRAINT_HEIGHT, + "viewportBlockStart": ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START, + "viewportBlockEnd": ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END, + "resumeBlockOffset": ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET, + "maxLines": ENGINE_CONSTRAINT_MAX_LINES, + "regionStart": ENGINE_CONSTRAINT_REGION_START, + "resumeCluster": ENGINE_CONSTRAINT_RESUME_CLUSTER, + "regionCount": ENGINE_CONSTRAINT_REGION_COUNT, + "resumeRegion": ENGINE_CONSTRAINT_RESUME_REGION, + "widthMode": ENGINE_CONSTRAINT_WIDTH_MODE, + "heightMode": ENGINE_CONSTRAINT_HEIGHT_MODE, + "wrap": ENGINE_CONSTRAINT_WRAP, + "align": ENGINE_CONSTRAINT_ALIGN, + "overflow": ENGINE_CONSTRAINT_OVERFLOW, + "blockAlign": ENGINE_CONSTRAINT_BLOCK_ALIGN, + "flags": ENGINE_CONSTRAINT_FLAGS + }, + "engineFlowVertex": { + "size": ENGINE_FLOW_VERTEX_RECORD_SIZE, + "alignment": ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + "inline": ENGINE_FLOW_VERTEX_INLINE, + "block": ENGINE_FLOW_VERTEX_BLOCK + }, + "engineRegion": { + "size": ENGINE_REGION_RECORD_SIZE, + "alignment": ENGINE_REGION_RECORD_ALIGNMENT, + "id": ENGINE_REGION_ID, + "geometryRevision": ENGINE_REGION_GEOMETRY_REVISION, + "verticesOffset": ENGINE_REGION_VERTICES_OFFSET, + "vertexCount": ENGINE_REGION_VERTEX_COUNT, + "exclusionStart": ENGINE_REGION_EXCLUSION_START, + "exclusionCount": ENGINE_REGION_EXCLUSION_COUNT, + "flags": ENGINE_REGION_FLAGS, + "shape": ENGINE_REGION_SHAPE, + "writingMode": ENGINE_REGION_WRITING_MODE, + "textOrientation": ENGINE_REGION_TEXT_ORIENTATION, + "reserved0": ENGINE_REGION_RESERVED0, + "inlineStart": ENGINE_REGION_INLINE_START, + "blockStart": ENGINE_REGION_BLOCK_START, + "inlineEnd": ENGINE_REGION_INLINE_END, + "blockEnd": ENGINE_REGION_BLOCK_END, + "clipInlineStart": ENGINE_REGION_CLIP_INLINE_START, + "clipBlockStart": ENGINE_REGION_CLIP_BLOCK_START, + "clipInlineEnd": ENGINE_REGION_CLIP_INLINE_END, + "clipBlockEnd": ENGINE_REGION_CLIP_BLOCK_END + }, + "engineExclusion": { + "size": ENGINE_EXCLUSION_RECORD_SIZE, + "alignment": ENGINE_EXCLUSION_RECORD_ALIGNMENT, + "id": ENGINE_EXCLUSION_ID, + "regionId": ENGINE_EXCLUSION_REGION_ID, + "geometryRevision": ENGINE_EXCLUSION_GEOMETRY_REVISION, + "verticesOffset": ENGINE_EXCLUSION_VERTICES_OFFSET, + "vertexCount": ENGINE_EXCLUSION_VERTEX_COUNT, + "flags": ENGINE_EXCLUSION_FLAGS, + "shape": ENGINE_EXCLUSION_SHAPE, + "wrapSide": ENGINE_EXCLUSION_WRAP_SIDE, + "reserved0": ENGINE_EXCLUSION_RESERVED0, + "inlineStart": ENGINE_EXCLUSION_INLINE_START, + "blockStart": ENGINE_EXCLUSION_BLOCK_START, + "inlineEnd": ENGINE_EXCLUSION_INLINE_END, + "blockEnd": ENGINE_EXCLUSION_BLOCK_END, + "marginInline": ENGINE_EXCLUSION_MARGIN_INLINE, + "marginBlock": ENGINE_EXCLUSION_MARGIN_BLOCK + }, + "engineInlineObject": { + "size": ENGINE_INLINE_OBJECT_RECORD_SIZE, + "alignment": ENGINE_INLINE_OBJECT_RECORD_ALIGNMENT, + "id": ENGINE_INLINE_OBJECT_ID, + "contentRevision": ENGINE_INLINE_OBJECT_CONTENT_REVISION, + "textOffset": ENGINE_INLINE_OBJECT_TEXT_OFFSET, + "materialId": ENGINE_INLINE_OBJECT_MATERIAL_ID, + "resourceId": ENGINE_INLINE_OBJECT_RESOURCE_ID, + "resourceGeneration": ENGINE_INLINE_OBJECT_RESOURCE_GENERATION, + "inlineExtent": ENGINE_INLINE_OBJECT_INLINE_EXTENT, + "blockExtent": ENGINE_INLINE_OBJECT_BLOCK_EXTENT, + "baselineOffset": ENGINE_INLINE_OBJECT_BASELINE_OFFSET, + "marginInlineStart": ENGINE_INLINE_OBJECT_MARGIN_INLINE_START, + "marginInlineEnd": ENGINE_INLINE_OBJECT_MARGIN_INLINE_END, + "marginBlockStart": ENGINE_INLINE_OBJECT_MARGIN_BLOCK_START, + "marginBlockEnd": ENGINE_INLINE_OBJECT_MARGIN_BLOCK_END, + "baselineAlignment": ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT, + "flags": ENGINE_INLINE_OBJECT_FLAGS, + "reserved0": ENGINE_INLINE_OBJECT_RESERVED0 + }, "engineResult": { "size": ENGINE_RESULT_HEADER_SIZE, "alignment": ENGINE_RESULT_HEADER_ALIGNMENT, @@ -1497,6 +2205,22 @@ pub fn json() -> String { } }, "engine": { + "textMutationOpcodes": { + "replaceUtf16": TEXT_MUTATION_REPLACE_UTF16 + }, + "styleMutationOpcodes": { + "upsert": STYLE_MUTATION_UPSERT, + "remove": STYLE_MUTATION_REMOVE + }, + "flowShapeKinds": { + "rectangle": SHAPE_RECTANGLE, + "polygon": SHAPE_POLYGON + }, + "writingModes": { + "horizontalTb": WRITING_HORIZONTAL_TB, + "verticalRl": WRITING_VERTICAL_RL, + "verticalLr": WRITING_VERTICAL_LR + }, "resultFlags": { "checkpoint": RESULT_FLAG_CHECKPOINT }, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index a9ff689d..e18b2890 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -1,5 +1,14 @@ pub(crate) const RESULT_FLAG_CHECKPOINT: u32 = 1; +pub(crate) const TEXT_MUTATION_REPLACE_UTF16: u8 = 1; +pub(crate) const STYLE_MUTATION_UPSERT: u8 = 1; +pub(crate) const STYLE_MUTATION_REMOVE: u8 = 2; +pub(crate) const SHAPE_RECTANGLE: u8 = 1; +pub(crate) const SHAPE_POLYGON: u8 = 2; +pub(crate) const WRITING_HORIZONTAL_TB: u8 = 1; +pub(crate) const WRITING_VERTICAL_RL: u8 = 2; +pub(crate) const WRITING_VERTICAL_LR: u8 = 3; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct UpdateRequest { pub session_id: u32, diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 613748a0..827b6246 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -38,6 +38,10 @@ export const textShaperAbi = { "orderedDirect": 1, "stableIndirect": 2 }, + "flowShapeKinds": { + "polygon": 2, + "rectangle": 1 + }, "internalBufferBindings": { "order": 65535 }, @@ -77,6 +81,18 @@ export const textShaperAbi = { "line": 1, "run": 3, "selection": 6 + }, + "styleMutationOpcodes": { + "remove": 2, + "upsert": 1 + }, + "textMutationOpcodes": { + "replaceUtf16": 1 + }, + "writingModes": { + "horizontalTb": 1, + "verticalLr": 3, + "verticalRl": 2 } }, "functions": { @@ -139,6 +155,29 @@ export const textShaperAbi = { "strategy": 16, "vectorWidth": 15 }, + "engineConstraint": { + "align": 47, + "alignment": 4, + "blockAlign": 49, + "flags": 50, + "flowThreadId": 0, + "geometryRevision": 4, + "height": 12, + "heightMode": 45, + "maxLines": 28, + "overflow": 48, + "regionCount": 40, + "regionStart": 32, + "resumeBlockOffset": 24, + "resumeCluster": 36, + "resumeRegion": 42, + "size": 52, + "viewportBlockEnd": 20, + "viewportBlockStart": 16, + "width": 8, + "widthMode": 44, + "wrap": 46 + }, "engineDiagnostic": { "alignment": 4, "code": 0, @@ -171,6 +210,51 @@ export const textShaperAbi = { "resourceStart": 40, "size": 60 }, + "engineExclusion": { + "alignment": 4, + "blockEnd": 36, + "blockStart": 28, + "flags": 18, + "geometryRevision": 8, + "id": 0, + "inlineEnd": 32, + "inlineStart": 24, + "marginBlock": 44, + "marginInline": 40, + "regionId": 4, + "reserved0": 22, + "shape": 20, + "size": 48, + "vertexCount": 16, + "verticesOffset": 12, + "wrapSide": 21 + }, + "engineFlowVertex": { + "alignment": 4, + "block": 4, + "inline": 0, + "size": 8 + }, + "engineInlineObject": { + "alignment": 4, + "baselineAlignment": 52, + "baselineOffset": 32, + "blockExtent": 28, + "contentRevision": 4, + "flags": 53, + "id": 0, + "inlineExtent": 24, + "marginBlockEnd": 48, + "marginBlockStart": 44, + "marginInlineEnd": 40, + "marginInlineStart": 36, + "materialId": 12, + "reserved0": 54, + "resourceGeneration": 20, + "resourceId": 16, + "size": 56, + "textOffset": 8 + }, "enginePatch": { "alignment": 4, "bufferGeneration": 8, @@ -207,6 +291,29 @@ export const textShaperAbi = { "size": 64, "techniqueId": 8 }, + "engineRegion": { + "alignment": 4, + "blockEnd": 36, + "blockStart": 28, + "clipBlockEnd": 52, + "clipBlockStart": 44, + "clipInlineEnd": 48, + "clipInlineStart": 40, + "exclusionCount": 16, + "exclusionStart": 14, + "flags": 18, + "geometryRevision": 4, + "id": 0, + "inlineEnd": 32, + "inlineStart": 24, + "reserved0": 23, + "shape": 20, + "size": 56, + "textOrientation": 22, + "vertexCount": 12, + "verticesOffset": 8, + "writingMode": 21 + }, "engineResource": { "action": 14, "alignment": 4, @@ -287,6 +394,46 @@ export const textShaperAbi = { "textEnd": 16, "textStart": 12 }, + "engineStyleMutation": { + "alignment": 4, + "baselineShift": 56, + "decorationFlags": 68, + "decorationOffset": 76, + "decorationRgba": 64, + "decorationStyle": 2, + "decorationThickness": 72, + "direction": 1, + "featureCount": 34, + "featuresOffset": 36, + "fieldMask": 8, + "flags": 3, + "fontSize": 40, + "fontStackHandle": 20, + "foregroundRgba": 60, + "languageLength": 32, + "languageOffset": 28, + "letterSpacing": 48, + "lineHeight": 44, + "materialId": 24, + "opcode": 0, + "size": 80, + "styleId": 4, + "textEnd": 16, + "textStart": 12, + "wordSpacing": 52 + }, + "engineTextMutation": { + "alignment": 4, + "deleteCount": 8, + "encoding": 1, + "insertCount": 16, + "insertOffset": 12, + "opcode": 0, + "reserved0": 2, + "reserved1": 20, + "size": 24, + "textStart": 4 + }, "engineUpdateRequest": { "abiVersion": 0, "acknowledgedPublicationGeneration": 20, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index d0d722a4..d67498fc 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -31,6 +31,25 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.equal(abi.layouts.engineBuffer.size, 36); assert.equal(abi.layouts.enginePatch.size, 36); assert.equal(abi.layouts.enginePrimitive.size, 64); + assert.deepEqual( + [ + abi.layouts.engineTextMutation.size, + abi.layouts.engineStyleMutation.size, + abi.layouts.engineConstraint.size, + abi.layouts.engineFlowVertex.size, + abi.layouts.engineRegion.size, + abi.layouts.engineExclusion.size, + abi.layouts.engineInlineObject.size, + ], + [24, 80, 52, 8, 56, 48, 56], + ); + assert.equal(abi.layouts.engineInlineObject.alignment, 4); + assert.equal(abi.layouts.engineInlineObject.baselineAlignment, 52); + assert.equal(abi.engine.textMutationOpcodes.replaceUtf16, 1); + assert.equal(abi.engine.styleMutationOpcodes.upsert, 1); + assert.equal(abi.engine.styleMutationOpcodes.remove, 2); + assert.deepEqual(abi.engine.flowShapeKinds, { polygon: 2, rectangle: 1 }); + assert.deepEqual(abi.engine.writingModes, { horizontalTb: 1, verticalLr: 3, verticalRl: 2 }); assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size), abi.status.ok); assert.equal(fn.sessionCount(), 1); let requestPointer = fn.requestPointer(sessionId); From 7e16d0b0c448c788859c54e9128bb9bb86baf49d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 11:04:44 -0400 Subject: [PATCH 018/128] feat(text): retain frame text mutations in Rust --- docs/log.md | 12 + docs/packages/text.md | 24 +- docs/planning/decision-register.md | 2 + docs/planning/rust-layout-engine.md | 32 +- packages/text/rust/shaper/src/abi_contract.rs | 10 +- packages/text/rust/shaper/src/engine/frame.rs | 5 +- .../text/rust/shaper/src/engine/frame_wire.rs | 35 ++- packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/semantic_wire.rs | 175 +++++++++++ packages/text/rust/shaper/src/engine/state.rs | 290 ++++++++++++++++-- packages/text/rust/shaper/src/wasm.rs | 22 +- .../text/src/generated/text-shaper-abi.ts | 4 + .../render-plan-frame-abi.test.mjs | 72 ++++- packages/text/tests/support/engine-abi.d.mts | 5 + packages/text/tests/support/engine-abi.mjs | 37 ++- 15 files changed, 662 insertions(+), 64 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/semantic_wire.rs diff --git a/docs/log.md b/docs/log.md index 0075e394..a5fac924 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,18 @@ ## 2026-08-08 +- **Retained ordered UTF-16 edits transactionally inside Rust sessions** — The frame decoder now borrows and validates + replacement records/payloads without allocating mutation objects. Sessions apply sequential edits to retained scratch + and swap only on commit; abort or an invalid later replacement preserves committed text. Compiled Wasm proves cold + reserve/re-pin, retained follow-up edit, invalid rollback, A/B preservation, and no same-capacity memory growth. Styles, + shaping, layout, and nonempty plans remain open, so this adds no end-to-end timing claim. The reachable slice adds + 2,829 / 1,528 / 616 raw/gzip/Brotli bytes. + +- **Prewarmed retained text capacity without multiplying shaping scratch per session** — Session creation now reserves + both UTF-16 transaction buffers to 1,024 units by default, while cold create/reserve accepts an explicit text capacity. + The production 32,768-record analysis/shaping/layout workspace is fixed as one engine-global synchronous allocation + when those arrays land, covering the 25,515-glyph target without assigning that footprint to every paragraph. + - **Fixed the semantic update record grammar in the compiler-derived ABI** — Added exact UTF-16 text replacement, stable style, constraint, flow-vertex, region, exclusion, and inline-object layouts. Rectangle and bounded-polygon geometry resolve inside the same request; style records carry shaping, spacing, material/color, and decoration data. diff --git a/docs/packages/text.md b/docs/packages/text.md index c05abd2e..a95af677 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:1f44b1ea6bb9e562a7a941e0f8a5cc3e224ba6273230a4c0afdcdd3dc958a1c4' +source_digest: 'sha256:1bc7cd639c991441bb0dae6ed543b101bbf472ca6221ac80a5e5944c0b709eee' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -482,7 +482,8 @@ abort/retry, capability-set changes, and policy identity. A post-prepare Wasm ab semantic input exists, so that exact ordering remains an explicit test gap. The now-reachable planners increase the optimized artifact from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. This is a measured shared-runtime cost and a pending optimization -target. Nonempty mutation sections remain rejected, so sessions currently publish an empty Rust plan; there is no Rust +target. Ordered UTF-16 replacements are now retained transactionally, while style and geometry sections remain rejected. +Sessions still publish an empty Rust plan because retained text is not yet shaped or laid out; there is no Rust shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. The semantic request now has compiler-derived record layouts without a handwritten TypeScript mirror: 24-byte UTF-16 @@ -490,8 +491,23 @@ text replacements, 80-byte stable style mutations, 52-byte constraints, 8-byte f exclusions, and 56-byte inline objects. Region/exclusion rectangles use inline bounds, while bounded polygons reference vertices inside the same request. Styles include current shaping fields plus word spacing, material/color, and decoration inputs. The generated ABI and compiled-Wasm test pin every size, tag, and the inline-object -`baselineAlignment` offset; the semantic decoder still rejects nonempty sections, so this checkpoint changes no runtime -layout behavior and has no performance result. +`baselineAlignment` offset. The following text-retention checkpoint admits only the text record; the remaining sections +still fail closed. + +The frame decoder now borrows ordered UTF-16 replacement records and their offset-addressed payloads directly from the +pinned request. It validates canonical empty offsets, opcode/encoding, reserved fields, bounds, alignment, arithmetic, +and record/payload non-overlap before the session transaction. Rust applies sequential replacements into retained +scratch, swaps them into committed text only after plan commit, and clears scratch on abort or a malformed later edit. +The compiled-Wasm test performs a cold reserve/re-pin, inserts text, edits a position that is valid only if the first +update was retained, rejects an out-of-bounds edit without changing the active A/B publication, and observes no memory +growth on the same-capacity edit. This is real Rust text retention, not shaping or layout; plan tables remain empty and no +latency result is claimed. Reachability changes optimized Wasm from 822,469 / 306,502 / 242,707 to +825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616 shared-runtime bytes). + +Session creation also prewarms both retained UTF-16 buffers to 1,024 units by default, and the cold create/reserve ABI +accepts an explicit text capacity for known large paragraphs. This removes the observed second-buffer lazy allocation +without giving every text object the 25K-glyph benchmark footprint. Production analysis/shaping/layout scratch will be +one synchronous engine-global 32,768-record workspace, reserved once when those arrays land and shared by every session. The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index fe2dc8c5..af313e32 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -237,6 +237,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | | D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | | D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | +| D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | +| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; when its production arrays land it prewarms once to 32,768 clusters/glyphs, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index b63e7d7c..729d39c9 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -298,7 +298,12 @@ JavaScript cannot write an oversized request before calling the function that wo therefore has an explicit cold lifecycle operation: ```text -text_reserve(session_id: u32, request_capacity: u32, result_capacity: u32) -> u32 +text_reserve( + session_id: u32, + request_capacity: u32, + result_capacity: u32, + text_capacity: u32 +) -> u32 ``` The host computes the exact encoded request length before pinning. It calls `text_reserve` only when that length exceeds @@ -326,6 +331,13 @@ excess buffers become unreachable and are collected on the worker. Failure to re not permission to grow an unbounded pool. GPU staging belts and submission fences remain backend responsibilities. [^staging][^worker-transfer] +Cold capacity separates per-session retained state from shared synchronous work. Session creation prewarms both UTF-16 +transaction buffers to 1,024 units unless the caller supplies a larger text capacity; cold reserve can grow both before +the request view is pinned. The analysis/shaping/layout arrays are one engine-global workspace reused by synchronous +updates, not one 25K-glyph allocation per paragraph. Those production arrays prewarm once to 32,768 clusters/glyphs when +they land, covering the 25,515-glyph target fixture, and expose explicit cold growth beyond that envelope. A warm update +inside declared capacities may not lazily settle another allocation. + ## Rust layout pipeline Each update follows one dependency graph inside Rust: @@ -757,15 +769,21 @@ The Wasm update prepares the Rust plan, validates and serializes it into the ina state only after staging succeeds, and aborts preparation on every intervening failure. The acknowledgment itself survives an aborted publication because it reports an already-completed renderer fence. Compiled-Wasm tests exercise accepted and future acknowledgments plus A/B preservation. Host tests exercise compiler abort/retry directly. A -post-prepare Wasm failure is not constructible while semantic input is empty and the encoded plan is the minimum-size -header; that ABI ordering requires a regression test once nonempty Rust semantic input can exceed a request limit. +post-prepare Wasm failure is not constructible while the encoded plan is the minimum-size header; that ABI ordering +requires a regression test once Rust shaping/layout can produce nonempty plan output that exceeds a request limit. This makes the full retained plan compiler reachable: the optimized artifact changes from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. The 82,534 raw / 35,409 gzip / 28,052 Brotli increase is shared -runtime code, not font-local shaping data, and is now an explicit size-optimization target. Mutation sections still reject -nonempty data and the session currently supplies an empty semantic input, so the Wasm path emits an empty Rust plan. -Rust shaping/layout → nonempty plan connection and its 25,515-glyph end-to-end timing remain open; the TypeScript layout -benchmark is baseline evidence only. +runtime code, not font-local shaping data, and is now an explicit size-optimization target. Ordered UTF-16 text +replacements now decode as borrowed records and commit into retained session scratch transactionally; style and geometry +sections remain rejected. Because retained text is not yet analyzed, shaped, or laid out, the Wasm path still emits an +empty Rust plan. Rust shaping/layout → nonempty plan connection and its 25,515-glyph end-to-end timing remain open; the +TypeScript layout benchmark is baseline evidence only. + +The retained-text decoder, transaction buffers, and cold capacity control change the optimized artifact from +822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes, a shared-runtime delta of +2,829 / 1,528 / 616 bytes. The per-session 1,024-unit default is runtime memory rather than binary payload. This size +checkpoint does not time shaping or layout because neither stage consumes the retained text yet. ## Performance contract diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index e44ca129..d9afde07 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -3,9 +3,9 @@ use core::mem::{align_of, offset_of, size_of}; use serde_json::json; use crate::engine::frame::{ - RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_MUTATION_REMOVE, - STYLE_MUTATION_UPSERT, TEXT_MUTATION_REPLACE_UTF16, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, - WRITING_VERTICAL_RL, + DEFAULT_SESSION_TEXT_CAPACITY, RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + TEXT_MUTATION_REPLACE_UTF16, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; use crate::engine::policy::{ ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, @@ -2205,9 +2205,13 @@ pub fn json() -> String { } }, "engine": { + "defaultSessionTextCapacity": DEFAULT_SESSION_TEXT_CAPACITY, "textMutationOpcodes": { "replaceUtf16": TEXT_MUTATION_REPLACE_UTF16 }, + "textEncodings": { + "utf16Le": TEXT_ENCODING_UTF16_LE + }, "styleMutationOpcodes": { "upsert": STYLE_MUTATION_UPSERT, "remove": STYLE_MUTATION_REMOVE diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index e18b2890..d17f30b7 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -1,6 +1,7 @@ pub(crate) const RESULT_FLAG_CHECKPOINT: u32 = 1; pub(crate) const TEXT_MUTATION_REPLACE_UTF16: u8 = 1; +pub(crate) const TEXT_ENCODING_UTF16_LE: u8 = 1; pub(crate) const STYLE_MUTATION_UPSERT: u8 = 1; pub(crate) const STYLE_MUTATION_REMOVE: u8 = 2; pub(crate) const SHAPE_RECTANGLE: u8 = 1; @@ -8,9 +9,10 @@ pub(crate) const SHAPE_POLYGON: u8 = 2; pub(crate) const WRITING_HORIZONTAL_TB: u8 = 1; pub(crate) const WRITING_VERTICAL_RL: u8 = 2; pub(crate) const WRITING_VERTICAL_LR: u8 = 3; +pub(crate) const DEFAULT_SESSION_TEXT_CAPACITY: u32 = 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) struct UpdateRequest { +pub(crate) struct UpdateRequest<'a> { pub session_id: u32, pub expected_engine_revision: u32, pub consumed_plan_revision: u32, @@ -18,6 +20,7 @@ pub(crate) struct UpdateRequest { pub policy_handle: u32, pub capability_set: u32, pub limits: UpdateLimits, + pub text_mutations: super::semantic_wire::TextMutationBatch<'a>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 68463868..084e2299 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -1,8 +1,8 @@ //! Direct-memory decoding for the retained engine update transaction. //! //! The compiler-derived offsets in `abi_contract` remain the sole layout authority. Stage 1 -//! accepts the complete fixed header but deliberately rejects nonempty mutation sections until -//! their Rust semantic consumers land; no request can silently fall back to TypeScript logic. +//! admits only semantic sections whose Rust consumer has landed; no request can silently fall +//! back to TypeScript logic. use crate::{ STATUS_INVALID_REQUEST, @@ -24,13 +24,19 @@ use crate::{ ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, ENGINE_UPDATE_TEXT_MUTATION_COUNT, ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, }, - engine::frame::{UpdateLimits, UpdateRequest}, + engine::{ + frame::{UpdateLimits, UpdateRequest}, + semantic_wire::parse_text_mutations, + }, wire::read_u32, }; const MAX_DECLARED_OUTPUT_BYTES: u32 = 64 * 1024 * 1024; -pub(crate) fn parse_update_request(bytes: &[u8], session_id: u32) -> Result { +pub(crate) fn parse_update_request( + bytes: &[u8], + session_id: u32, +) -> Result, u32> { if bytes.len() < ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize || read_u32(bytes, ENGINE_UPDATE_ABI_VERSION)? != ABI_VERSION || read_u32(bytes, ENGINE_UPDATE_SESSION_ID)? != session_id @@ -43,10 +49,6 @@ pub(crate) fn parse_update_request(bytes: &[u8], session_id: u32) -> Result Result Result limits.max_clusters { + return Err(STATUS_INVALID_REQUEST); + } + let text_mutations = parse_text_mutations( + bytes, + read_u32(bytes, ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET)?, + text_mutation_count, + )?; + if text_mutation_count == 0 && bytes.len() != ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize { + return Err(STATUS_INVALID_REQUEST); + } Ok(UpdateRequest { session_id, expected_engine_revision: read_u32(bytes, ENGINE_UPDATE_EXPECTED_ENGINE_REVISION)?, @@ -103,6 +113,7 @@ pub(crate) fn parse_update_request(bytes: &[u8], session_id: u32) -> Result { + request: &'a [u8], + records: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TextMutation<'a> { + pub text_start: u32, + pub delete_count: u32, + pub insert_utf16_le: &'a [u8], +} + +impl<'a> TextMutationBatch<'a> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + records: &[], + } + } + + pub(crate) fn len(self) -> usize { + self.records.len() / ENGINE_TEXT_MUTATION_RECORD_SIZE as usize + } + + pub(crate) fn get(self, index: usize) -> Option> { + let stride = ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let start = index.checked_mul(stride)?; + let record = self.records.get(start..start.checked_add(stride)?)?; + let insert_count = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT).ok()?; + let insert_utf16_le = if insert_count == 0 { + &[] + } else { + array( + self.request, + read_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET).ok()?, + insert_count, + 2, + 2, + ) + .ok()? + }; + Some(TextMutation { + text_start: read_u32(record, ENGINE_TEXT_MUTATION_TEXT_START).ok()?, + delete_count: read_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT).ok()?, + insert_utf16_le, + }) + } +} + +pub(crate) fn parse_text_mutations( + request: &[u8], + offset: u32, + count: u32, +) -> Result, u32> { + if count == 0 { + return if offset == 0 { + Ok(TextMutationBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < ENGINE_UPDATE_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + let records = array( + request, + offset, + count, + ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, + )?; + let record_start = offset as usize; + let record_end = record_start + .checked_add(records.len()) + .ok_or(STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) { + if record[ENGINE_TEXT_MUTATION_OPCODE] != TEXT_MUTATION_REPLACE_UTF16 + || record[ENGINE_TEXT_MUTATION_ENCODING] != TEXT_ENCODING_UTF16_LE + || read_u16(record, ENGINE_TEXT_MUTATION_RESERVED0)? != 0 + || read_u32(record, ENGINE_TEXT_MUTATION_RESERVED1)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let insert_count = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT)?; + let insert_offset = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET)?; + if insert_count == 0 { + if insert_offset != 0 { + return Err(STATUS_INVALID_REQUEST); + } + continue; + } + let payload = array(request, insert_offset, insert_count, 2, 2)?; + let payload_start = insert_offset as usize; + let payload_end = payload_start + .checked_add(payload.len()) + .ok_or(STATUS_INVALID_REQUEST)?; + if payload_start < record_end && record_start < payload_end { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(TextMutationBatch { request, records }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + abi_contract::{ + ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, + ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, + ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_TEXT_START, + }, + wire::write_u32, + }; + use alloc::vec; + + #[test] + fn validates_and_borrows_utf16_replacements_without_decoding_objects() { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE; + let payload_offset = record_offset + ENGINE_TEXT_MUTATION_RECORD_SIZE; + let mut bytes = vec![0; payload_offset as usize + 4]; + let record = &mut bytes[record_offset as usize..payload_offset as usize]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, 2); + write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, 1); + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET, payload_offset); + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT, 2); + bytes[payload_offset as usize..payload_offset as usize + 2] + .copy_from_slice(&0x0061_u16.to_le_bytes()); + bytes[payload_offset as usize + 2..payload_offset as usize + 4] + .copy_from_slice(&0xd83d_u16.to_le_bytes()); + + let batch = parse_text_mutations(&bytes, record_offset, 1).unwrap(); + assert_eq!(batch.len(), 1); + assert_eq!( + batch.get(0), + Some(TextMutation { + text_start: 2, + delete_count: 1, + insert_utf16_le: &[0x61, 0x00, 0x3d, 0xd8], + }) + ); + } + + #[test] + fn rejects_noncanonical_empty_and_overlapping_payloads() { + assert!(parse_text_mutations(&[], 4, 0).is_err()); + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE; + let mut bytes = vec![0; record_offset as usize + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize]; + let record = &mut bytes[record_offset as usize..]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET, record_offset); + write_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT, 1); + assert!(parse_text_mutations(&bytes, record_offset, 1).is_err()); + } +} diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 6d3925ba..1e12d10f 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1,4 +1,4 @@ -use alloc::collections::BTreeMap; +use alloc::{collections::BTreeMap, vec::Vec}; use super::{ frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, @@ -33,6 +33,9 @@ struct EngineSession { acknowledged_publication_generation: u32, policy_binding: Option, plan: RenderPlanCompiler, + text: Vec, + pending_text: Vec, + text_prepared: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -94,6 +97,17 @@ impl TextEngine { .ok_or(EngineError::SessionMissing) } + pub fn reserve_session_text(&mut self, handle: u32, capacity: u32) -> Result<(), EngineError> { + let capacity = usize::try_from(capacity).map_err(|_| EngineError::ResultTooLarge)?; + let session = self + .sessions + .get_mut(&handle) + .ok_or(EngineError::SessionMissing)?; + reserve_text_buffer(&mut session.text, capacity)?; + reserve_text_buffer(&mut session.pending_text, capacity)?; + Ok(()) + } + pub(crate) fn session_revision(&self, handle: u32) -> Result { self.sessions .get(&handle) @@ -101,13 +115,21 @@ impl TextEngine { .ok_or(EngineError::SessionMissing) } + #[cfg(test)] + pub(crate) fn session_text(&self, handle: u32) -> Result<&[u16], EngineError> { + self.sessions + .get(&handle) + .map(|session| session.text.as_slice()) + .ok_or(EngineError::SessionMissing) + } + pub fn session_count(&self) -> u32 { self.sessions.len().try_into().unwrap_or(u32::MAX) } pub(crate) fn prepare_update( &mut self, - request: UpdateRequest, + request: UpdateRequest<'_>, publication_generation: u32, ) -> Result { if !request.limits.all_nonzero() { @@ -159,21 +181,22 @@ impl TextEngine { // A completed renderer fence is external monotonic state. It remains accepted even if // plan preparation or publication later aborts. session.acknowledged_publication_generation = request.acknowledged_publication_generation; - session - .plan - .prepare( - policy, - CapabilitySetId(request.capability_set), - PlanInput { - glyphs: &[], - f32_fields: &[], - u32_fields: &[], - }, - checkpoint, - publication_generation, - request.acknowledged_publication_generation, - ) - .map_err(plan_error)?; + session.prepare_text(request.text_mutations)?; + if let Err(error) = session.plan.prepare( + policy, + CapabilitySetId(request.capability_set), + PlanInput { + glyphs: &[], + f32_fields: &[], + u32_fields: &[], + }, + checkpoint, + publication_generation, + request.acknowledged_publication_generation, + ) { + session.abort_text(); + return Err(plan_error(error)); + } Ok(PreparedUpdate { session_id: request.session_id, previous: session.revision, @@ -216,6 +239,7 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.plan.abort(); + session.abort_text(); Ok(()) } @@ -231,6 +255,7 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.plan.commit().map_err(plan_error)?; + session.commit_text(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, @@ -245,6 +270,103 @@ impl TextEngine { } } +impl EngineSession { + fn prepare_text( + &mut self, + mutations: super::semantic_wire::TextMutationBatch<'_>, + ) -> Result<(), EngineError> { + self.abort_text(); + if mutations.len() == 0 { + return Ok(()); + } + if self.pending_text.try_reserve(self.text.len()).is_err() { + return Err(EngineError::ResultTooLarge); + } + self.pending_text.extend_from_slice(&self.text); + for index in 0..mutations.len() { + let Some(mutation) = mutations.get(index) else { + self.abort_text(); + return Err(EngineError::InvalidRequest); + }; + if let Err(error) = apply_text_mutation(&mut self.pending_text, mutation) { + self.abort_text(); + return Err(match error { + TextMutationError::Invalid => EngineError::InvalidRequest, + TextMutationError::Allocation => EngineError::ResultTooLarge, + }); + } + } + self.text_prepared = true; + Ok(()) + } + + fn abort_text(&mut self) { + self.pending_text.clear(); + self.text_prepared = false; + } + + fn commit_text(&mut self) { + if self.text_prepared { + core::mem::swap(&mut self.text, &mut self.pending_text); + } + self.abort_text(); + } +} + +fn apply_text_mutation( + text: &mut Vec, + mutation: super::semantic_wire::TextMutation<'_>, +) -> Result<(), TextMutationError> { + let start = usize::try_from(mutation.text_start).map_err(|_| TextMutationError::Invalid)?; + let delete_count = + usize::try_from(mutation.delete_count).map_err(|_| TextMutationError::Invalid)?; + let delete_end = start + .checked_add(delete_count) + .ok_or(TextMutationError::Invalid)?; + if delete_end > text.len() || !mutation.insert_utf16_le.len().is_multiple_of(2) { + return Err(TextMutationError::Invalid); + } + let insert_count = mutation.insert_utf16_le.len() / 2; + let old_len = text.len(); + let new_len = old_len + .checked_sub(delete_count) + .and_then(|length| length.checked_add(insert_count)) + .ok_or(TextMutationError::Invalid)?; + if u32::try_from(new_len).is_err() { + return Err(TextMutationError::Invalid); + } + if new_len > old_len { + text.try_reserve(new_len - old_len) + .map_err(|_| TextMutationError::Allocation)?; + text.resize(new_len, 0); + } + text.copy_within(delete_end..old_len, start + insert_count); + if new_len < old_len { + text.truncate(new_len); + } + for (unit, bytes) in text[start..start + insert_count] + .iter_mut() + .zip(mutation.insert_utf16_le.chunks_exact(2)) + { + *unit = u16::from_le_bytes([bytes[0], bytes[1]]); + } + Ok(()) +} + +fn reserve_text_buffer(text: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if text.capacity() < capacity { + text.try_reserve_exact(capacity.saturating_sub(text.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum TextMutationError { + Invalid, + Allocation, +} + fn plan_error(error: RenderPlanCompilerError) -> EngineError { if error.is_result_too_large() { EngineError::ResultTooLarge @@ -256,11 +378,24 @@ fn plan_error(error: RenderPlanCompilerError) -> EngineError { #[cfg(test)] mod tests { use super::*; - use crate::engine::policy::{ - ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, - BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CAP_ORDERED_DIRECT, - CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, - ProgramId, ScalarType, TechniqueId, + use crate::{ + abi_contract::{ + ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, + ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, + ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, + }, + engine::{ + frame::{TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16}, + policy::{ + ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, + BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, + BufferSchema, CAP_ORDERED_DIRECT, CapabilitySet, Operation, PolicyDescriptor, + ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, TechniqueId, + }, + semantic_wire::parse_text_mutations, + }, + wire::write_u32, }; use alloc::vec; @@ -406,6 +541,77 @@ mod tests { engine.commit_update(retry).unwrap(); } + #[test] + fn ordered_utf16_replacements_commit_and_abort_with_the_session_transaction() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + engine.reserve_session_text(4, 8).unwrap(); + + let initial_bytes = text_mutation_bytes(&[(0, 0, &[0x61, 0x62, 0x63, 0x64])]); + let initial_batch = + parse_text_mutations(&initial_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let mut initial = update(0, 0, 0); + initial.text_mutations = initial_batch; + let prepared = engine.prepare_update(initial, 1).unwrap(); + assert!(engine.session_text(4).unwrap().is_empty()); + engine.commit_update(prepared).unwrap(); + assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); + + let edit_bytes = text_mutation_bytes(&[(1, 2, &[0x58, 0x59]), (4, 0, &[0x21])]); + let edit_batch = + parse_text_mutations(&edit_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let mut edit = update(1, 1, 1); + edit.text_mutations = edit_batch; + let prepared = engine.prepare_update(edit, 2).unwrap(); + engine.abort_update(prepared).unwrap(); + assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); + + let retry = engine.prepare_update(edit, 2).unwrap(); + engine.commit_update(retry).unwrap(); + assert_eq!( + engine.session_text(4).unwrap(), + &[0x61, 0x58, 0x59, 0x64, 0x21] + ); + + let settled_capacities = { + let session = engine.sessions.get(&4).unwrap(); + [session.text.capacity(), session.pending_text.capacity()] + }; + let warm_bytes = text_mutation_bytes(&[(0, 1, &[0x7a])]); + let warm_batch = + parse_text_mutations(&warm_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let mut warm = update(2, 2, 2); + warm.text_mutations = warm_batch; + let prepared = engine.prepare_update(warm, 3).unwrap(); + engine.commit_update(prepared).unwrap(); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + [session.pending_text.capacity(), session.text.capacity()], + settled_capacities + ); + } + + #[test] + fn an_invalid_later_replacement_cannot_partially_mutate_committed_text() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let bytes = text_mutation_bytes(&[(0, 0, &[0x61]), (9, 0, &[0x62])]); + let batch = parse_text_mutations(&bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let mut request = update(0, 0, 0); + request.text_mutations = batch; + assert_eq!( + engine.prepare_update(request, 1), + Err(EngineError::InvalidRequest) + ); + assert!(engine.session_text(4).unwrap().is_empty()); + } + #[test] fn a_committed_session_rejects_rebinding_its_policy_identity() { let mut engine = TextEngine::default(); @@ -496,7 +702,7 @@ mod tests { expected_engine_revision: u32, consumed_plan_revision: u32, acknowledged_publication_generation: u32, - ) -> UpdateRequest { + ) -> UpdateRequest<'static> { UpdateRequest { session_id: 4, expected_engine_revision, @@ -513,6 +719,44 @@ mod tests { max_slots_per_band: 1, max_output_bytes: 128, }, + text_mutations: super::super::semantic_wire::TextMutationBatch::empty(), + } + } + + fn text_mutation_bytes(records: &[(u32, u32, &[u16])]) -> Vec { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let records_length = records.len() * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let payload_length = records + .iter() + .map(|(_, _, insert)| insert.len() * 2) + .sum::(); + let mut bytes = vec![0; record_offset + records_length + payload_length]; + let mut payload_offset = record_offset + records_length; + for (index, &(text_start, delete_count, insert)) in records.iter().enumerate() { + let start = record_offset + index * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let end = start + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let record = &mut bytes[start..end]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, text_start); + write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, delete_count); + if !insert.is_empty() { + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + u32::try_from(payload_offset).unwrap(), + ); + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_COUNT, + u32::try_from(insert.len()).unwrap(), + ); + for &unit in insert { + bytes[payload_offset..payload_offset + 2].copy_from_slice(&unit.to_le_bytes()); + payload_offset += 2; + } + } } + bytes } } diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index e53d8524..a5a66fba 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -133,6 +133,7 @@ pub extern "C" fn pmndrs_text_engine_create_session( handle: u32, request_capacity: u32, result_capacity: u32, + text_capacity: u32, ) -> u32 { with_state(|state| { if handle == 0 { @@ -148,6 +149,15 @@ pub extern "C" fn pmndrs_text_engine_create_session( if let Err(error) = state.engine.create_session(handle) { return engine_status(error); } + let text_capacity = if text_capacity == 0 { + crate::engine::frame::DEFAULT_SESSION_TEXT_CAPACITY + } else { + text_capacity + }; + if let Err(error) = state.engine.reserve_session_text(handle, text_capacity) { + let _ = state.engine.dispose_session(handle); + return engine_status(error); + } state.frames.insert(handle, transport); 0 }) @@ -158,15 +168,21 @@ pub extern "C" fn pmndrs_text_engine_reserve_session( handle: u32, request_capacity: u32, result_capacity: u32, + text_capacity: u32, ) -> u32 { with_state(|state| { let Some(transport) = state.frames.get_mut(&handle) else { return STATUS_SESSION_MISSING; }; - match transport.reserve(request_capacity, result_capacity) { - Ok(()) => 0, - Err(status) => status, + if let Err(status) = transport.reserve(request_capacity, result_capacity) { + return status; } + if text_capacity != 0 + && let Err(error) = state.engine.reserve_session_text(handle, text_capacity) + { + return engine_status(error); + } + 0 }) } diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 827b6246..04c7396d 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -38,6 +38,7 @@ export const textShaperAbi = { "orderedDirect": 1, "stableIndirect": 2 }, + "defaultSessionTextCapacity": 1024, "flowShapeKinds": { "polygon": 2, "rectangle": 1 @@ -86,6 +87,9 @@ export const textShaperAbi = { "remove": 2, "upsert": 1 }, + "textEncodings": { + "utf16Le": 1 + }, "textMutationOpcodes": { "replaceUtf16": 1 }, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index d67498fc..67d1d8a5 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -46,11 +46,13 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.equal(abi.layouts.engineInlineObject.alignment, 4); assert.equal(abi.layouts.engineInlineObject.baselineAlignment, 52); assert.equal(abi.engine.textMutationOpcodes.replaceUtf16, 1); + assert.equal(abi.engine.textEncodings.utf16Le, 1); assert.equal(abi.engine.styleMutationOpcodes.upsert, 1); assert.equal(abi.engine.styleMutationOpcodes.remove, 2); assert.deepEqual(abi.engine.flowShapeKinds, { polygon: 2, rectangle: 1 }); assert.deepEqual(abi.engine.writingModes, { horizontalTb: 1, verticalLr: 3, verticalRl: 2 }); - assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size), abi.status.ok); + assert.equal(abi.engine.defaultSessionTextCapacity, 1024); + assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size, 0), abi.status.ok); assert.equal(fn.sessionCount(), 1); let requestPointer = fn.requestPointer(sessionId); assert.notEqual(requestPointer, 0); @@ -129,23 +131,74 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as }); const checkpointHeader = resultBytes(memory, checkpointPointer, resultLayout).slice(); - writeRequest(memory, requestPointer, abi, 3, 3, 3); + assert.equal(fn.reserveSession(sessionId, 256, resultLayout.size, 8), abi.status.ok); + requestPointer = fn.requestPointer(sessionId); + assert.ok(fn.requestCapacity(sessionId) >= 256); + const textWarmBuffer = memory.buffer; + const insertLength = writeRequest(memory, requestPointer, abi, 3, 3, 3, [ + { start: 0, deleteCount: 0, insert: [0x61, 0x62, 0x63] }, + ]); + const insertedPointer = fn.textUpdate(sessionId, requestPointer, insertLength); + assert.strictEqual(memory.buffer, textWarmBuffer); + assertResult(memory, insertedPointer, abi, { + status: abi.status.ok, + engineRevision: 4, + planRevision: 4, + requiredBaseRevision: 3, + publicationGeneration: 4, + outputSlot: 1, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, checkpointPointer, resultLayout), checkpointHeader); + + const retainedEditLength = writeRequest(memory, requestPointer, abi, 4, 4, 4, [ + { start: 1, deleteCount: 1, insert: [0x58] }, + ]); + const retainedEditPointer = fn.textUpdate(sessionId, requestPointer, retainedEditLength); + assert.strictEqual(memory.buffer, textWarmBuffer); + assertResult(memory, retainedEditPointer, abi, { + status: abi.status.ok, + engineRevision: 5, + planRevision: 5, + requiredBaseRevision: 4, + publicationGeneration: 5, + outputSlot: 0, + flags: 0, + }); + const retainedHeader = resultBytes(memory, retainedEditPointer, resultLayout).slice(); + + const invalidEditLength = writeRequest(memory, requestPointer, abi, 5, 5, 5, [ + { start: 9, deleteCount: 0, insert: [0x21] }, + ]); + const invalidEditPointer = fn.textUpdate(sessionId, requestPointer, invalidEditLength); + assertResult(memory, invalidEditPointer, abi, { + status: abi.status.invalidRequest, + engineRevision: 5, + planRevision: 5, + requiredBaseRevision: 5, + publicationGeneration: 5, + outputSlot: 1, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, retainedEditPointer, resultLayout), retainedHeader); + + writeRequest(memory, requestPointer, abi, 5, 5, 5); new DataView(memory.buffer, requestPointer, requestLayout.size).setUint32(requestLayout.regionCount, 1, true); const unsupportedPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); assertResult(memory, unsupportedPointer, abi, { status: abi.status.invalidRequest, - engineRevision: 3, - planRevision: 3, - requiredBaseRevision: 3, - publicationGeneration: 3, + engineRevision: 5, + planRevision: 5, + requiredBaseRevision: 5, + publicationGeneration: 5, outputSlot: 1, flags: 0, }); - assert.deepEqual(resultBytes(memory, checkpointPointer, resultLayout), checkpointHeader); + assert.deepEqual(resultBytes(memory, retainedEditPointer, resultLayout), retainedHeader); const oldBuffer = memory.buffer; const grownCapacity = 8 * 1024 * 1024; - assert.equal(fn.reserveSession(sessionId, grownCapacity, grownCapacity), abi.status.ok); + assert.equal(fn.reserveSession(sessionId, grownCapacity, grownCapacity, 0), abi.status.ok); assert.notStrictEqual(memory.buffer, oldBuffer); assert.equal(oldBuffer.byteLength, 0, 'memory.grow must detach fixed-length views in the pinned runtime'); requestPointer = fn.requestPointer(sessionId); @@ -166,6 +219,7 @@ function writeRequest( expectedEngineRevision, consumedPlanRevision, acknowledgedPublicationGeneration = 0, + textMutations = [], ) { const bytes = engineUpdateBytes(abi, { sessionId, @@ -173,8 +227,10 @@ function writeRequest( expectedEngineRevision, consumedPlanRevision, acknowledgedPublicationGeneration, + textMutations, }); new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + return bytes.byteLength; } function assertResult(memory, pointer, abi, expected) { diff --git a/packages/text/tests/support/engine-abi.d.mts b/packages/text/tests/support/engine-abi.d.mts index 6ce88895..bb3e3c57 100644 --- a/packages/text/tests/support/engine-abi.d.mts +++ b/packages/text/tests/support/engine-abi.d.mts @@ -4,6 +4,11 @@ export interface EngineUpdateFields { readonly expectedEngineRevision: number; readonly consumedPlanRevision: number; readonly acknowledgedPublicationGeneration?: number; + readonly textMutations?: readonly { + readonly start: number; + readonly deleteCount: number; + readonly insert: readonly number[]; + }[]; } export function renderPolicyBytes(abi: object): Uint8Array; diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 9f83fd1b..79c0b2df 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -52,10 +52,23 @@ export function kernelPolicyBytes(abi) { export function engineUpdateBytes( abi, - { sessionId, policyHandle, expectedEngineRevision, consumedPlanRevision, acknowledgedPublicationGeneration = 0 }, + { + sessionId, + policyHandle, + expectedEngineRevision, + consumedPlanRevision, + acknowledgedPublicationGeneration = 0, + textMutations = [], + }, ) { const layout = abi.layouts.engineUpdateRequest; - const bytes = new Uint8Array(layout.size); + const mutationLayout = abi.layouts.engineTextMutation; + const mutationsOffset = textMutations.length === 0 ? 0 : align(layout.size, mutationLayout.alignment); + const recordsEnd = + textMutations.length === 0 ? layout.size : mutationsOffset + textMutations.length * mutationLayout.size; + const payloadOffset = align(recordsEnd, 2); + const payloadLength = textMutations.reduce((total, mutation) => total + mutation.insert.length * 2, 0); + const bytes = new Uint8Array(payloadOffset + payloadLength); const view = new DataView(bytes.buffer); view.setUint32(layout.abiVersion, abi.version, true); view.setUint32(layout.byteLength, bytes.byteLength, true); @@ -73,9 +86,27 @@ export function engineUpdateBytes( 'maxInlineObjects', 'maxSlotsPerBand', ]) { - view.setUint32(layout[field], 1, true); + view.setUint32(layout[field], field === 'maxClusters' ? Math.max(1, textMutations.length) : 1, true); } view.setUint32(layout.maxOutputBytes, abi.layouts.engineResult.size, true); + view.setUint32(layout.textMutationsOffset, mutationsOffset, true); + view.setUint32(layout.textMutationCount, textMutations.length, true); + let insertOffset = payloadOffset; + for (const [index, mutation] of textMutations.entries()) { + const record = mutationsOffset + index * mutationLayout.size; + view.setUint8(record + mutationLayout.opcode, abi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(record + mutationLayout.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(record + mutationLayout.textStart, mutation.start, true); + view.setUint32(record + mutationLayout.deleteCount, mutation.deleteCount, true); + if (mutation.insert.length > 0) { + view.setUint32(record + mutationLayout.insertOffset, insertOffset, true); + view.setUint32(record + mutationLayout.insertCount, mutation.insert.length, true); + for (const unit of mutation.insert) { + view.setUint16(insertOffset, unit, true); + insertOffset += 2; + } + } + } return bytes; } From 7fb03076f1d5eb183112730d66794d2758855b73 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 11:17:41 -0400 Subject: [PATCH 019/128] perf(text): initialize wasm engine eagerly --- docs/log.md | 5 +++++ docs/packages/text.md | 5 ++++- docs/planning/decision-register.md | 2 +- docs/planning/rust-layout-engine.md | 6 ++++++ packages/text/rust/shaper/src/abi_contract.rs | 1 + packages/text/rust/shaper/src/wasm.rs | 11 ++++++++--- packages/text/src/generated/text-shaper-abi.ts | 1 + packages/text/src/shaper.ts | 3 +++ .../tests/integration/render-plan-frame-abi.test.mjs | 1 + 9 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/log.md b/docs/log.md index a5fac924..dd209467 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,11 @@ ## 2026-08-08 +- **Made Wasm engine initialization explicit and eager** — The compiler-derived ABI now publishes `initialize()`, and + the standard host invokes it immediately after instantiation so module state is not lazily allocated by the first + font, session, or update operation. The focused compiled-Wasm frame test exercises the export. Concrete 32,768-record + shaping/layout lanes have not landed, so this checkpoint does not claim first-shape allocation or latency evidence. + - **Retained ordered UTF-16 edits transactionally inside Rust sessions** — The frame decoder now borrows and validates replacement records/payloads without allocating mutation objects. Sessions apply sequential edits to retained scratch and swap only on commit; abort or an invalid later replacement preserves committed text. Compiled Wasm proves cold diff --git a/docs/packages/text.md b/docs/packages/text.md index a95af677..46fd01ea 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:1bc7cd639c991441bb0dae6ed543b101bbf472ca6221ac80a5e5944c0b709eee' +source_digest: 'sha256:a251ffc58d3779c03b32a7fac04d6773d5f54b4ba10115465aca6d32e1976e25' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -508,6 +508,9 @@ Session creation also prewarms both retained UTF-16 buffers to 1,024 units by de accepts an explicit text capacity for known large paragraphs. This removes the observed second-buffer lazy allocation without giving every text object the 25K-glyph benchmark footprint. Production analysis/shaping/layout scratch will be one synchronous engine-global 32,768-record workspace, reserved once when those arrays land and shared by every session. +The compiler-published `initialize()` export is invoked by the standard host immediately after Wasm instantiation, so +module-owned state no longer allocates behind the first operational export. This checkpoint does not yet reserve the +unimplemented shaping lanes and therefore makes no first-shape allocation or latency claim. The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index af313e32..ea2d81cf 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -238,7 +238,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | | D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | | D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | -| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; when its production arrays land it prewarms once to 32,768 clusters/glyphs, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | +| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; when its production arrays land it prewarms once to 32,768 clusters/glyphs, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation, creates module state before any operational export, and will own that workspace reservation when its concrete SoA lanes land; it does not claim currently unimplemented arrays are prewarmed. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 729d39c9..b235893b 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -338,6 +338,12 @@ updates, not one 25K-glyph allocation per paragraph. Those production arrays pre they land, covering the 25,515-glyph target fixture, and expose explicit cold growth beyond that envelope. A warm update inside declared capacities may not lazily settle another allocation. +Module initialization is explicit rather than an incidental side effect of the first operational export. The generated +ABI publishes `initialize()`, and the standard host calls it immediately after `WebAssembly.instantiate`; this eagerly +creates module-owned state before a font registration, session operation, or update can be observed. At the current +checkpoint this moves only the state allocation. The 32,768-record claim begins when the concrete production SoA lanes +are created and reserved by that initializer, not before. + ## Rust layout pipeline Each update follows one dependency graph inside Rust: diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index d9afde07..038e1e94 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -1629,6 +1629,7 @@ pub fn json() -> String { "fontFormat": 0 }, "functions": { + "initialize": "pmndrs_text_shaper_initialize", "allocate": "pmndrs_text_shaper_alloc", "deallocate": "pmndrs_text_shaper_dealloc", "registerFont": "pmndrs_text_shaper_register_font", diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index a5a66fba..0c1b606d 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -2,9 +2,9 @@ use alloc::{boxed::Box, collections::BTreeMap, vec::Vec}; use core::sync::atomic::{AtomicUsize, Ordering}; use crate::{ - STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, STATUS_POLICY_CONFLICT, STATUS_POLICY_MISSING, - STATUS_RESULT_TOO_LARGE, STATUS_REVISION_CONFLICT, STATUS_SESSION_CONFLICT, - STATUS_SESSION_MISSING, ShaperRegistry, bidi, + STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, STATUS_OK, STATUS_POLICY_CONFLICT, + STATUS_POLICY_MISSING, STATUS_RESULT_TOO_LARGE, STATUS_REVISION_CONFLICT, + STATUS_SESSION_CONFLICT, STATUS_SESSION_MISSING, ShaperRegistry, bidi, engine::{ EngineError, TextEngine, frame::SessionRevision, frame_wire::parse_update_request, render_plan_wire::plan_layout, transport::FrameTransport, wire::parse_policy, @@ -28,6 +28,11 @@ fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { core::arch::wasm32::unreachable() } +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_shaper_initialize() -> u32 { + with_state(|_| STATUS_OK) +} + #[unsafe(no_mangle)] pub extern "C" fn pmndrs_text_shaper_alloc(length: u32) -> u32 { with_state(|state| state.allocate(length)) diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 04c7396d..faeaa027 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -108,6 +108,7 @@ export const textShaperAbi = { "disposePolicy": "pmndrs_text_engine_dispose_policy", "disposeSession": "pmndrs_text_engine_dispose_session", "fontCount": "pmndrs_text_shaper_font_count", + "initialize": "pmndrs_text_shaper_initialize", "planCount": "pmndrs_text_shaper_plan_count", "policyCount": "pmndrs_text_engine_policy_count", "registerFont": "pmndrs_text_shaper_register_font", diff --git a/packages/text/src/shaper.ts b/packages/text/src/shaper.ts index 91e6c368..54568edb 100644 --- a/packages/text/src/shaper.ts +++ b/packages/text/src/shaper.ts @@ -409,6 +409,9 @@ function readModule(instance: WebAssembly.Instance): ShaperModule { const memory = instance.exports.memory; if (!(memory instanceof WebAssembly.Memory)) throw new TypeError('text shaper is missing memory'); const functions = textShaperAbi.functions; + const initialize = exportedFunction(instance, functions.initialize); + const status = initialize(); + if (status !== 0) throw shaperStatusError(status, 'initialize'); return { exports: { memory, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 67d1d8a5..58d96762 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -17,6 +17,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), ); assert.ok(memory instanceof WebAssembly.Memory); + assert.equal(fn.initialize(), abi.status.ok); const policy = renderPolicyBytes(abi); const policyPointer = copyIntoAllocation(memory, fn.allocate, policy); From d2885ca809ff44ce99e6b73a4b630aa6144331f1 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 11:32:23 -0400 Subject: [PATCH 020/128] feat(text): register rust font stacks --- docs/log.md | 7 ++ docs/packages/text.md | 10 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 9 ++ packages/text/rust/shaper/src/abi_contract.rs | 7 +- packages/text/rust/shaper/src/engine/state.rs | 101 ++++++++++++++++++ packages/text/rust/shaper/src/lib.rs | 6 ++ packages/text/rust/shaper/src/wasm.rs | 60 ++++++++++- .../text/src/generated/text-shaper-abi.ts | 5 + packages/text/src/shaper.ts | 2 + .../integration/shaper-registration.test.mjs | 49 +++++++++ 11 files changed, 251 insertions(+), 6 deletions(-) diff --git a/docs/log.md b/docs/log.md index dd209467..6c3ebfd1 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Registered ordered font-stack ownership in Rust** — Added cold, direct-memory font-stack lifecycle operations with + nonempty/unique member validation, exact-order idempotence, conflict detection, and member-font retention. A + compiled-Wasm test registers a real baked Inter font, proves disposal fails while its stack is live, releases the + stack, and then disposes the font. Size measurement rejected a generic tree map at 837,865 raw / 312,057 gzip / + 246,478 Brotli bytes in favor of a compact cold vector at 828,401 / 309,252 / 244,402. Technique/resource binding and + fallback shaping remain open, so this adds no frame latency claim. + - **Made Wasm engine initialization explicit and eager** — The compiler-derived ABI now publishes `initialize()`, and the standard host invokes it immediately after instantiation so module state is not lazily allocated by the first font, session, or update operation. The focused compiled-Wasm frame test exercises the export. Concrete 32,768-record diff --git a/docs/packages/text.md b/docs/packages/text.md index 46fd01ea..33ce3133 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:a251ffc58d3779c03b32a7fac04d6773d5f54b4ba10115465aca6d32e1976e25' +source_digest: 'sha256:67818f68ec35bc04acd514ee699833fa69718d7f52710f63c1b2e08f28a0a5dd' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -512,6 +512,14 @@ The compiler-published `initialize()` export is invoked by the standard host imm module-owned state no longer allocates behind the first operational export. This checkpoint does not yet reserve the unimplemented shaping lanes and therefore makes no first-shape allocation or latency claim. +Ordered font stacks now have cold Rust lifecycle operations independent from frame updates. A stack is nonempty, +duplicate-free, idempotent only for the same ordered handles, and retains its already registered shaping fonts until +stack disposal. A compiled-Wasm integration test uses the real baked Inter shaping payload to prove that a retained +member cannot be disposed and becomes disposable after the stack is released. Per-font technique/resource binding and +fallback shaping remain the next slices; this registry alone makes no layout or timing claim. The selected compact +vector registry produces an optimized 828,401 raw / 309,252 gzip / 244,402 Brotli-byte Wasm. A rejected generic tree-map +version measured 837,865 / 312,057 / 246,478, so cold linear lookup avoids 9,464 raw and 2,076 Brotli bytes. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index ea2d81cf..01ff2479 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -239,6 +239,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | | D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | | D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; when its production arrays land it prewarms once to 32,768 clusters/glyphs, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation, creates module state before any operational export, and will own that workspace reservation when its concrete SoA lanes land; it does not claim currently unimplemented arrays are prewarmed. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | +| D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index b235893b..a7864a53 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -558,6 +558,15 @@ fallback mechanism. It removes the old raster-homogeneity restriction: every mem but each loaded font carries its own technique and resource binding. Fallback remains a shaping decision about glyph availability, not a renderer eligibility decision. +The Rust engine now cold-registers stack identity as a nonempty, duplicate-free ordered list of already registered +shaping-font handles. Equivalent registration is idempotent; conflicting order fails, and a member font cannot be +disposed while any registered stack retains it. Technique/resource data is deliberately not duplicated in the stack: +the next cold binding layer attaches those tables once to the loaded font. A real-font compiled-Wasm test proves the +registration and disposal lifecycle; fallback shaping itself has not yet moved into the update transaction. Because +stack lifecycle is cold and cardinality is normally small, the selected registry uses a compact vector. A generic tree +map measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes; removing that monomorphization reduced the artifact to +828,401 / 309,252 / 244,402 without changing the ABI or lookup result. + RGBA8 costs four GPU bytes per texel versus one for the grayscale R8 bitmap path; selected coverage and independently resident pages are therefore mandatory, and the payload report keeps color pages separate.[^renderer-capabilities] [^payload-budget] Slug color-paint compilation is not required to ship emoji in this stack; Bitmap is the required color diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 038e1e94..25a74ade 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -1637,6 +1637,9 @@ pub fn json() -> String { "fontCount": "pmndrs_text_shaper_font_count", "retainedFontBytes": "pmndrs_text_shaper_retained_font_bytes", "planCount": "pmndrs_text_shaper_plan_count", + "registerFontStack": "pmndrs_text_engine_register_font_stack", + "disposeFontStack": "pmndrs_text_engine_dispose_font_stack", + "fontStackCount": "pmndrs_text_engine_font_stack_count", "registerPolicy": "pmndrs_text_engine_register_policy", "disposePolicy": "pmndrs_text_engine_dispose_policy", "policyCount": "pmndrs_text_engine_policy_count", @@ -2284,7 +2287,9 @@ pub fn json() -> String { "policyMissing": 9, "sessionConflict": 10, "sessionMissing": 11, - "revisionConflict": 12 + "revisionConflict": 12, + "fontStackMissing": 13, + "fontInUse": 14 } }) .to_string() diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 1e12d10f..bb4a1874 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -13,6 +13,7 @@ pub enum EngineError { InvalidHandle, HandleConflict, PolicyMissing, + FontStackMissing, SessionConflict, SessionMissing, RevisionConflict, @@ -24,9 +25,15 @@ pub enum EngineError { #[derive(Default)] pub struct TextEngine { policies: BTreeMap, + font_stacks: Vec, sessions: BTreeMap, } +struct RegisteredFontStack { + handle: u32, + fonts: Vec, +} + #[derive(Default)] struct EngineSession { revision: SessionRevision, @@ -45,6 +52,68 @@ struct PolicyBinding { } impl TextEngine { + pub fn register_font_stack(&mut self, handle: u32, fonts: &[u32]) -> Result<(), EngineError> { + if handle == 0 + || fonts.is_empty() + || fonts.len() > usize::from(u16::MAX) + || fonts.contains(&0) + || fonts + .iter() + .enumerate() + .any(|(index, font)| fonts[..index].contains(font)) + { + return Err(EngineError::InvalidRequest); + } + if let Some(existing) = self.font_stacks.iter().find(|stack| stack.handle == handle) { + return if existing.fonts == fonts { + Ok(()) + } else { + Err(EngineError::HandleConflict) + }; + } + let mut retained = Vec::new(); + retained + .try_reserve_exact(fonts.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + retained.extend_from_slice(fonts); + self.font_stacks + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + self.font_stacks.push(RegisteredFontStack { + handle, + fonts: retained, + }); + Ok(()) + } + + pub fn dispose_font_stack(&mut self, handle: u32) -> Result<(), EngineError> { + let index = self + .font_stacks + .iter() + .position(|stack| stack.handle == handle) + .ok_or(EngineError::FontStackMissing)?; + self.font_stacks.swap_remove(index); + Ok(()) + } + + pub fn font_stack(&self, handle: u32) -> Result<&[u32], EngineError> { + self.font_stacks + .iter() + .find(|stack| stack.handle == handle) + .map(|stack| stack.fonts.as_slice()) + .ok_or(EngineError::FontStackMissing) + } + + pub fn font_stack_count(&self) -> u32 { + self.font_stacks.len().try_into().unwrap_or(u32::MAX) + } + + pub fn references_font(&self, handle: u32) -> bool { + self.font_stacks + .iter() + .any(|stack| stack.fonts.contains(&handle)) + } + pub fn register_policy( &mut self, handle: u32, @@ -416,6 +485,38 @@ mod tests { ); } + #[test] + fn font_stacks_retain_exact_order_and_reject_ambiguous_identity() { + let mut engine = TextEngine::default(); + assert_eq!( + engine.register_font_stack(0, &[1]), + Err(EngineError::InvalidRequest) + ); + assert_eq!( + engine.register_font_stack(1, &[]), + Err(EngineError::InvalidRequest) + ); + assert_eq!( + engine.register_font_stack(1, &[1, 1]), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.register_font_stack(7, &[9, 4, 12]), Ok(())); + assert_eq!(engine.register_font_stack(7, &[9, 4, 12]), Ok(())); + assert_eq!(engine.font_stack(7), Ok(&[9, 4, 12][..])); + assert!(engine.references_font(4)); + assert_eq!(engine.font_stack_count(), 1); + assert_eq!( + engine.register_font_stack(7, &[9, 12]), + Err(EngineError::HandleConflict) + ); + assert_eq!(engine.dispose_font_stack(7), Ok(())); + assert!(!engine.references_font(4)); + assert_eq!( + engine.dispose_font_stack(7), + Err(EngineError::FontStackMissing) + ); + } + #[test] fn disposal_is_exact_and_missing_handles_are_observable() { let mut engine = TextEngine::default(); diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 0bbefc9a..b73ff609 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -33,6 +33,8 @@ pub const STATUS_POLICY_MISSING: u32 = 9; pub const STATUS_SESSION_CONFLICT: u32 = 10; pub const STATUS_SESSION_MISSING: u32 = 11; pub const STATUS_REVISION_CONFLICT: u32 = 12; +pub const STATUS_FONT_STACK_MISSING: u32 = 13; +pub const STATUS_FONT_IN_USE: u32 = 14; const BUFFER_FLAGS_MASK: u32 = 0xff; const MAX_CACHED_PLANS_PER_FONT: usize = 64; @@ -298,6 +300,10 @@ impl ShaperRegistry { self.fonts.len().try_into().unwrap_or(u32::MAX) } + pub fn contains_font(&self, handle: u32) -> bool { + self.fonts.contains_key(&handle) + } + pub fn retained_font_bytes(&self) -> u32 { self.fonts .values() diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 0c1b606d..98bc3667 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -2,9 +2,10 @@ use alloc::{boxed::Box, collections::BTreeMap, vec::Vec}; use core::sync::atomic::{AtomicUsize, Ordering}; use crate::{ - STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, STATUS_OK, STATUS_POLICY_CONFLICT, - STATUS_POLICY_MISSING, STATUS_RESULT_TOO_LARGE, STATUS_REVISION_CONFLICT, - STATUS_SESSION_CONFLICT, STATUS_SESSION_MISSING, ShaperRegistry, bidi, + STATUS_FONT_IN_USE, STATUS_FONT_STACK_MISSING, STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, + STATUS_OK, STATUS_POLICY_CONFLICT, STATUS_POLICY_MISSING, STATUS_RESULT_TOO_LARGE, + STATUS_REVISION_CONFLICT, STATUS_SESSION_CONFLICT, STATUS_SESSION_MISSING, ShaperRegistry, + bidi, engine::{ EngineError, TextEngine, frame::SessionRevision, frame_wire::parse_update_request, render_plan_wire::plan_layout, transport::FrameTransport, wire::parse_policy, @@ -76,7 +77,13 @@ pub unsafe extern "C" fn pmndrs_text_shaper_register_font( #[unsafe(no_mangle)] pub extern "C" fn pmndrs_text_shaper_dispose_font(handle: u32) -> u32 { - with_state(|state| state.registry.dispose_font(handle)) + with_state(|state| { + if state.engine.references_font(handle) { + STATUS_FONT_IN_USE + } else { + state.registry.dispose_font(handle) + } + }) } #[unsafe(no_mangle)] @@ -94,6 +101,50 @@ pub extern "C" fn pmndrs_text_shaper_plan_count() -> u32 { with_state(|state| state.registry.plan_count()) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_engine_register_font_stack( + handle: u32, + pointer: u32, + count: u32, +) -> u32 { + with_state(|state| { + let Some(length) = count.checked_mul(4) else { + return STATUS_INVALID_REQUEST; + }; + let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { + return STATUS_INVALID_REQUEST; + }; + let mut fonts = Vec::new(); + if fonts.try_reserve_exact(count as usize).is_err() { + return STATUS_RESULT_TOO_LARGE; + } + for bytes in bytes.chunks_exact(4) { + let font = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + if !state.registry.contains_font(font) { + return crate::STATUS_FONT_MISSING; + } + fonts.push(font); + } + match state.engine.register_font_stack(handle, &fonts) { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_dispose_font_stack(handle: u32) -> u32 { + with_state(|state| match state.engine.dispose_font_stack(handle) { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_font_stack_count() -> u32 { + with_state(|state| state.engine.font_stack_count()) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn pmndrs_text_engine_register_policy( handle: u32, @@ -679,6 +730,7 @@ fn engine_status(error: EngineError) -> u32 { EngineError::InvalidHandle => STATUS_INVALID_HANDLE, EngineError::HandleConflict => STATUS_POLICY_CONFLICT, EngineError::PolicyMissing => STATUS_POLICY_MISSING, + EngineError::FontStackMissing => STATUS_FONT_STACK_MISSING, EngineError::SessionConflict => STATUS_SESSION_CONFLICT, EngineError::SessionMissing => STATUS_SESSION_MISSING, EngineError::RevisionConflict => STATUS_REVISION_CONFLICT, diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index faeaa027..5efc0a9b 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -105,13 +105,16 @@ export const textShaperAbi = { "createSession": "pmndrs_text_engine_create_session", "deallocate": "pmndrs_text_shaper_dealloc", "disposeFont": "pmndrs_text_shaper_dispose_font", + "disposeFontStack": "pmndrs_text_engine_dispose_font_stack", "disposePolicy": "pmndrs_text_engine_dispose_policy", "disposeSession": "pmndrs_text_engine_dispose_session", "fontCount": "pmndrs_text_shaper_font_count", + "fontStackCount": "pmndrs_text_engine_font_stack_count", "initialize": "pmndrs_text_shaper_initialize", "planCount": "pmndrs_text_shaper_plan_count", "policyCount": "pmndrs_text_engine_policy_count", "registerFont": "pmndrs_text_shaper_register_font", + "registerFontStack": "pmndrs_text_engine_register_font_stack", "registerPolicy": "pmndrs_text_engine_register_policy", "requestCapacity": "pmndrs_text_engine_request_capacity", "requestPointer": "pmndrs_text_engine_request_ptr", @@ -669,7 +672,9 @@ export const textShaperAbi = { } }, "status": { + "fontInUse": 14, "fontMissing": 5, + "fontStackMissing": 13, "handleConflict": 4, "invalidExtents": 3, "invalidFont": 2, diff --git a/packages/text/src/shaper.ts b/packages/text/src/shaper.ts index 54568edb..0727919d 100644 --- a/packages/text/src/shaper.ts +++ b/packages/text/src/shaper.ts @@ -740,6 +740,8 @@ function shaperStatusError(status: number, action: string): Error { 5: 'font handle is not registered', 6: 'invalid batch request', 7: 'result exceeds the V0 address space', + 13: 'font stack handle is not registered', + 14: 'font is retained by a registered font stack', }; return new Error(`text shaper could not ${action}: ${labels[status] ?? `status ${status}`}`); } diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 0d7ce91b..12c6209a 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -78,6 +78,47 @@ test('the shaper registers only the exact shaping views retained from the valida assert.throws(() => shaper.memoryReport(), /disposed/); }); +test('compiled Wasm retains ordered font stacks and prevents dangling font disposal', async () => { + const { artifact, shaperWasm } = await fixture(); + const [validated, abi] = await Promise.all([ + validateFontArtifact(artifact), + readFile(shaperAbiUrl, 'utf8').then(JSON.parse), + ]); + const instance = await WebAssembly.instantiate(await WebAssembly.compile(shaperWasm), {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), + ); + assert.equal(fn.initialize(), abi.status.ok); + const allocations = [ + copyToWasm(memory, fn.allocate, validated.shapingSfnt), + copyToWasm(memory, fn.allocate, validated.glyphExtents), + copyToWasm(memory, fn.allocate, validated.glyphExtentsAvailability), + ]; + assert.equal( + fn.registerFont( + 101, + allocations[0].pointer, + allocations[0].length, + allocations[1].pointer, + allocations[1].length, + allocations[2].pointer, + allocations[2].length, + ), + abi.status.ok, + ); + for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); + + const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(101, 0, 0, 0)); + assert.equal(fn.registerFontStack(17, stack.pointer, 1), abi.status.ok); + fn.deallocate(stack.pointer, stack.length); + assert.equal(fn.fontStackCount(), 1); + assert.equal(fn.disposeFont(101), abi.status.fontInUse); + assert.equal(fn.disposeFontStack(17), abi.status.ok); + assert.equal(fn.disposeFontStack(17), abi.status.fontStackMissing); + assert.equal(fn.disposeFont(101), abi.status.ok); +}); + test('shaper ownership stays scoped to its FontRegistry', async () => { const { artifact, shaperWasm } = await fixture(); const firstRegistry = new FontRegistry(); @@ -90,6 +131,14 @@ test('shaper ownership stays scoped to its FontRegistry', async () => { foreign.dispose(); }); +function copyToWasm(memory, allocate, source) { + const bytes = new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + const pointer = allocate(bytes.byteLength); + assert.notEqual(pointer, 0); + new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + return { pointer, length: bytes.byteLength }; +} + test('re-registering the same artifact creates a new lifecycle without reviving stale handles', async () => { const { artifact, shaperWasm } = await fixture(); const registry = new FontRegistry(); From 0960247918a2a77fbe4d8df5dc059e24f1e15bc3 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 11:52:34 -0400 Subject: [PATCH 021/128] feat(text): direct policy input sources --- docs/log.md | 6 + docs/packages/text.md | 9 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 5 + packages/text/rust/shaper/src/abi_contract.rs | 55 ++++++++- .../rust/shaper/src/engine/ordered_plan.rs | 1 + .../text/rust/shaper/src/engine/policy.rs | 65 ++++++++++ .../shaper/src/engine/render_plan_compiler.rs | 1 + .../rust/shaper/src/engine/stable_plan.rs | 1 + packages/text/rust/shaper/src/engine/state.rs | 1 + packages/text/rust/shaper/src/engine/wire.rs | 111 ++++++++++++++++-- .../text/src/generated/text-shaper-abi.ts | 22 +++- .../render-policy-registration.test.mjs | 9 ++ packages/text/tests/support/engine-abi.mjs | 32 ++++- 14 files changed, 296 insertions(+), 23 deletions(-) diff --git a/docs/log.md b/docs/log.md index 6c3ebfd1..7ea3d364 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-08 +- **Made render-policy input shaping explicit data** — Policy programs now retain compiler-mapped source records for + every typed input lane, selecting numeric semantic, glyph, resource, or strike data without callbacks. Source order + is validated and fingerprinted; Rust and compiled-Wasm tests cover exact decoding, conflict, unknown/reserved data, + count mismatch, and overlap. The optimized ABI grows from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 + raw/gzip/Brotli bytes. Per-font binding tables and gather execution remain open, so there is no frame timing claim. + - **Registered ordered font-stack ownership in Rust** — Added cold, direct-memory font-stack lifecycle operations with nonempty/unique member validation, exact-order idempotence, conflict detection, and member-font retention. A compiled-Wasm test registers a real baked Inter font, proves disposal fails while its stack is live, releases the diff --git a/docs/packages/text.md b/docs/packages/text.md index 33ce3133..9b18a319 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:67818f68ec35bc04acd514ee699833fa69718d7f52710f63c1b2e08f28a0a5dd' +source_digest: 'sha256:72d23c0124450b49869babf957cd04c203e25a5cdecaacdb41911440659d95bd' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -520,6 +520,13 @@ fallback shaping remain the next slices; this registry alone makes no layout or vector registry produces an optimized 828,401 raw / 309,252 gzip / 244,402 Brotli-byte Wasm. A rejected generic tree-map version measured 837,865 / 312,057 / 246,478, so cold linear lookup avoids 9,464 raw and 2,076 Brotli bytes. +Render-policy registration now retains one exact input-source record per F32/U32 program field. Numeric scope tags +select semantic, glyph, resource, or strike lanes; source order is fingerprinted, so changing a gather recipe under an +existing policy handle fails atomically. The compiler-derived policy request/program/input layouts are 44/64/4 bytes, +and compiled-Wasm tests pin their offsets, tags, conflict behavior, and malformed-input rejection. The optimized module +is 829,906 raw / 309,646 gzip / 244,790 Brotli bytes, a +1,505 / +394 / +388 contract cost. Per-font tables and gather +execution remain open, so the measurement is module size rather than layout latency. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 01ff2479..b1927786 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -240,6 +240,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | | D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; when its production arrays land it prewarms once to 32,768 clusters/glyphs, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation, creates module state before any operational export, and will own that workspace reservation when its concrete SoA lanes land; it does not claim currently unimplemented arrays are prewarmed. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | | D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | +| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and will direct the production gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable contract changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388). Registration is implemented; executing the gather waits for the render-binding tables and carries no frame timing claim. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index a7864a53..0b584b88 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -146,6 +146,11 @@ renderer. - Render implementations register a versioned render-plan policy describing formats, batching compatibility, capabilities, patch preferences, and permitted augmentations. Stable policies are referenced by ID on updates rather than serialized every frame. +- Each program owns an explicit typed gather recipe. Ordered 4-byte records name `semantic`, `glyph`, `resource`, or + `strike` source lanes for every F32 field followed by every U32 field. This is numeric policy data, not a callback; + registration validates and fingerprints it so retained input meanings cannot change under one handle. The per-font + render binding owns the latter three lane families and layout owns semantic lanes. Registration is live, while gather + execution waits for those binding tables and therefore has no frame timing claim yet. - A loaded font owns its raster technique and resource binding. `FontStack`, `Text`, and `TextGroup` do not ask the user to repeat a technique: an ordered stack may contain fonts from different techniques in the same runtime, and the render policy declares which of those techniques its engine can lower. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 25a74ade..e0adac36 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -11,10 +11,10 @@ use crate::engine::policy::{ ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BUFFER_USAGE_VERTEX, CAP_ALIAS_VEC2, CAP_ALIAS_VEC4, CAP_INDIRECT_DRAWS, - CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, CAP_STORAGE_BUFFERS, OP_ADD_F32, OP_CONSTANT_F32, - OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, - OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, - ScalarType, + CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, CAP_STORAGE_BUFFERS, INPUT_GLYPH, INPUT_RESOURCE, + INPUT_SEMANTIC, INPUT_STRIKE, OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, + OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, + OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, ScalarType, }; use crate::engine::render_plan::{ BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT, BufferRecord, DiagnosticRecord, DrawRecord, @@ -71,6 +71,8 @@ struct PolicyRequestHeader { buffer_count: u32, operations_offset: u32, operation_count: u32, + inputs_offset: u32, + input_count: u32, } #[repr(C)] @@ -109,6 +111,16 @@ struct PolicyProgramRecord { u32_input_count: u8, reserved0: u16, draw_key_mask: u32, + input_start: u32, + input_count: u16, + reserved1: u16, +} + +#[repr(C)] +struct PolicyInputRecord { + scope: u8, + field: u8, + reserved: u16, } #[repr(C)] @@ -448,6 +460,11 @@ layout!( POLICY_OPERATION_RECORD_ALIGNMENT, PolicyOperationRecord ); +layout!( + POLICY_INPUT_RECORD_SIZE, + POLICY_INPUT_RECORD_ALIGNMENT, + PolicyInputRecord +); layout!( ENGINE_UPDATE_REQUEST_HEADER_SIZE, ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, @@ -575,6 +592,8 @@ field_offset!( operations_offset ); field_offset!(POLICY_OPERATION_COUNT, PolicyRequestHeader, operation_count); +field_offset!(POLICY_INPUTS_OFFSET, PolicyRequestHeader, inputs_offset); +field_offset!(POLICY_INPUT_COUNT, PolicyRequestHeader, input_count); field_offset!(POLICY_CAPABILITY_SET_ID, PolicyCapabilitySetRecord, id); field_offset!( POLICY_CAPABILITY_SET_FLAGS, @@ -709,6 +728,9 @@ field_offset!( PolicyProgramRecord, draw_key_mask ); +field_offset!(POLICY_PROGRAM_INPUT_START, PolicyProgramRecord, input_start); +field_offset!(POLICY_PROGRAM_INPUT_COUNT, PolicyProgramRecord, input_count); +field_offset!(POLICY_PROGRAM_RESERVED1, PolicyProgramRecord, reserved1); field_offset!(POLICY_BUFFER_ID, PolicyBufferRecord, id); field_offset!(POLICY_BUFFER_SCALAR, PolicyBufferRecord, scalar); field_offset!(POLICY_BUFFER_VECTOR_WIDTH, PolicyBufferRecord, vector_width); @@ -723,6 +745,9 @@ field_offset!( field_offset!(POLICY_BUFFER_RESERVED0, PolicyBufferRecord, reserved0); field_offset!(POLICY_OPERATION_OPCODE, PolicyOperationRecord, opcode); field_offset!(POLICY_OPERATION_TARGET, PolicyOperationRecord, target); +field_offset!(POLICY_INPUT_SCOPE, PolicyInputRecord, scope); +field_offset!(POLICY_INPUT_FIELD, PolicyInputRecord, field); +field_offset!(POLICY_INPUT_RESERVED, PolicyInputRecord, reserved); field_offset!(POLICY_OPERATION_OPERAND0, PolicyOperationRecord, operand0); field_offset!(POLICY_OPERATION_OPERAND1, PolicyOperationRecord, operand1); field_offset!( @@ -1693,7 +1718,9 @@ pub fn json() -> String { "buffersOffset": POLICY_BUFFERS_OFFSET, "bufferCount": POLICY_BUFFER_COUNT, "operationsOffset": POLICY_OPERATIONS_OFFSET, - "operationCount": POLICY_OPERATION_COUNT + "operationCount": POLICY_OPERATION_COUNT, + "inputsOffset": POLICY_INPUTS_OFFSET, + "inputCount": POLICY_INPUT_COUNT }, "policyCapabilitySet": { "size": POLICY_CAPABILITY_SET_RECORD_SIZE, @@ -1731,7 +1758,10 @@ pub fn json() -> String { "reserved0": POLICY_PROGRAM_RESERVED0, "operationStart": POLICY_PROGRAM_OPERATION_START, "operationCount": POLICY_PROGRAM_OPERATION_COUNT, - "allocationStrategy": POLICY_PROGRAM_ALLOCATION_STRATEGY + "allocationStrategy": POLICY_PROGRAM_ALLOCATION_STRATEGY, + "inputStart": POLICY_PROGRAM_INPUT_START, + "inputCount": POLICY_PROGRAM_INPUT_COUNT, + "reserved1": POLICY_PROGRAM_RESERVED1 }, "policyBuffer": { "size": POLICY_BUFFER_RECORD_SIZE, @@ -1756,6 +1786,13 @@ pub fn json() -> String { "immediate1": POLICY_OPERATION_IMMEDIATE1, "immediate2": POLICY_OPERATION_IMMEDIATE2 }, + "policyInput": { + "size": POLICY_INPUT_RECORD_SIZE, + "alignment": POLICY_INPUT_RECORD_ALIGNMENT, + "scope": POLICY_INPUT_SCOPE, + "field": POLICY_INPUT_FIELD, + "reserved": POLICY_INPUT_RESERVED + }, "engineUpdateRequest": { "size": ENGINE_UPDATE_REQUEST_HEADER_SIZE, "alignment": ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, @@ -2192,6 +2229,12 @@ pub fn json() -> String { "u32": ScalarType::U32 as u8, "u16": ScalarType::U16 as u8 }, + "inputScopes": { + "semantic": INPUT_SEMANTIC, + "glyph": INPUT_GLYPH, + "resource": INPUT_RESOURCE, + "strike": INPUT_STRIKE + }, "opcodes": { "loadF32": OP_LOAD_F32, "loadU32": OP_LOAD_U32, diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index 000a539d..4f37c2b0 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -1505,6 +1505,7 @@ mod tests { allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], capabilities: ProgramCapabilities::default(), buffers: vec![BufferSchema::packed( BufferId(1), diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 00c712b4..ea70909e 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -10,6 +10,7 @@ pub const MAX_CAPABILITY_SETS: usize = 8; pub const MAX_BUFFERS_PER_PROGRAM: usize = 16; pub const MAX_OPERATIONS_PER_PROGRAM: usize = 128; pub const MAX_REGISTERS: usize = 32; +pub const MAX_INPUT_FIELDS_PER_PROGRAM: usize = MAX_REGISTERS * 2; pub const MAX_VECTOR_WIDTH: u8 = 4; const MAX_OUTPUT_LANES: usize = MAX_BUFFERS_PER_PROGRAM * MAX_VECTOR_WIDTH as usize; const NOT_A_STORE: u8 = u8::MAX; @@ -67,6 +68,11 @@ pub const OP_STORE_F32: u8 = 11; pub const OP_STORE_U32: u8 = 12; pub const OP_STORE_U16: u8 = 13; +pub const INPUT_SEMANTIC: u8 = 1; +pub const INPUT_GLYPH: u8 = 2; +pub const INPUT_RESOURCE: u8 = 3; +pub const INPUT_STRIKE: u8 = 4; + const UNINITIALIZED: u8 = 0; const F32_REGISTER: u8 = 1; const U32_REGISTER: u8 = 2; @@ -161,6 +167,30 @@ pub struct ProgramCapabilities { pub compositing: u32, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub enum InputScope { + Semantic = INPUT_SEMANTIC, + Glyph = INPUT_GLYPH, + Resource = INPUT_RESOURCE, + Strike = INPUT_STRIKE, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InputSource { + pub scope: InputScope, + pub field: u8, +} + +impl InputSource { + pub const fn semantic(field: u8) -> Self { + Self { + scope: InputScope::Semantic, + field, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub enum Operation { LoadF32 { @@ -240,6 +270,8 @@ pub struct ProgramDescriptor { pub allocation_strategy: u16, pub f32_input_count: u8, pub u32_input_count: u8, + /// Ordered F32 sources followed by ordered U32 sources. + pub inputs: Vec, pub capabilities: ProgramCapabilities, pub buffers: Vec, pub operations: Vec, @@ -387,6 +419,11 @@ fn policy_fingerprint(descriptor: &PolicyDescriptor) -> u64 { mix_u32(&mut fingerprint, u32::from(program.variant)); mix_u32(&mut fingerprint, u32::from(program.f32_input_count)); mix_u32(&mut fingerprint, u32::from(program.u32_input_count)); + mix_u32(&mut fingerprint, program.inputs.len() as u32); + for input in &program.inputs { + mix_u32(&mut fingerprint, input.scope as u32); + mix_u32(&mut fingerprint, u32::from(input.field)); + } mix_u32(&mut fingerprint, program.capabilities.paint); mix_u32(&mut fingerprint, program.capabilities.compositing); mix_u32(&mut fingerprint, program.buffers.len() as u32); @@ -559,6 +596,7 @@ pub enum PolicyError { InvalidBatchKey, UnsupportedAllocationStrategy, TooManyInputFields, + InvalidInputSources, EmptyBuffers, TooManyBuffers, InvalidBufferId, @@ -1056,6 +1094,11 @@ fn validate_program(program: &ProgramDescriptor) -> Result<(), PolicyError> { { return Err(PolicyError::TooManyInputFields); } + if program.inputs.len() + != usize::from(program.f32_input_count) + usize::from(program.u32_input_count) + { + return Err(PolicyError::InvalidInputSources); + } if program.buffers.is_empty() { return Err(PolicyError::EmptyBuffers); } @@ -1317,6 +1360,7 @@ mod tests { allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 2, u32_input_count: 0, + inputs: vec![InputSource::semantic(0), InputSource::semantic(1)], capabilities: ProgramCapabilities::default(), buffers: vec![BufferSchema::packed( ORIGINS, @@ -1358,6 +1402,26 @@ mod tests { assert_eq!(policy.program(CAPABILITY, BITMAP, 1), None); } + #[test] + fn input_sources_are_exact_and_participate_in_policy_identity() { + let program = valid_program(); + let first = ValidatedPolicy::new(descriptor(vec![program.clone()])).unwrap(); + let mut changed = program.clone(); + changed.inputs[1] = InputSource { + scope: InputScope::Glyph, + field: 0, + }; + let second = ValidatedPolicy::new(descriptor(vec![changed])).unwrap(); + assert_ne!(first.fingerprint(), second.fingerprint()); + + let mut missing = program; + missing.inputs.pop(); + assert_eq!( + ValidatedPolicy::new(descriptor(vec![missing])), + Err(PolicyError::InvalidInputSources) + ); + } + #[test] fn fingerprints_exact_validated_policy_content() { let first = ValidatedPolicy::new(descriptor(vec![valid_program()])).unwrap(); @@ -1606,6 +1670,7 @@ mod tests { allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 1, + inputs: vec![InputSource::semantic(0), InputSource::semantic(0)], capabilities: ProgramCapabilities::default(), buffers: vec![ BufferSchema::packed( diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index a0a42872..3d236b1f 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -912,6 +912,7 @@ mod tests { allocation_strategy, f32_input_count: 1, u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], capabilities: ProgramCapabilities::default(), buffers: vec![BufferSchema::packed( BufferId(1), diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 718dc091..cca628dc 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -1998,6 +1998,7 @@ mod tests { allocation_strategy: ALLOCATION_STABLE_INDIRECT, f32_input_count: 1, u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], capabilities: ProgramCapabilities::default(), buffers: vec![BufferSchema::packed( BufferId(1), diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index bb4a1874..8b8c2504 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -775,6 +775,7 @@ mod tests { allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, + inputs: vec![crate::engine::policy::InputSource::semantic(0)], capabilities: ProgramCapabilities::default(), buffers: vec![BufferSchema::packed( BufferId(1), diff --git a/packages/text/rust/shaper/src/engine/wire.rs b/packages/text/rust/shaper/src/engine/wire.rs index e6f948c3..c22e5cc0 100644 --- a/packages/text/rust/shaper/src/engine/wire.rs +++ b/packages/text/rust/shaper/src/engine/wire.rs @@ -22,6 +22,8 @@ use crate::{ POLICY_CAPABILITY_SET_RECORD_SIZE, POLICY_CAPABILITY_SET_RESERVED, POLICY_CAPABILITY_SET_UPDATE_ALIGNMENT, POLICY_CAPABILITY_SET_WHOLE_BUFFER_THRESHOLD_BASIS_POINTS, POLICY_CAPABILITY_SETS_OFFSET, + POLICY_INPUT_COUNT, POLICY_INPUT_FIELD, POLICY_INPUT_RECORD_ALIGNMENT, + POLICY_INPUT_RECORD_SIZE, POLICY_INPUT_RESERVED, POLICY_INPUT_SCOPE, POLICY_INPUTS_OFFSET, POLICY_OPERATION_COUNT, POLICY_OPERATION_IMMEDIATE0, POLICY_OPERATION_IMMEDIATE1, POLICY_OPERATION_IMMEDIATE2, POLICY_OPERATION_OPCODE, POLICY_OPERATION_OPERAND0, POLICY_OPERATION_OPERAND1, POLICY_OPERATION_RECORD_ALIGNMENT, POLICY_OPERATION_RECORD_SIZE, @@ -29,20 +31,23 @@ use crate::{ POLICY_PROGRAM_BUFFER_COUNT, POLICY_PROGRAM_BUFFER_START, POLICY_PROGRAM_CAPABILITY_SET_ID, POLICY_PROGRAM_COMPOSITING_CAPABILITIES, POLICY_PROGRAM_COUNT, POLICY_PROGRAM_DRAW_KEY_MASK, POLICY_PROGRAM_F32_INPUT_COUNT, POLICY_PROGRAM_ID, - POLICY_PROGRAM_OPERATION_COUNT, POLICY_PROGRAM_OPERATION_START, - POLICY_PROGRAM_PAINT_CAPABILITIES, POLICY_PROGRAM_RECORD_ALIGNMENT, - POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, POLICY_PROGRAM_RESOURCE_KIND_MASK, + POLICY_PROGRAM_INPUT_COUNT, POLICY_PROGRAM_INPUT_START, POLICY_PROGRAM_OPERATION_COUNT, + POLICY_PROGRAM_OPERATION_START, POLICY_PROGRAM_PAINT_CAPABILITIES, + POLICY_PROGRAM_RECORD_ALIGNMENT, POLICY_PROGRAM_RECORD_SIZE, POLICY_PROGRAM_RESERVED0, + POLICY_PROGRAM_RESERVED1, POLICY_PROGRAM_RESOURCE_KIND_MASK, POLICY_PROGRAM_SEMANTIC_VIEW_MASK, POLICY_PROGRAM_STORAGE_KEY_MASK, POLICY_PROGRAM_TECHNIQUE_ID, POLICY_PROGRAM_U32_INPUT_COUNT, POLICY_PROGRAM_VARIANT, POLICY_PROGRAMS_OFFSET, POLICY_REQUEST_HEADER_SIZE, }, engine::policy::{ - BufferId, BufferSchema, CapabilitySet, CapabilitySetId, MAX_BUFFERS_PER_PROGRAM, - MAX_CAPABILITY_SETS, MAX_OPERATIONS_PER_PROGRAM, MAX_PROGRAMS, OP_ADD_F32, OP_CONSTANT_F32, - OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, - OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, - Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, - TechniqueId, ValidatedPolicy, + BufferId, BufferSchema, CapabilitySet, CapabilitySetId, INPUT_GLYPH, INPUT_RESOURCE, + INPUT_SEMANTIC, INPUT_STRIKE, InputScope, InputSource, MAX_BUFFERS_PER_PROGRAM, + MAX_CAPABILITY_SETS, MAX_INPUT_FIELDS_PER_PROGRAM, MAX_OPERATIONS_PER_PROGRAM, + MAX_PROGRAMS, OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, + OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, + OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, Operation, PolicyDescriptor, + ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, TechniqueId, + ValidatedPolicy, }, wire::{array, read_u16, read_u32}, }; @@ -59,6 +64,7 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { let program_count = read_u32(bytes, POLICY_PROGRAM_COUNT)?; let buffer_count = read_u32(bytes, POLICY_BUFFER_COUNT)?; let operation_count = read_u32(bytes, POLICY_OPERATION_COUNT)?; + let input_count = read_u32(bytes, POLICY_INPUT_COUNT)?; if usize::try_from(capability_set_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_CAPABILITY_SETS || usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_PROGRAMS @@ -66,6 +72,8 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { > MAX_PROGRAMS * MAX_BUFFERS_PER_PROGRAM || usize::try_from(operation_count).map_err(|_| STATUS_INVALID_REQUEST)? > MAX_PROGRAMS * MAX_OPERATIONS_PER_PROGRAM + || usize::try_from(input_count).map_err(|_| STATUS_INVALID_REQUEST)? + > MAX_PROGRAMS * MAX_INPUT_FIELDS_PER_PROGRAM { return Err(STATUS_INVALID_REQUEST); } @@ -100,7 +108,21 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { POLICY_OPERATION_RECORD_SIZE, POLICY_OPERATION_RECORD_ALIGNMENT, )?; - reject_overlaps(bytes, capability_sets, programs, buffers, operations)?; + let inputs = table( + bytes, + read_u32(bytes, POLICY_INPUTS_OFFSET)?, + input_count, + POLICY_INPUT_RECORD_SIZE, + POLICY_INPUT_RECORD_ALIGNMENT, + )?; + reject_overlaps( + bytes, + capability_sets, + programs, + buffers, + operations, + inputs, + )?; let capability_sets = decode_capability_sets(capability_sets)?; @@ -109,7 +131,9 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { .try_reserve_exact(usize::try_from(program_count).map_err(|_| STATUS_INVALID_REQUEST)?) .map_err(|_| STATUS_INVALID_REQUEST)?; for record in programs.chunks_exact(POLICY_PROGRAM_RECORD_SIZE as usize) { - if read_u16(record, POLICY_PROGRAM_RESERVED0)? != 0 { + if read_u16(record, POLICY_PROGRAM_RESERVED0)? != 0 + || read_u16(record, POLICY_PROGRAM_RESERVED1)? != 0 + { return Err(STATUS_INVALID_REQUEST); } let selected_buffers = indexed_records( @@ -124,6 +148,12 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { read_u16(record, POLICY_PROGRAM_OPERATION_COUNT)?, POLICY_OPERATION_RECORD_SIZE, )?; + let selected_inputs = indexed_records( + inputs, + read_u32(record, POLICY_PROGRAM_INPUT_START)?, + read_u16(record, POLICY_PROGRAM_INPUT_COUNT)?, + POLICY_INPUT_RECORD_SIZE, + )?; decoded.push(ProgramDescriptor { technique: TechniqueId(read_u32(record, POLICY_PROGRAM_TECHNIQUE_ID)?), variant: read_u16(record, POLICY_PROGRAM_VARIANT)?, @@ -136,6 +166,7 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { allocation_strategy: read_u16(record, POLICY_PROGRAM_ALLOCATION_STRATEGY)?, f32_input_count: byte(record, POLICY_PROGRAM_F32_INPUT_COUNT)?, u32_input_count: byte(record, POLICY_PROGRAM_U32_INPUT_COUNT)?, + inputs: decode_inputs(selected_inputs)?, capabilities: ProgramCapabilities { paint: read_u32(record, POLICY_PROGRAM_PAINT_CAPABILITIES)?, compositing: read_u32(record, POLICY_PROGRAM_COMPOSITING_CAPABILITIES)?, @@ -152,6 +183,13 @@ pub(crate) fn parse_policy(bytes: &[u8]) -> Result { } fn table(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { + if count == 0 { + return if offset == 0 { + Ok(&bytes[..0]) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } if offset < POLICY_REQUEST_HEADER_SIZE { return Err(STATUS_INVALID_REQUEST); } @@ -164,12 +202,14 @@ fn reject_overlaps( programs: &[u8], buffers: &[u8], operations: &[u8], + inputs: &[u8], ) -> Result<(), u32> { let ranges = [ relative_range(bytes, capability_sets)?, relative_range(bytes, programs)?, relative_range(bytes, buffers)?, relative_range(bytes, operations)?, + relative_range(bytes, inputs)?, ]; for index in 0..ranges.len() { for previous in &ranges[..index] { @@ -182,6 +222,30 @@ fn reject_overlaps( Ok(()) } +fn decode_inputs(records: &[u8]) -> Result, u32> { + let mut inputs = Vec::new(); + inputs + .try_reserve_exact(records.len() / POLICY_INPUT_RECORD_SIZE as usize) + .map_err(|_| STATUS_INVALID_REQUEST)?; + for record in records.chunks_exact(POLICY_INPUT_RECORD_SIZE as usize) { + if read_u16(record, POLICY_INPUT_RESERVED)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let scope = match byte(record, POLICY_INPUT_SCOPE)? { + INPUT_SEMANTIC => InputScope::Semantic, + INPUT_GLYPH => InputScope::Glyph, + INPUT_RESOURCE => InputScope::Resource, + INPUT_STRIKE => InputScope::Strike, + _ => return Err(STATUS_INVALID_REQUEST), + }; + inputs.push(InputSource { + scope, + field: byte(record, POLICY_INPUT_FIELD)?, + }); + } + Ok(inputs) +} + fn decode_capability_sets(records: &[u8]) -> Result, u32> { let mut capability_sets = Vec::new(); capability_sets @@ -414,8 +478,10 @@ mod tests { const BUFFERS_OFFSET: usize = PROGRAMS_OFFSET + POLICY_PROGRAM_RECORD_SIZE as usize; const OPERATIONS_OFFSET: usize = BUFFERS_OFFSET + POLICY_BUFFER_RECORD_SIZE as usize; const OPERATION_COUNT: usize = 4; - const BYTE_LENGTH: usize = + const INPUTS_OFFSET: usize = OPERATIONS_OFFSET + OPERATION_COUNT * POLICY_OPERATION_RECORD_SIZE as usize; + const INPUT_COUNT: usize = 2; + const BYTE_LENGTH: usize = INPUTS_OFFSET + INPUT_COUNT * POLICY_INPUT_RECORD_SIZE as usize; #[test] fn decodes_compiler_mapped_policy_records() { @@ -427,6 +493,16 @@ mod tests { assert_eq!(program.id, ProgramId(2)); assert_eq!(program.buffers[0].stride(), 8); assert_eq!(program.operations.len(), OPERATION_COUNT); + assert_eq!( + program.inputs, + vec![ + InputSource::semantic(0), + InputSource { + scope: InputScope::Glyph, + field: 1, + }, + ] + ); } #[test] @@ -450,6 +526,10 @@ mod tests { 1, ); assert_eq!(parse_policy(&reserved), Err(STATUS_INVALID_REQUEST)); + + let mut unknown_input = valid_policy_bytes(); + unknown_input[INPUTS_OFFSET + POLICY_INPUT_SCOPE] = u8::MAX; + assert_eq!(parse_policy(&unknown_input), Err(STATUS_INVALID_REQUEST)); } #[test] @@ -482,6 +562,8 @@ mod tests { OPERATIONS_OFFSET as u32, ); put_u32(&mut bytes, POLICY_OPERATION_COUNT, OPERATION_COUNT as u32); + put_u32(&mut bytes, POLICY_INPUTS_OFFSET, INPUTS_OFFSET as u32); + put_u32(&mut bytes, POLICY_INPUT_COUNT, INPUT_COUNT as u32); let capability = &mut bytes[CAPABILITY_SETS_OFFSET..PROGRAMS_OFFSET]; put_u32(capability, POLICY_CAPABILITY_SET_ID, 1); @@ -532,6 +614,7 @@ mod tests { POLICY_PROGRAM_OPERATION_COUNT, OPERATION_COUNT as u16, ); + put_u16(program, POLICY_PROGRAM_INPUT_COUNT, INPUT_COUNT as u16); let buffer = &mut bytes[BUFFERS_OFFSET..OPERATIONS_OFFSET]; put_u16(buffer, POLICY_BUFFER_ID, 1); @@ -550,6 +633,10 @@ mod tests { write_operation(&mut bytes, 1, OP_LOAD_F32, 1, 1, 0, 0); write_operation(&mut bytes, 2, OP_STORE_F32, 0, 0, 0, 1); write_operation(&mut bytes, 3, OP_STORE_F32, 0, 1, 1, 1); + bytes[INPUTS_OFFSET + POLICY_INPUT_SCOPE] = INPUT_SEMANTIC; + bytes[INPUTS_OFFSET + POLICY_INPUT_FIELD] = 0; + bytes[INPUTS_OFFSET + POLICY_INPUT_RECORD_SIZE as usize + POLICY_INPUT_SCOPE] = INPUT_GLYPH; + bytes[INPUTS_OFFSET + POLICY_INPUT_RECORD_SIZE as usize + POLICY_INPUT_FIELD] = 1; bytes } diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 5efc0a9b..5458a05d 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -512,6 +512,13 @@ export const textShaperAbi = { "updateAlignment": 12, "wholeBufferThresholdBasisPoints": 32 }, + "policyInput": { + "alignment": 2, + "field": 1, + "reserved": 2, + "scope": 0, + "size": 4 + }, "policyOperation": { "alignment": 4, "immediate0": 4, @@ -532,14 +539,17 @@ export const textShaperAbi = { "compositingCapabilities": 28, "drawKeyMask": 52, "f32InputCount": 48, + "inputCount": 60, + "inputStart": 56, "operationCount": 44, "operationStart": 36, "paintCapabilities": 24, "programId": 4, "reserved0": 50, + "reserved1": 62, "resourceKindMask": 12, "semanticViewMask": 16, - "size": 56, + "size": 64, "storageKeyMask": 20, "techniqueId": 0, "u32InputCount": 49, @@ -552,11 +562,13 @@ export const textShaperAbi = { "byteLength": 0, "capabilitySetCount": 8, "capabilitySetsOffset": 4, + "inputCount": 40, + "inputsOffset": 36, "operationCount": 32, "operationsOffset": 28, "programCount": 16, "programsOffset": 12, - "size": 36 + "size": 44 }, "reshapeRange": { "alignment": 4, @@ -650,6 +662,12 @@ export const textShaperAbi = { "stableIndirect": 32, "storageBuffers": 1 }, + "inputScopes": { + "glyph": 2, + "resource": 3, + "semantic": 1, + "strike": 4 + }, "opcodes": { "addF32": 5, "constantF32": 3, diff --git a/packages/text/tests/integration/render-policy-registration.test.mjs b/packages/text/tests/integration/render-policy-registration.test.mjs index 7f57c421..f059f160 100644 --- a/packages/text/tests/integration/render-policy-registration.test.mjs +++ b/packages/text/tests/integration/render-policy-registration.test.mjs @@ -23,6 +23,10 @@ test('registers compiler-mapped render policies as retained typed Wasm state', a assert.equal(typeof register, 'function'); assert.equal(typeof dispose, 'function'); assert.equal(typeof count, 'function'); + assert.equal(abi.layouts.policyRequest.size, 44); + assert.equal(abi.layouts.policyProgram.size, 64); + assert.deepEqual(abi.layouts.policyInput, { alignment: 2, field: 1, reserved: 2, scope: 0, size: 4 }); + assert.deepEqual(abi.policy.inputScopes, { glyph: 2, resource: 3, semantic: 1, strike: 4 }); const bytes = renderPolicyBytes(abi); const pointer = allocate(bytes.byteLength); @@ -36,6 +40,11 @@ test('registers compiler-mapped render policies as retained typed Wasm state', a const request = abi.layouts.policyRequest; const program = abi.layouts.policyProgram; + const input = abi.layouts.policyInput; + const inputsOffset = new DataView(bytes.buffer).getUint32(request.inputsOffset, true); + new DataView(memory.buffer).setUint8(pointer + inputsOffset + input.scope, abi.policy.inputScopes.glyph); + assert.equal(register(7, pointer, bytes.byteLength), abi.status.policyConflict); + new DataView(memory.buffer).setUint8(pointer + inputsOffset + input.scope, abi.policy.inputScopes.semantic); const programsOffset = new DataView(bytes.buffer).getUint32(request.programsOffset, true); new DataView(memory.buffer).setUint32(pointer + programsOffset + program.techniqueId, 2, true); assert.equal(register(7, pointer, bytes.byteLength), abi.status.policyConflict); diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 79c0b2df..01ee9a2c 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -127,8 +127,17 @@ function policyBytes(abi, programs) { const programLayout = abi.layouts.policyProgram; const bufferLayout = abi.layouts.policyBuffer; const operationLayout = abi.layouts.policyOperation; + const inputLayout = abi.layouts.policyInput; const bufferCount = programs.reduce((total, program) => total + program.buffers.length, 0); const operationCount = programs.reduce((total, program) => total + program.operations.length, 0); + const programInputs = programs.map( + (program) => + program.inputs ?? [ + ...Array.from({ length: program.f32InputCount }, (_, field) => ({ scope: 'semantic', field })), + ...Array.from({ length: program.u32InputCount }, (_, field) => ({ scope: 'semantic', field })), + ], + ); + const inputCount = programInputs.reduce((total, inputs) => total + inputs.length, 0); const capabilities = [ { id: 1, @@ -154,7 +163,13 @@ function policyBytes(abi, programs) { ); const buffersOffset = align(programsOffset + programLayout.size * programs.length, bufferLayout.alignment); const operationsOffset = align(buffersOffset + bufferLayout.size * bufferCount, operationLayout.alignment); - const bytes = new Uint8Array(operationsOffset + operationLayout.size * operationCount); + const inputsOffset = + inputCount === 0 ? 0 : align(operationsOffset + operationLayout.size * operationCount, inputLayout.alignment); + const byteLength = + inputCount === 0 + ? operationsOffset + operationLayout.size * operationCount + : inputsOffset + inputLayout.size * inputCount; + const bytes = new Uint8Array(byteLength); const view = new DataView(bytes.buffer); view.setUint32(requestLayout.byteLength, bytes.byteLength, true); view.setUint32(requestLayout.capabilitySetsOffset, capabilitiesOffset, true); @@ -165,6 +180,8 @@ function policyBytes(abi, programs) { view.setUint32(requestLayout.bufferCount, bufferCount, true); view.setUint32(requestLayout.operationsOffset, operationsOffset, true); view.setUint32(requestLayout.operationCount, operationCount, true); + view.setUint32(requestLayout.inputsOffset, inputsOffset, true); + view.setUint32(requestLayout.inputCount, inputCount, true); for (let index = 0; index < capabilities.length; index += 1) { const descriptor = capabilities[index]; @@ -188,6 +205,7 @@ function policyBytes(abi, programs) { let bufferStart = 0; let operationStart = 0; + let inputStart = 0; for (let index = 0; index < programs.length; index += 1) { const descriptor = programs[index]; const offset = programsOffset + index * programLayout.size; @@ -225,12 +243,16 @@ function policyBytes(abi, programs) { descriptor.allocationStrategy ?? abi.policy.allocationStrategies.orderedDirect, true, ); + view.setUint32(offset + programLayout.inputStart, inputStart, true); + view.setUint16(offset + programLayout.inputCount, programInputs[index].length, true); bufferStart += descriptor.buffers.length; operationStart += descriptor.operations.length; + inputStart += programInputs[index].length; } let bufferIndex = 0; let operationIndex = 0; - for (const descriptor of programs) { + let inputIndex = 0; + for (const [programIndex, descriptor] of programs.entries()) { for (const buffer of descriptor.buffers) { const offset = buffersOffset + bufferIndex * bufferLayout.size; view.setUint16(offset + bufferLayout.id, buffer.id, true); @@ -258,6 +280,12 @@ function policyBytes(abi, programs) { view.setUint32(offset + operationLayout.immediate2, operation.immediate2 ?? 0, true); operationIndex += 1; } + for (const input of programInputs[programIndex]) { + const offset = inputsOffset + inputIndex * inputLayout.size; + view.setUint8(offset + inputLayout.scope, abi.policy.inputScopes[input.scope]); + view.setUint8(offset + inputLayout.field, input.field); + inputIndex += 1; + } } return bytes; } From fa5bafeb3a34c9743da5e9b7b213e3d3cbddb481 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 12:11:33 -0400 Subject: [PATCH 022/128] feat(text): register font render bindings --- docs/log.md | 7 + docs/packages/text.md | 16 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 15 +- packages/text/rust/shaper/src/abi_contract.rs | 241 +++++++++++ .../rust/shaper/src/engine/font_binding.rs | 397 ++++++++++++++++++ .../shaper/src/engine/font_binding_wire.rs | 374 +++++++++++++++++ packages/text/rust/shaper/src/engine/mod.rs | 3 + packages/text/rust/shaper/src/engine/state.rs | 110 +++++ packages/text/rust/shaper/src/lib.rs | 6 + packages/text/rust/shaper/src/wasm.rs | 43 +- .../text/src/generated/text-shaper-abi.ts | 46 ++ .../integration/shaper-registration.test.mjs | 30 ++ packages/text/tests/support/engine-abi.mjs | 89 ++++ 14 files changed, 1370 insertions(+), 8 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/font_binding.rs create mode 100644 packages/text/rust/shaper/src/engine/font_binding_wire.rs diff --git a/docs/log.md b/docs/log.md index 7ea3d364..cb243ee4 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Registered normalized per-font render bindings in Rust** — Added a cold compiler-mapped ABI for one font-owned + technique/program variant, field-major glyph/strike/resource lanes, scalable or ordered physical strikes, dense + strike×glyph resource selection, and exact shaping-coverage validation. Rust hostile-wire and strike-selection tests + pass; compiled Wasm registers a binding against real baked Inter, proves owned/idempotent state and conflict, retains + it through the stack lifecycle, and removes it with final font disposal. The optimized module changes from 829,906 / + 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes. Policy gather and frame timing remain open. + - **Made render-policy input shaping explicit data** — Policy programs now retain compiler-mapped source records for every typed input lane, selecting numeric semantic, glyph, resource, or strike data without callbacks. Source order is validated and fingerprinted; Rust and compiled-Wasm tests cover exact decoding, conflict, unknown/reserved data, diff --git a/docs/packages/text.md b/docs/packages/text.md index 9b18a319..ef7098d1 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:72d23c0124450b49869babf957cd04c203e25a5cdecaacdb41911440659d95bd' +source_digest: 'sha256:0a4159ba082bac6e36ede27796435413d5baac4e15930500b51df468f575fbf3' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -524,8 +524,18 @@ Render-policy registration now retains one exact input-source record per F32/U32 select semantic, glyph, resource, or strike lanes; source order is fingerprinted, so changing a gather recipe under an existing policy handle fails atomically. The compiler-derived policy request/program/input layouts are 44/64/4 bytes, and compiled-Wasm tests pin their offsets, tags, conflict behavior, and malformed-input rejection. The optimized module -is 829,906 raw / 309,646 gzip / 244,790 Brotli bytes, a +1,505 / +394 / +388 contract cost. Per-font tables and gather -execution remain open, so the measurement is module size rather than layout latency. +is 829,906 raw / 309,646 gzip / 244,790 Brotli bytes, a +1,505 / +394 / +388 contract cost. + +Per-font render bindings now cross a separate cold compiler-mapped ABI and become owned Rust engine state. A binding +contains one technique/program variant, dense field-major glyph lanes, scalable or strictly ordered physical strikes, +dense strike×glyph resource addresses and lanes, and a resource directory with its own field lanes. This admits mixed +Bitmap/MSDF/Slug stacks without a universal union record or a technique repeated by `Text`. Selection is one bounded +nearest-strike pass with the lower exact tie, and MSDF/Slug take the one scalable-strike branch. Rust hostile-input tests +cover table shapes, overlap, reserved data, nonfinite floats, invalid resources, and selection; compiled Wasm uses the +real baked Inter glyph count to prove owned/idempotent registration, conflict, stack retention, and disposal. The +optimized module is 838,060 raw / 312,606 gzip / 246,732 Brotli bytes, +8,154 / +2,960 / +1,942 from the preceding +policy-source checkpoint. Policy-directed gather and frame use remain open, so this is a size/ownership result rather +than a layout-latency claim. The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index b1927786..3b26380e 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -241,6 +241,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; when its production arrays land it prewarms once to 32,768 clusters/glyphs, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation, creates module state before any operational export, and will own that workspace reservation when its concrete SoA lanes land; it does not claim currently unimplemented arrays are prewarmed. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | | D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | | D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and will direct the production gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable contract changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388). Registration is implemented; executing the gather waits for the render-binding tables and carries no frame timing claim. | Accepted | +| D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 0b584b88..b1d3f9f4 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -149,11 +149,22 @@ renderer. - Each program owns an explicit typed gather recipe. Ordered 4-byte records name `semantic`, `glyph`, `resource`, or `strike` source lanes for every F32 field followed by every U32 field. This is numeric policy data, not a callback; registration validates and fingerprints it so retained input meanings cannot change under one handle. The per-font - render binding owns the latter three lane families and layout owns semantic lanes. Registration is live, while gather - execution waits for those binding tables and therefore has no frame timing claim yet. + render binding owns the latter three lane families and layout owns semantic lanes. Both policy and binding + registration are live, while gather execution waits for the Rust layout connection and therefore has no frame timing + claim yet. - A loaded font owns its raster technique and resource binding. `FontStack`, `Text`, and `TextGroup` do not ask the user to repeat a technique: an ordered stack may contain fonts from different techniques in the same runtime, and the render policy declares which of those techniques its engine can lower. + +Per-font render data is normalized without paying for a union of every built-in technique's fields. One immutable +binding owns a technique/program variant, a dense font-wide glyph table, one or more strikes, a dense strike×glyph +address table, and a resource directory. MSDF and Slug use one scalable strike identified by zero ppem; Bitmap uses +strictly increasing physical ppem strikes and selects the nearest to `fontSize × rasterPixelRatio`, retaining the lower +strike at an exact tie. Every strike×glyph row selects a resource or carries the missing sentinel. Glyph, selected-strike, +and selected-resource F32/U32 data are field-major SoA tables with at most 32 lanes per scalar kind, so four neighboring +glyphs remain directly gatherable by the policy executor. The cold compiler-mapped decoder rejects noncanonical strikes, +unsorted/invalid resources, nonfinite floats, invalid indices, field-shape mismatch, reserved data, overlap, and shaping +glyph-count mismatch before publication. - Result publication uses A/B Wasm buffers for synchronous reads only. A retained or asynchronous result is copied into a worker-owned transferable `ArrayBuffer`; root returns ownership of that same buffer to the worker on retirement so pooling or garbage collection occurs on the worker rather than root. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index e0adac36..f13c8991 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -123,6 +123,50 @@ struct PolicyInputRecord { reserved: u16, } +#[repr(C)] +struct FontBindingRequestHeader { + abi_version: u32, + byte_length: u32, + technique_id: u32, + program_variant: u16, + reserved0: u16, + glyph_count: u32, + strike_count: u32, + resource_count: u32, + glyph_f32_field_count: u8, + glyph_u32_field_count: u8, + strike_f32_field_count: u8, + strike_u32_field_count: u8, + resource_f32_field_count: u8, + resource_u32_field_count: u8, + reserved1: u16, + strikes_offset: u32, + resources_offset: u32, + resource_indices_offset: u32, + glyph_f32_offset: u32, + glyph_u32_offset: u32, + strike_f32_offset: u32, + strike_u32_offset: u32, + resource_f32_offset: u32, + resource_u32_offset: u32, + reserved2: u32, +} + +#[repr(C)] +struct FontBindingStrikeRecord { + ppem: u32, + reserved: u32, +} + +#[repr(C)] +struct FontBindingResourceRecord { + id: u32, + generation: u32, + kind: u16, + reserved: u16, + reference: u32, +} + #[repr(C)] struct PolicyBufferRecord { id: u16, @@ -465,6 +509,21 @@ layout!( POLICY_INPUT_RECORD_ALIGNMENT, PolicyInputRecord ); +layout!( + FONT_BINDING_REQUEST_HEADER_SIZE, + FONT_BINDING_REQUEST_HEADER_ALIGNMENT, + FontBindingRequestHeader +); +layout!( + FONT_BINDING_STRIKE_RECORD_SIZE, + FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + FontBindingStrikeRecord +); +layout!( + FONT_BINDING_RESOURCE_RECORD_SIZE, + FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, + FontBindingResourceRecord +); layout!( ENGINE_UPDATE_REQUEST_HEADER_SIZE, ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, @@ -748,6 +807,142 @@ field_offset!(POLICY_OPERATION_TARGET, PolicyOperationRecord, target); field_offset!(POLICY_INPUT_SCOPE, PolicyInputRecord, scope); field_offset!(POLICY_INPUT_FIELD, PolicyInputRecord, field); field_offset!(POLICY_INPUT_RESERVED, PolicyInputRecord, reserved); +field_offset!( + FONT_BINDING_ABI_VERSION, + FontBindingRequestHeader, + abi_version +); +field_offset!( + FONT_BINDING_BYTE_LENGTH, + FontBindingRequestHeader, + byte_length +); +field_offset!( + FONT_BINDING_TECHNIQUE_ID, + FontBindingRequestHeader, + technique_id +); +field_offset!( + FONT_BINDING_PROGRAM_VARIANT, + FontBindingRequestHeader, + program_variant +); +field_offset!(FONT_BINDING_RESERVED0, FontBindingRequestHeader, reserved0); +field_offset!( + FONT_BINDING_GLYPH_COUNT, + FontBindingRequestHeader, + glyph_count +); +field_offset!( + FONT_BINDING_STRIKE_COUNT, + FontBindingRequestHeader, + strike_count +); +field_offset!( + FONT_BINDING_RESOURCE_COUNT, + FontBindingRequestHeader, + resource_count +); +field_offset!( + FONT_BINDING_GLYPH_F32_FIELD_COUNT, + FontBindingRequestHeader, + glyph_f32_field_count +); +field_offset!( + FONT_BINDING_GLYPH_U32_FIELD_COUNT, + FontBindingRequestHeader, + glyph_u32_field_count +); +field_offset!( + FONT_BINDING_STRIKE_F32_FIELD_COUNT, + FontBindingRequestHeader, + strike_f32_field_count +); +field_offset!( + FONT_BINDING_STRIKE_U32_FIELD_COUNT, + FontBindingRequestHeader, + strike_u32_field_count +); +field_offset!( + FONT_BINDING_RESOURCE_F32_FIELD_COUNT, + FontBindingRequestHeader, + resource_f32_field_count +); +field_offset!( + FONT_BINDING_RESOURCE_U32_FIELD_COUNT, + FontBindingRequestHeader, + resource_u32_field_count +); +field_offset!(FONT_BINDING_RESERVED1, FontBindingRequestHeader, reserved1); +field_offset!( + FONT_BINDING_STRIKES_OFFSET, + FontBindingRequestHeader, + strikes_offset +); +field_offset!( + FONT_BINDING_RESOURCES_OFFSET, + FontBindingRequestHeader, + resources_offset +); +field_offset!( + FONT_BINDING_RESOURCE_INDICES_OFFSET, + FontBindingRequestHeader, + resource_indices_offset +); +field_offset!( + FONT_BINDING_GLYPH_F32_OFFSET, + FontBindingRequestHeader, + glyph_f32_offset +); +field_offset!( + FONT_BINDING_GLYPH_U32_OFFSET, + FontBindingRequestHeader, + glyph_u32_offset +); +field_offset!( + FONT_BINDING_STRIKE_F32_OFFSET, + FontBindingRequestHeader, + strike_f32_offset +); +field_offset!( + FONT_BINDING_STRIKE_U32_OFFSET, + FontBindingRequestHeader, + strike_u32_offset +); +field_offset!( + FONT_BINDING_RESOURCE_F32_OFFSET, + FontBindingRequestHeader, + resource_f32_offset +); +field_offset!( + FONT_BINDING_RESOURCE_U32_OFFSET, + FontBindingRequestHeader, + resource_u32_offset +); +field_offset!(FONT_BINDING_RESERVED2, FontBindingRequestHeader, reserved2); +field_offset!(FONT_BINDING_STRIKE_PPEM, FontBindingStrikeRecord, ppem); +field_offset!( + FONT_BINDING_STRIKE_RESERVED, + FontBindingStrikeRecord, + reserved +); +field_offset!(FONT_BINDING_RESOURCE_ID, FontBindingResourceRecord, id); +field_offset!( + FONT_BINDING_RESOURCE_GENERATION, + FontBindingResourceRecord, + generation +); +field_offset!(FONT_BINDING_RESOURCE_KIND, FontBindingResourceRecord, kind); +field_offset!( + FONT_BINDING_RESOURCE_RESERVED, + FontBindingResourceRecord, + reserved +); +field_offset!( + FONT_BINDING_RESOURCE_REFERENCE, + FontBindingResourceRecord, + reference +); field_offset!(POLICY_OPERATION_OPERAND0, PolicyOperationRecord, operand0); field_offset!(POLICY_OPERATION_OPERAND1, PolicyOperationRecord, operand1); field_offset!( @@ -1665,6 +1860,8 @@ pub fn json() -> String { "registerFontStack": "pmndrs_text_engine_register_font_stack", "disposeFontStack": "pmndrs_text_engine_dispose_font_stack", "fontStackCount": "pmndrs_text_engine_font_stack_count", + "registerFontBinding": "pmndrs_text_engine_register_font_binding", + "fontBindingCount": "pmndrs_text_engine_font_binding_count", "registerPolicy": "pmndrs_text_engine_register_policy", "disposePolicy": "pmndrs_text_engine_dispose_policy", "policyCount": "pmndrs_text_engine_policy_count", @@ -1793,6 +1990,50 @@ pub fn json() -> String { "field": POLICY_INPUT_FIELD, "reserved": POLICY_INPUT_RESERVED }, + "fontBindingRequest": { + "size": FONT_BINDING_REQUEST_HEADER_SIZE, + "alignment": FONT_BINDING_REQUEST_HEADER_ALIGNMENT, + "abiVersion": FONT_BINDING_ABI_VERSION, + "byteLength": FONT_BINDING_BYTE_LENGTH, + "techniqueId": FONT_BINDING_TECHNIQUE_ID, + "programVariant": FONT_BINDING_PROGRAM_VARIANT, + "reserved0": FONT_BINDING_RESERVED0, + "glyphCount": FONT_BINDING_GLYPH_COUNT, + "strikeCount": FONT_BINDING_STRIKE_COUNT, + "resourceCount": FONT_BINDING_RESOURCE_COUNT, + "glyphF32FieldCount": FONT_BINDING_GLYPH_F32_FIELD_COUNT, + "glyphU32FieldCount": FONT_BINDING_GLYPH_U32_FIELD_COUNT, + "strikeF32FieldCount": FONT_BINDING_STRIKE_F32_FIELD_COUNT, + "strikeU32FieldCount": FONT_BINDING_STRIKE_U32_FIELD_COUNT, + "resourceF32FieldCount": FONT_BINDING_RESOURCE_F32_FIELD_COUNT, + "resourceU32FieldCount": FONT_BINDING_RESOURCE_U32_FIELD_COUNT, + "reserved1": FONT_BINDING_RESERVED1, + "strikesOffset": FONT_BINDING_STRIKES_OFFSET, + "resourcesOffset": FONT_BINDING_RESOURCES_OFFSET, + "resourceIndicesOffset": FONT_BINDING_RESOURCE_INDICES_OFFSET, + "glyphF32Offset": FONT_BINDING_GLYPH_F32_OFFSET, + "glyphU32Offset": FONT_BINDING_GLYPH_U32_OFFSET, + "strikeF32Offset": FONT_BINDING_STRIKE_F32_OFFSET, + "strikeU32Offset": FONT_BINDING_STRIKE_U32_OFFSET, + "resourceF32Offset": FONT_BINDING_RESOURCE_F32_OFFSET, + "resourceU32Offset": FONT_BINDING_RESOURCE_U32_OFFSET, + "reserved2": FONT_BINDING_RESERVED2 + }, + "fontBindingStrike": { + "size": FONT_BINDING_STRIKE_RECORD_SIZE, + "alignment": FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + "ppem": FONT_BINDING_STRIKE_PPEM, + "reserved": FONT_BINDING_STRIKE_RESERVED + }, + "fontBindingResource": { + "size": FONT_BINDING_RESOURCE_RECORD_SIZE, + "alignment": FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, + "id": FONT_BINDING_RESOURCE_ID, + "generation": FONT_BINDING_RESOURCE_GENERATION, + "kind": FONT_BINDING_RESOURCE_KIND, + "reserved": FONT_BINDING_RESOURCE_RESERVED, + "reference": FONT_BINDING_RESOURCE_REFERENCE + }, "engineUpdateRequest": { "size": ENGINE_UPDATE_REQUEST_HEADER_SIZE, "alignment": ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, diff --git a/packages/text/rust/shaper/src/engine/font_binding.rs b/packages/text/rust/shaper/src/engine/font_binding.rs new file mode 100644 index 00000000..47b45b09 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/font_binding.rs @@ -0,0 +1,397 @@ +//! Normalized renderer data owned by one shaping font. + +use alloc::vec::Vec; + +use super::policy::TechniqueId; + +pub const MAX_BINDING_FIELDS: u8 = 32; +pub const MISSING_RESOURCE_INDEX: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FontResource { + pub id: u32, + pub generation: u32, + pub kind: u16, + pub reference: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct FontStrike { + /// Pixels per em. Zero identifies the sole scalable strike used by MSDF and Slug. + pub ppem: u32, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FieldTable { + row_count: u32, + field_count: u8, + /// Field-major values: every field is one contiguous `row_count` lane. + values: Vec, +} + +impl FieldTable { + pub fn new(row_count: u32, field_count: u8, values: Vec) -> Result { + let expected = usize::try_from(row_count) + .ok() + .and_then(|rows| rows.checked_mul(usize::from(field_count))) + .ok_or(FontBindingError::ArithmeticOverflow)?; + if field_count > MAX_BINDING_FIELDS || values.len() != expected { + return Err(FontBindingError::InvalidFieldTable); + } + Ok(Self { + row_count, + field_count, + values, + }) + } + + pub fn row_count(&self) -> u32 { + self.row_count + } + + pub fn field_count(&self) -> u8 { + self.field_count + } + + pub fn field(&self, field: u8) -> Option<&[T]> { + if field >= self.field_count { + return None; + } + let rows = usize::try_from(self.row_count).ok()?; + let start = usize::from(field).checked_mul(rows)?; + self.values.get(start..start.checked_add(rows)?) + } + + pub fn values(&self) -> &[T] { + &self.values + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct FontRenderBinding { + technique: TechniqueId, + program_variant: u16, + glyph_count: u32, + strikes: Vec, + resources: Vec, + resource_indices: Vec, + glyph_f32: FieldTable, + glyph_u32: FieldTable, + strike_f32: FieldTable, + strike_u32: FieldTable, + resource_f32: FieldTable, + resource_u32: FieldTable, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SelectedGlyphBinding { + pub strike: u32, + pub strike_row: u32, + pub resource: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FontBindingError { + InvalidTechnique, + InvalidGlyphCount, + InvalidStrikes, + InvalidResources, + InvalidResourceIndex, + InvalidFieldTable, + ArithmeticOverflow, +} + +#[allow(clippy::too_many_arguments)] +impl FontRenderBinding { + pub fn new( + technique: TechniqueId, + program_variant: u16, + glyph_count: u32, + strikes: Vec, + resources: Vec, + resource_indices: Vec, + glyph_f32: FieldTable, + glyph_u32: FieldTable, + strike_f32: FieldTable, + strike_u32: FieldTable, + resource_f32: FieldTable, + resource_u32: FieldTable, + ) -> Result { + if technique.0 == 0 { + return Err(FontBindingError::InvalidTechnique); + } + if glyph_count == 0 { + return Err(FontBindingError::InvalidGlyphCount); + } + validate_strikes(&strikes)?; + validate_resources(&resources)?; + let strike_rows = glyph_count + .checked_mul( + u32::try_from(strikes.len()).map_err(|_| FontBindingError::ArithmeticOverflow)?, + ) + .ok_or(FontBindingError::ArithmeticOverflow)?; + if resource_indices.len() != usize::try_from(strike_rows).unwrap_or(usize::MAX) + || resource_indices.iter().any(|&resource| { + resource != MISSING_RESOURCE_INDEX + && usize::try_from(resource).map_or(true, |index| index >= resources.len()) + }) + { + return Err(FontBindingError::InvalidResourceIndex); + } + let resource_count = + u32::try_from(resources.len()).map_err(|_| FontBindingError::ArithmeticOverflow)?; + if glyph_f32.row_count() != glyph_count + || glyph_u32.row_count() != glyph_count + || strike_f32.row_count() != strike_rows + || strike_u32.row_count() != strike_rows + || resource_f32.row_count() != resource_count + || resource_u32.row_count() != resource_count + { + return Err(FontBindingError::InvalidFieldTable); + } + Ok(Self { + technique, + program_variant, + glyph_count, + strikes, + resources, + resource_indices, + glyph_f32, + glyph_u32, + strike_f32, + strike_u32, + resource_f32, + resource_u32, + }) + } + + pub fn technique(&self) -> TechniqueId { + self.technique + } + + pub fn program_variant(&self) -> u16 { + self.program_variant + } + + pub fn glyph_count(&self) -> u32 { + self.glyph_count + } + + pub fn strikes(&self) -> &[FontStrike] { + &self.strikes + } + + pub fn resources(&self) -> &[FontResource] { + &self.resources + } + + pub fn glyph_f32(&self) -> &FieldTable { + &self.glyph_f32 + } + + pub fn glyph_u32(&self) -> &FieldTable { + &self.glyph_u32 + } + + pub fn strike_f32(&self) -> &FieldTable { + &self.strike_f32 + } + + pub fn strike_u32(&self) -> &FieldTable { + &self.strike_u32 + } + + pub fn resource_f32(&self) -> &FieldTable { + &self.resource_f32 + } + + pub fn resource_u32(&self) -> &FieldTable { + &self.resource_u32 + } + + pub fn select( + &self, + glyph: u32, + font_size: f32, + raster_pixel_ratio: f32, + ) -> Option { + if glyph >= self.glyph_count + || !font_size.is_finite() + || font_size <= 0.0 + || !raster_pixel_ratio.is_finite() + || raster_pixel_ratio <= 0.0 + { + return None; + } + let target = font_size * raster_pixel_ratio; + if !target.is_finite() { + return None; + } + let strike = if self.strikes.len() == 1 && self.strikes[0].ppem == 0 { + 0 + } else { + nearest_strike(&self.strikes, target) + }; + let strike = u32::try_from(strike).ok()?; + let strike_row = strike.checked_mul(self.glyph_count)?.checked_add(glyph)?; + let resource = *self + .resource_indices + .get(usize::try_from(strike_row).ok()?)?; + (resource != MISSING_RESOURCE_INDEX).then_some(SelectedGlyphBinding { + strike, + strike_row, + resource, + }) + } +} + +fn validate_strikes(strikes: &[FontStrike]) -> Result<(), FontBindingError> { + if strikes.is_empty() || strikes.len() > usize::from(u16::MAX) { + return Err(FontBindingError::InvalidStrikes); + } + if strikes[0].ppem == 0 { + return if strikes.len() == 1 { + Ok(()) + } else { + Err(FontBindingError::InvalidStrikes) + }; + } + if strikes.windows(2).any(|pair| pair[0].ppem >= pair[1].ppem) { + return Err(FontBindingError::InvalidStrikes); + } + Ok(()) +} + +fn validate_resources(resources: &[FontResource]) -> Result<(), FontBindingError> { + if resources.is_empty() + || resources.len() > usize::from(u16::MAX) + || resources.iter().any(|resource| { + resource.id == 0 || resource.generation == 0 || !(1..=32).contains(&resource.kind) + }) + || resources.windows(2).any(|pair| pair[0].id >= pair[1].id) + { + return Err(FontBindingError::InvalidResources); + } + Ok(()) +} + +fn nearest_strike(strikes: &[FontStrike], target: f32) -> usize { + let mut selected = 0; + let mut distance = ((strikes[0].ppem as f64) - f64::from(target)).abs(); + for (index, strike) in strikes.iter().enumerate().skip(1) { + let candidate = ((strike.ppem as f64) - f64::from(target)).abs(); + if candidate < distance { + selected = index; + distance = candidate; + } + } + selected +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + fn table(rows: u32, fields: u8, values: Vec) -> FieldTable { + FieldTable::new(rows, fields, values).unwrap() + } + + fn binding(strikes: Vec, indices: Vec) -> FontRenderBinding { + let glyph_count = 3; + let strike_rows = glyph_count * strikes.len() as u32; + FontRenderBinding::new( + TechniqueId(7), + 2, + glyph_count, + strikes, + vec![ + FontResource { + id: 11, + generation: 1, + kind: 2, + reference: 91, + }, + FontResource { + id: 12, + generation: 1, + kind: 2, + reference: 92, + }, + ], + indices, + table(glyph_count, 1, vec![1.0, 2.0, 3.0]), + table(glyph_count, 0, vec![]), + table(strike_rows, 0, vec![]), + table(strike_rows, 1, (0..strike_rows).collect()), + table(2, 0, vec![]), + table(2, 1, vec![100, 200]), + ) + .unwrap() + } + + #[test] + fn scalable_and_bitmap_selection_share_one_dense_address_model() { + let scalable = binding(vec![FontStrike { ppem: 0 }], vec![0, 0, 1]); + assert_eq!( + scalable.select(2, 300.0, 2.0), + Some(SelectedGlyphBinding { + strike: 0, + strike_row: 2, + resource: 1, + }) + ); + + let bitmap = binding( + vec![FontStrike { ppem: 16 }, FontStrike { ppem: 32 }], + vec![0, 0, MISSING_RESOURCE_INDEX, 1, 1, 0], + ); + assert_eq!(bitmap.select(1, 12.0, 2.0).unwrap().strike, 0); + assert_eq!(bitmap.select(1, 12.1, 2.0).unwrap().strike, 1); + assert_eq!(bitmap.select(2, 8.0, 2.0), None); + assert_eq!(bitmap.select(2, 16.0, 2.0).unwrap().resource, 0); + } + + #[test] + fn rejects_noncanonical_strikes_resources_and_field_shapes() { + let glyph = table(1, 0, Vec::::new()); + let empty_u32 = table(1, 0, Vec::::new()); + assert_eq!( + FontRenderBinding::new( + TechniqueId(1), + 0, + 1, + vec![FontStrike { ppem: 0 }, FontStrike { ppem: 16 }], + vec![FontResource { + id: 1, + generation: 1, + kind: 1, + reference: 0, + }], + vec![0, 0], + glyph.clone(), + empty_u32.clone(), + table(2, 0, vec![]), + table(2, 0, vec![]), + table(1, 0, vec![]), + table(1, 0, vec![]), + ), + Err(FontBindingError::InvalidStrikes) + ); + assert_eq!( + FieldTable::new(2, 1, vec![0_u32]), + Err(FontBindingError::InvalidFieldTable) + ); + assert_eq!( + FieldTable::new(1, MAX_BINDING_FIELDS + 1, vec![0_u32; 33]), + Err(FontBindingError::InvalidFieldTable) + ); + + let mut unsorted = binding(vec![FontStrike { ppem: 0 }], vec![0, 0, 1]); + unsorted.resources.swap(0, 1); + assert_eq!( + validate_resources(&unsorted.resources), + Err(FontBindingError::InvalidResources) + ); + } +} diff --git a/packages/text/rust/shaper/src/engine/font_binding_wire.rs b/packages/text/rust/shaper/src/engine/font_binding_wire.rs new file mode 100644 index 00000000..c6d044d7 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/font_binding_wire.rs @@ -0,0 +1,374 @@ +//! Compiler-mapped cold decoder for normalized per-font renderer data. + +use alloc::vec::Vec; + +use crate::{ + STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, + abi_contract::{ + ABI_VERSION, FONT_BINDING_ABI_VERSION, FONT_BINDING_BYTE_LENGTH, FONT_BINDING_GLYPH_COUNT, + FONT_BINDING_GLYPH_F32_FIELD_COUNT, FONT_BINDING_GLYPH_F32_OFFSET, + FONT_BINDING_GLYPH_U32_FIELD_COUNT, FONT_BINDING_GLYPH_U32_OFFSET, + FONT_BINDING_PROGRAM_VARIANT, FONT_BINDING_REQUEST_HEADER_SIZE, FONT_BINDING_RESERVED0, + FONT_BINDING_RESERVED1, FONT_BINDING_RESERVED2, FONT_BINDING_RESOURCE_COUNT, + FONT_BINDING_RESOURCE_F32_FIELD_COUNT, FONT_BINDING_RESOURCE_F32_OFFSET, + FONT_BINDING_RESOURCE_GENERATION, FONT_BINDING_RESOURCE_ID, + FONT_BINDING_RESOURCE_INDICES_OFFSET, FONT_BINDING_RESOURCE_KIND, + FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, FONT_BINDING_RESOURCE_RECORD_SIZE, + FONT_BINDING_RESOURCE_REFERENCE, FONT_BINDING_RESOURCE_RESERVED, + FONT_BINDING_RESOURCE_U32_FIELD_COUNT, FONT_BINDING_RESOURCE_U32_OFFSET, + FONT_BINDING_RESOURCES_OFFSET, FONT_BINDING_STRIKE_COUNT, + FONT_BINDING_STRIKE_F32_FIELD_COUNT, FONT_BINDING_STRIKE_F32_OFFSET, + FONT_BINDING_STRIKE_PPEM, FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + FONT_BINDING_STRIKE_RECORD_SIZE, FONT_BINDING_STRIKE_RESERVED, + FONT_BINDING_STRIKE_U32_FIELD_COUNT, FONT_BINDING_STRIKE_U32_OFFSET, + FONT_BINDING_STRIKES_OFFSET, FONT_BINDING_TECHNIQUE_ID, + }, + engine::{ + font_binding::{ + FieldTable, FontRenderBinding, FontResource, FontStrike, MAX_BINDING_FIELDS, + }, + policy::TechniqueId, + }, + wire::{array, read_u16, read_u32}, +}; + +const MAX_GLYPHS: u32 = u16::MAX as u32; +const MAX_STRIKES: u32 = u16::MAX as u32; +const MAX_RESOURCES: u32 = u16::MAX as u32; + +pub(crate) fn parse_font_binding(bytes: &[u8]) -> Result { + if bytes.len() < FONT_BINDING_REQUEST_HEADER_SIZE as usize + || read_u32(bytes, FONT_BINDING_ABI_VERSION)? != ABI_VERSION + || read_u32(bytes, FONT_BINDING_BYTE_LENGTH)? + != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? + || read_u16(bytes, FONT_BINDING_RESERVED0)? != 0 + || read_u16(bytes, FONT_BINDING_RESERVED1)? != 0 + || read_u32(bytes, FONT_BINDING_RESERVED2)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let glyph_count = bounded_positive(bytes, FONT_BINDING_GLYPH_COUNT, MAX_GLYPHS)?; + let strike_count = bounded_positive(bytes, FONT_BINDING_STRIKE_COUNT, MAX_STRIKES)?; + let resource_count = bounded_positive(bytes, FONT_BINDING_RESOURCE_COUNT, MAX_RESOURCES)?; + let strike_rows = glyph_count + .checked_mul(strike_count) + .ok_or(STATUS_INVALID_REQUEST)?; + let field_counts = [ + byte(bytes, FONT_BINDING_GLYPH_F32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_GLYPH_U32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_STRIKE_F32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_STRIKE_U32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_RESOURCE_F32_FIELD_COUNT)?, + byte(bytes, FONT_BINDING_RESOURCE_U32_FIELD_COUNT)?, + ]; + if field_counts.iter().any(|&count| count > MAX_BINDING_FIELDS) { + return Err(STATUS_INVALID_REQUEST); + } + + let strikes = table( + bytes, + read_u32(bytes, FONT_BINDING_STRIKES_OFFSET)?, + strike_count, + FONT_BINDING_STRIKE_RECORD_SIZE, + FONT_BINDING_STRIKE_RECORD_ALIGNMENT, + )?; + let resources = table( + bytes, + read_u32(bytes, FONT_BINDING_RESOURCES_OFFSET)?, + resource_count, + FONT_BINDING_RESOURCE_RECORD_SIZE, + FONT_BINDING_RESOURCE_RECORD_ALIGNMENT, + )?; + let resource_indices = scalar_table(bytes, FONT_BINDING_RESOURCE_INDICES_OFFSET, strike_rows)?; + let glyph_f32 = scalar_table( + bytes, + FONT_BINDING_GLYPH_F32_OFFSET, + field_rows(glyph_count, field_counts[0])?, + )?; + let glyph_u32 = scalar_table( + bytes, + FONT_BINDING_GLYPH_U32_OFFSET, + field_rows(glyph_count, field_counts[1])?, + )?; + let strike_f32 = scalar_table( + bytes, + FONT_BINDING_STRIKE_F32_OFFSET, + field_rows(strike_rows, field_counts[2])?, + )?; + let strike_u32 = scalar_table( + bytes, + FONT_BINDING_STRIKE_U32_OFFSET, + field_rows(strike_rows, field_counts[3])?, + )?; + let resource_f32 = scalar_table( + bytes, + FONT_BINDING_RESOURCE_F32_OFFSET, + field_rows(resource_count, field_counts[4])?, + )?; + let resource_u32 = scalar_table( + bytes, + FONT_BINDING_RESOURCE_U32_OFFSET, + field_rows(resource_count, field_counts[5])?, + )?; + reject_overlaps( + bytes, + &[ + strikes, + resources, + resource_indices, + glyph_f32, + glyph_u32, + strike_f32, + strike_u32, + resource_f32, + resource_u32, + ], + )?; + + FontRenderBinding::new( + TechniqueId(read_u32(bytes, FONT_BINDING_TECHNIQUE_ID)?), + read_u16(bytes, FONT_BINDING_PROGRAM_VARIANT)?, + glyph_count, + decode_strikes(strikes)?, + decode_resources(resources)?, + decode_u32(resource_indices)?, + FieldTable::new(glyph_count, field_counts[0], decode_f32(glyph_f32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(glyph_count, field_counts[1], decode_u32(glyph_u32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(strike_rows, field_counts[2], decode_f32(strike_f32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(strike_rows, field_counts[3], decode_u32(strike_u32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(resource_count, field_counts[4], decode_f32(resource_f32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + FieldTable::new(resource_count, field_counts[5], decode_u32(resource_u32)?) + .map_err(|_| STATUS_INVALID_REQUEST)?, + ) + .map_err(|_| STATUS_INVALID_REQUEST) +} + +fn bounded_positive(bytes: &[u8], offset: usize, maximum: u32) -> Result { + let value = read_u32(bytes, offset)?; + if value == 0 || value > maximum { + return Err(STATUS_INVALID_REQUEST); + } + Ok(value) +} + +fn field_rows(rows: u32, fields: u8) -> Result { + rows.checked_mul(u32::from(fields)) + .ok_or(STATUS_INVALID_REQUEST) +} + +fn scalar_table(bytes: &[u8], offset_field: usize, count: u32) -> Result<&[u8], u32> { + table(bytes, read_u32(bytes, offset_field)?, count, 4, 4) +} + +fn table(bytes: &[u8], offset: u32, count: u32, stride: u32, alignment: u32) -> Result<&[u8], u32> { + if count == 0 { + return if offset == 0 { + Ok(&bytes[..0]) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < FONT_BINDING_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + array(bytes, offset, count, stride, alignment) +} + +fn reject_overlaps(bytes: &[u8], tables: &[&[u8]]) -> Result<(), u32> { + for (index, table) in tables.iter().enumerate() { + if table.is_empty() { + continue; + } + let current = relative_range(bytes, table)?; + for previous in &tables[..index] { + if previous.is_empty() { + continue; + } + let previous = relative_range(bytes, previous)?; + if current.0 < previous.1 && previous.0 < current.1 { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn relative_range(container: &[u8], selected: &[u8]) -> Result<(usize, usize), u32> { + let start = (selected.as_ptr() as usize) + .checked_sub(container.as_ptr() as usize) + .ok_or(STATUS_INVALID_REQUEST)?; + Ok(( + start, + start + .checked_add(selected.len()) + .ok_or(STATUS_INVALID_REQUEST)?, + )) +} + +fn decode_strikes(records: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(records.len() / FONT_BINDING_STRIKE_RECORD_SIZE as usize)?; + for record in records.chunks_exact(FONT_BINDING_STRIKE_RECORD_SIZE as usize) { + if read_u32(record, FONT_BINDING_STRIKE_RESERVED)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + decoded.push(FontStrike { + ppem: read_u32(record, FONT_BINDING_STRIKE_PPEM)?, + }); + } + Ok(decoded) +} + +fn decode_resources(records: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(records.len() / FONT_BINDING_RESOURCE_RECORD_SIZE as usize)?; + for record in records.chunks_exact(FONT_BINDING_RESOURCE_RECORD_SIZE as usize) { + if read_u16(record, FONT_BINDING_RESOURCE_RESERVED)? != 0 { + return Err(STATUS_INVALID_REQUEST); + } + decoded.push(FontResource { + id: read_u32(record, FONT_BINDING_RESOURCE_ID)?, + generation: read_u32(record, FONT_BINDING_RESOURCE_GENERATION)?, + kind: read_u16(record, FONT_BINDING_RESOURCE_KIND)?, + reference: read_u32(record, FONT_BINDING_RESOURCE_REFERENCE)?, + }); + } + Ok(decoded) +} + +fn decode_u32(bytes: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(bytes.len() / 4)?; + for value in bytes.chunks_exact(4) { + decoded.push(u32::from_le_bytes([value[0], value[1], value[2], value[3]])); + } + Ok(decoded) +} + +fn decode_f32(bytes: &[u8]) -> Result, u32> { + let mut decoded = reserved_vec(bytes.len() / 4)?; + for value in bytes.chunks_exact(4) { + let value = f32::from_bits(u32::from_le_bytes([value[0], value[1], value[2], value[3]])); + if !value.is_finite() { + return Err(STATUS_INVALID_REQUEST); + } + decoded.push(value); + } + Ok(decoded) +} + +fn reserved_vec(capacity: usize) -> Result, u32> { + let mut values = Vec::new(); + values + .try_reserve_exact(capacity) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + Ok(values) +} + +fn byte(bytes: &[u8], offset: usize) -> Result { + bytes.get(offset).copied().ok_or(STATUS_INVALID_REQUEST) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{STATUS_INVALID_REQUEST, wire::write_u32}; + + #[test] + fn decodes_exact_field_major_tables_and_dense_selection() { + let bytes = valid_bytes(); + let binding = parse_font_binding(&bytes).unwrap(); + assert_eq!(binding.technique(), TechniqueId(7)); + assert_eq!(binding.program_variant(), 2); + assert_eq!(binding.glyph_f32().field(0), Some(&[1.0, 2.0][..])); + assert_eq!(binding.glyph_u32().field(0), Some(&[10, 20][..])); + assert_eq!(binding.strike_f32().field(0), Some(&[3.0, 4.0][..])); + assert_eq!(binding.strike_u32().field(0), Some(&[30, 40][..])); + assert_eq!(binding.resource_f32().field(0), Some(&[5.0][..])); + assert_eq!(binding.resource_u32().field(0), Some(&[50][..])); + assert_eq!(binding.select(1, 12.0, 2.0).unwrap().resource, 0); + } + + #[test] + fn rejects_reserved_overlap_nonfinite_and_out_of_range_data() { + let mut reserved = valid_bytes(); + write_u32(&mut reserved, FONT_BINDING_RESERVED2, 1); + assert_eq!(parse_font_binding(&reserved), Err(STATUS_INVALID_REQUEST)); + + let mut overlap = valid_bytes(); + let indices = read_u32(&overlap, FONT_BINDING_RESOURCE_INDICES_OFFSET).unwrap(); + write_u32(&mut overlap, FONT_BINDING_GLYPH_F32_OFFSET, indices); + assert_eq!(parse_font_binding(&overlap), Err(STATUS_INVALID_REQUEST)); + + let mut nonfinite = valid_bytes(); + let offset = read_u32(&nonfinite, FONT_BINDING_GLYPH_F32_OFFSET).unwrap() as usize; + write_u32(&mut nonfinite, offset, f32::NAN.to_bits()); + assert_eq!(parse_font_binding(&nonfinite), Err(STATUS_INVALID_REQUEST)); + + let mut bad_index = valid_bytes(); + let offset = read_u32(&bad_index, FONT_BINDING_RESOURCE_INDICES_OFFSET).unwrap() as usize; + write_u32(&mut bad_index, offset, 1); + assert_eq!(parse_font_binding(&bad_index), Err(STATUS_INVALID_REQUEST)); + + let mut fields = valid_bytes(); + fields[FONT_BINDING_GLYPH_F32_FIELD_COUNT] = MAX_BINDING_FIELDS + 1; + assert_eq!(parse_font_binding(&fields), Err(STATUS_INVALID_REQUEST)); + } + + fn valid_bytes() -> Vec { + let mut bytes = vec![0; FONT_BINDING_REQUEST_HEADER_SIZE as usize]; + write_u32(&mut bytes, FONT_BINDING_ABI_VERSION, ABI_VERSION); + write_u32(&mut bytes, FONT_BINDING_TECHNIQUE_ID, 7); + bytes[FONT_BINDING_PROGRAM_VARIANT..FONT_BINDING_PROGRAM_VARIANT + 2] + .copy_from_slice(&2_u16.to_le_bytes()); + write_u32(&mut bytes, FONT_BINDING_GLYPH_COUNT, 2); + write_u32(&mut bytes, FONT_BINDING_STRIKE_COUNT, 1); + write_u32(&mut bytes, FONT_BINDING_RESOURCE_COUNT, 1); + for offset in [ + FONT_BINDING_GLYPH_F32_FIELD_COUNT, + FONT_BINDING_GLYPH_U32_FIELD_COUNT, + FONT_BINDING_STRIKE_F32_FIELD_COUNT, + FONT_BINDING_STRIKE_U32_FIELD_COUNT, + FONT_BINDING_RESOURCE_F32_FIELD_COUNT, + FONT_BINDING_RESOURCE_U32_FIELD_COUNT, + ] { + bytes[offset] = 1; + } + + let strikes = append(&mut bytes, &[0_u32, 0]); + let resources = append(&mut bytes, &[11, 1, 2, 91]); + let indices = append(&mut bytes, &[0, 0]); + let glyph_f32 = append(&mut bytes, &[1.0_f32.to_bits(), 2.0_f32.to_bits()]); + let glyph_u32 = append(&mut bytes, &[10, 20]); + let strike_f32 = append(&mut bytes, &[3.0_f32.to_bits(), 4.0_f32.to_bits()]); + let strike_u32 = append(&mut bytes, &[30, 40]); + let resource_f32 = append(&mut bytes, &[5.0_f32.to_bits()]); + let resource_u32 = append(&mut bytes, &[50]); + for (field, value) in [ + (FONT_BINDING_STRIKES_OFFSET, strikes), + (FONT_BINDING_RESOURCES_OFFSET, resources), + (FONT_BINDING_RESOURCE_INDICES_OFFSET, indices), + (FONT_BINDING_GLYPH_F32_OFFSET, glyph_f32), + (FONT_BINDING_GLYPH_U32_OFFSET, glyph_u32), + (FONT_BINDING_STRIKE_F32_OFFSET, strike_f32), + (FONT_BINDING_STRIKE_U32_OFFSET, strike_u32), + (FONT_BINDING_RESOURCE_F32_OFFSET, resource_f32), + (FONT_BINDING_RESOURCE_U32_OFFSET, resource_u32), + ] { + write_u32(&mut bytes, field, value); + } + let length = bytes.len() as u32; + write_u32(&mut bytes, FONT_BINDING_BYTE_LENGTH, length); + bytes + } + + fn append(bytes: &mut Vec, values: &[u32]) -> u32 { + let offset = bytes.len() as u32; + for value in values { + bytes.extend_from_slice(&value.to_le_bytes()); + } + offset + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index b04422ba..e7b0d170 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -3,6 +3,9 @@ //! The public types in this module are available to native consumers. Wasm memory ownership and //! pointer validation stay in the target-gated transport module. +pub mod font_binding; +#[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] +pub(crate) mod font_binding_wire; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod frame; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 8b8c2504..8386ce2f 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1,6 +1,7 @@ use alloc::{collections::BTreeMap, vec::Vec}; use super::{ + font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, plan_input::PlanInput, policy::{CapabilitySetId, ValidatedPolicy}, @@ -25,10 +26,16 @@ pub enum EngineError { #[derive(Default)] pub struct TextEngine { policies: BTreeMap, + font_bindings: Vec, font_stacks: Vec, sessions: BTreeMap, } +struct RegisteredFontBinding { + handle: u32, + binding: FontRenderBinding, +} + struct RegisteredFontStack { handle: u32, fonts: Vec, @@ -52,6 +59,55 @@ struct PolicyBinding { } impl TextEngine { + pub fn register_font_binding( + &mut self, + handle: u32, + shaping_glyph_count: u32, + binding: FontRenderBinding, + ) -> Result<(), EngineError> { + if handle == 0 || binding.glyph_count() != shaping_glyph_count { + return Err(EngineError::InvalidRequest); + } + if let Some(existing) = self + .font_bindings + .iter() + .find(|registered| registered.handle == handle) + { + return if existing.binding == binding { + Ok(()) + } else { + Err(EngineError::HandleConflict) + }; + } + self.font_bindings + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + self.font_bindings + .push(RegisteredFontBinding { handle, binding }); + Ok(()) + } + + pub fn dispose_font_binding(&mut self, handle: u32) { + if let Some(index) = self + .font_bindings + .iter() + .position(|binding| binding.handle == handle) + { + self.font_bindings.swap_remove(index); + } + } + + pub fn font_binding(&self, handle: u32) -> Option<&FontRenderBinding> { + self.font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| &binding.binding) + } + + pub fn font_binding_count(&self) -> u32 { + self.font_bindings.len().try_into().unwrap_or(u32::MAX) + } + pub fn register_font_stack(&mut self, handle: u32, fonts: &[u32]) -> Result<(), EngineError> { if handle == 0 || fonts.is_empty() @@ -455,6 +511,9 @@ mod tests { ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, engine::{ + font_binding::{ + FieldTable, FontRenderBinding, FontResource, FontStrike, MISSING_RESOURCE_INDEX, + }, frame::{TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16}, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, @@ -517,6 +576,26 @@ mod tests { ); } + #[test] + fn font_bindings_are_owned_once_per_font_and_match_shaping_coverage() { + let mut engine = TextEngine::default(); + let binding = render_binding(3, 7); + assert_eq!( + engine.register_font_binding(11, 4, binding.clone()), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.register_font_binding(11, 3, binding.clone()), Ok(())); + assert_eq!(engine.register_font_binding(11, 3, binding), Ok(())); + assert_eq!(engine.font_binding_count(), 1); + assert_eq!(engine.font_binding(11).unwrap().technique(), TechniqueId(7)); + assert_eq!( + engine.register_font_binding(11, 3, render_binding(3, 8)), + Err(EngineError::HandleConflict) + ); + engine.dispose_font_binding(11); + assert_eq!(engine.font_binding_count(), 0); + } + #[test] fn disposal_is_exact_and_missing_handles_are_observable() { let mut engine = TextEngine::default(); @@ -800,6 +879,37 @@ mod tests { .unwrap() } + fn render_binding(glyph_count: u32, technique: u32) -> FontRenderBinding { + FontRenderBinding::new( + TechniqueId(technique), + 0, + glyph_count, + vec![FontStrike { ppem: 0 }], + vec![FontResource { + id: 1, + generation: 1, + kind: 1, + reference: 0, + }], + (0..glyph_count) + .map(|glyph| { + if glyph == 0 { + MISSING_RESOURCE_INDEX + } else { + 0 + } + }) + .collect(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(glyph_count, 0, vec![]).unwrap(), + FieldTable::new(1, 0, vec![]).unwrap(), + FieldTable::new(1, 0, vec![]).unwrap(), + ) + .unwrap() + } + fn update( expected_engine_revision: u32, consumed_plan_revision: u32, diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index b73ff609..1a56153a 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -304,6 +304,12 @@ impl ShaperRegistry { self.fonts.contains_key(&handle) } + pub fn glyph_count(&self, handle: u32) -> Option { + self.fonts + .get(&handle) + .and_then(|font| u32::try_from(font.extents.len() / 8).ok()) + } + pub fn retained_font_bytes(&self) -> u32 { self.fonts .values() diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 98bc3667..53b990f5 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -7,8 +7,9 @@ use crate::{ STATUS_REVISION_CONFLICT, STATUS_SESSION_CONFLICT, STATUS_SESSION_MISSING, ShaperRegistry, bidi, engine::{ - EngineError, TextEngine, frame::SessionRevision, frame_wire::parse_update_request, - render_plan_wire::plan_layout, transport::FrameTransport, wire::parse_policy, + EngineError, TextEngine, font_binding_wire::parse_font_binding, frame::SessionRevision, + frame_wire::parse_update_request, render_plan_wire::plan_layout, transport::FrameTransport, + wire::parse_policy, }, wire::{ pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, @@ -81,7 +82,11 @@ pub extern "C" fn pmndrs_text_shaper_dispose_font(handle: u32) -> u32 { if state.engine.references_font(handle) { STATUS_FONT_IN_USE } else { - state.registry.dispose_font(handle) + let status = state.registry.dispose_font(handle); + if status == STATUS_OK { + state.engine.dispose_font_binding(handle); + } + status } }) } @@ -145,6 +150,38 @@ pub extern "C" fn pmndrs_text_engine_font_stack_count() -> u32 { with_state(|state| state.engine.font_stack_count()) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pmndrs_text_engine_register_font_binding( + handle: u32, + pointer: u32, + length: u32, +) -> u32 { + with_state(|state| { + let Some(glyph_count) = state.registry.glyph_count(handle) else { + return crate::STATUS_FONT_MISSING; + }; + let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { + return STATUS_INVALID_REQUEST; + }; + let binding = match parse_font_binding(bytes) { + Ok(binding) => binding, + Err(status) => return status, + }; + match state + .engine + .register_font_binding(handle, glyph_count, binding) + { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), + } + }) +} + +#[unsafe(no_mangle)] +pub extern "C" fn pmndrs_text_engine_font_binding_count() -> u32 { + with_state(|state| state.engine.font_binding_count()) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn pmndrs_text_engine_register_policy( handle: u32, diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 5458a05d..1c22a4bf 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -108,12 +108,14 @@ export const textShaperAbi = { "disposeFontStack": "pmndrs_text_engine_dispose_font_stack", "disposePolicy": "pmndrs_text_engine_dispose_policy", "disposeSession": "pmndrs_text_engine_dispose_session", + "fontBindingCount": "pmndrs_text_engine_font_binding_count", "fontCount": "pmndrs_text_shaper_font_count", "fontStackCount": "pmndrs_text_engine_font_stack_count", "initialize": "pmndrs_text_shaper_initialize", "planCount": "pmndrs_text_shaper_plan_count", "policyCount": "pmndrs_text_engine_policy_count", "registerFont": "pmndrs_text_shaper_register_font", + "registerFontBinding": "pmndrs_text_engine_register_font_binding", "registerFontStack": "pmndrs_text_engine_register_font_stack", "registerPolicy": "pmndrs_text_engine_register_policy", "requestCapacity": "pmndrs_text_engine_request_capacity", @@ -485,6 +487,50 @@ export const textShaperAbi = { "tag": 0, "value": 4 }, + "fontBindingRequest": { + "abiVersion": 0, + "alignment": 4, + "byteLength": 4, + "glyphCount": 16, + "glyphF32FieldCount": 28, + "glyphF32Offset": 48, + "glyphU32FieldCount": 29, + "glyphU32Offset": 52, + "programVariant": 12, + "reserved0": 14, + "reserved1": 34, + "reserved2": 72, + "resourceCount": 24, + "resourceF32FieldCount": 32, + "resourceF32Offset": 64, + "resourceIndicesOffset": 44, + "resourceU32FieldCount": 33, + "resourceU32Offset": 68, + "resourcesOffset": 40, + "size": 76, + "strikeCount": 20, + "strikeF32FieldCount": 30, + "strikeF32Offset": 56, + "strikeU32FieldCount": 31, + "strikeU32Offset": 60, + "strikesOffset": 36, + "techniqueId": 8 + }, + "fontBindingResource": { + "alignment": 4, + "generation": 4, + "id": 0, + "kind": 8, + "reference": 12, + "reserved": 10, + "size": 16 + }, + "fontBindingStrike": { + "alignment": 4, + "ppem": 0, + "reserved": 4, + "size": 8 + }, "policyBuffer": { "alignment": 4, "capacityClass": 12, diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 12c6209a..4432d76f 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -5,6 +5,7 @@ import test from 'node:test'; import { createRuntimeShaper, FontRegistry } from '@pmndrs/text'; import { createFontBaker } from '@pmndrs/text-font-baker'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { fontBindingBytes } from '../support/engine-abi.mjs'; const fixtureDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); const shaperWasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); @@ -109,6 +110,34 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo ); for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); + assert.deepEqual(abi.layouts.fontBindingStrike, { alignment: 4, ppem: 0, reserved: 4, size: 8 }); + assert.deepEqual(abi.layouts.fontBindingResource, { + alignment: 4, + generation: 4, + id: 0, + kind: 8, + reference: 12, + reserved: 10, + size: 16, + }); + const bindingBytes = fontBindingBytes(abi, { + techniqueId: 1, + glyphCount: validated.glyphExtents.byteLength / 8, + strikes: [0], + resources: [{ id: 71, generation: 1, kind: 1, reference: 19 }], + resourceIndices: new Array(validated.glyphExtents.byteLength / 8).fill(0), + glyphF32: [new Array(validated.glyphExtents.byteLength / 8).fill(1)], + }); + const binding = copyToWasm(memory, fn.allocate, bindingBytes); + assert.equal(fn.fontBindingCount(), 0); + assert.equal(fn.registerFontBinding(101, binding.pointer, binding.length), abi.status.ok); + assert.equal(fn.registerFontBinding(101, binding.pointer, binding.length), abi.status.ok); + assert.equal(fn.fontBindingCount(), 1); + new DataView(memory.buffer).setUint32(binding.pointer + abi.layouts.fontBindingRequest.techniqueId, 2, true); + assert.equal(fn.registerFontBinding(101, binding.pointer, binding.length), abi.status.policyConflict); + fn.deallocate(binding.pointer, binding.length); + assert.equal(fn.fontBindingCount(), 1, 'binding state must not borrow the registration allocation'); + const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(101, 0, 0, 0)); assert.equal(fn.registerFontStack(17, stack.pointer, 1), abi.status.ok); fn.deallocate(stack.pointer, stack.length); @@ -117,6 +146,7 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo assert.equal(fn.disposeFontStack(17), abi.status.ok); assert.equal(fn.disposeFontStack(17), abi.status.fontStackMissing); assert.equal(fn.disposeFont(101), abi.status.ok); + assert.equal(fn.fontBindingCount(), 0); }); test('shaper ownership stays scoped to its FontRegistry', async () => { diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 01ee9a2c..37401d8c 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -117,6 +117,95 @@ export function copyIntoAllocation(memory, allocate, bytes) { return pointer; } +export function fontBindingBytes( + abi, + { + techniqueId, + programVariant = 0, + glyphCount, + strikes, + resources, + resourceIndices, + glyphF32 = [], + glyphU32 = [], + strikeF32 = [], + strikeU32 = [], + resourceF32 = [], + resourceU32 = [], + }, +) { + const request = abi.layouts.fontBindingRequest; + const strikeLayout = abi.layouts.fontBindingStrike; + const resourceLayout = abi.layouts.fontBindingResource; + const strikeRows = glyphCount * strikes.length; + const fields = [ + ['glyphF32', glyphF32, glyphCount], + ['glyphU32', glyphU32, glyphCount], + ['strikeF32', strikeF32, strikeRows], + ['strikeU32', strikeU32, strikeRows], + ['resourceF32', resourceF32, resources.length], + ['resourceU32', resourceU32, resources.length], + ]; + for (const [name, lanes, rows] of fields) { + if (lanes.some((lane) => lane.length !== rows)) throw new RangeError(`${name} rows do not match the binding`); + } + if (resourceIndices.length !== strikeRows) throw new RangeError('resource index rows do not match the binding'); + + let length = request.size; + const allocateTable = (count, stride, alignment) => { + if (count === 0) return 0; + const offset = align(length, alignment); + length = offset + count * stride; + return offset; + }; + const strikesOffset = allocateTable(strikes.length, strikeLayout.size, strikeLayout.alignment); + const resourcesOffset = allocateTable(resources.length, resourceLayout.size, resourceLayout.alignment); + const resourceIndicesOffset = allocateTable(resourceIndices.length, 4, 4); + const fieldOffsets = fields.map(([, lanes, rows]) => allocateTable(lanes.length * rows, 4, 4)); + const bytes = new Uint8Array(length); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, abi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.techniqueId, techniqueId, true); + view.setUint16(request.programVariant, programVariant, true); + view.setUint32(request.glyphCount, glyphCount, true); + view.setUint32(request.strikeCount, strikes.length, true); + view.setUint32(request.resourceCount, resources.length, true); + for (const [index, [name, lanes]] of fields.entries()) { + view.setUint8(request[`${name}FieldCount`], lanes.length); + view.setUint32(request[`${name}Offset`], fieldOffsets[index], true); + } + view.setUint32(request.strikesOffset, strikesOffset, true); + view.setUint32(request.resourcesOffset, resourcesOffset, true); + view.setUint32(request.resourceIndicesOffset, resourceIndicesOffset, true); + + for (const [index, ppem] of strikes.entries()) { + view.setUint32(strikesOffset + index * strikeLayout.size + strikeLayout.ppem, ppem, true); + } + for (const [index, resource] of resources.entries()) { + const offset = resourcesOffset + index * resourceLayout.size; + view.setUint32(offset + resourceLayout.id, resource.id, true); + view.setUint32(offset + resourceLayout.generation, resource.generation, true); + view.setUint16(offset + resourceLayout.kind, resource.kind, true); + view.setUint32(offset + resourceLayout.reference, resource.reference, true); + } + for (const [index, value] of resourceIndices.entries()) { + view.setUint32(resourceIndicesOffset + index * 4, value, true); + } + for (const [fieldIndex, [, lanes]] of fields.entries()) { + let offset = fieldOffsets[fieldIndex]; + const f32 = fieldIndex === 0 || fieldIndex === 2 || fieldIndex === 4; + for (const lane of lanes) { + for (const value of lane) { + if (f32) view.setFloat32(offset, value, true); + else view.setUint32(offset, value, true); + offset += 4; + } + } + } + return bytes; +} + function align(value, alignment) { return Math.ceil(value / alignment) * alignment; } From 4cd3164e6a45119dc113e36ead0b38e2179aea13 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 12:33:46 -0400 Subject: [PATCH 023/128] feat(text): gather policy inputs in rust --- docs/log.md | 8 + docs/packages/text.md | 18 +- docs/planning/decision-register.md | 5 +- docs/planning/rust-layout-engine.md | 14 +- packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/policy_gather.rs | 666 ++++++++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 50 +- packages/text/rust/shaper/src/wasm.rs | 5 +- .../render-policy-registration.test.mjs | 11 + 9 files changed, 765 insertions(+), 13 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/policy_gather.rs diff --git a/docs/log.md b/docs/log.md index cb243ee4..8404c452 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Connected policy-directed gather to the Rust plan pipeline** — One reusable workspace now resolves every program's + semantic/glyph/strike/resource recipe into 16-byte-aligned four-record F32/U32 lanes and feeds the plan compiler. A + Rust proof emits a nonempty ordered plan with exact packed bytes across all source scopes and unchanged warm capacity. + Compiled Wasm reserves its policy-independent 32,768-entry plan-glyph arena at initialization (1,245,184→3,342,336 + bytes) and one declared F32 lane at policy registration (→3,538,944); both repeated operations are growth-free. The + production frame reaches the gather with empty layout input, so nonempty timing remains open. Optimized size changes + 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes. + - **Registered normalized per-font render bindings in Rust** — Added a cold compiler-mapped ABI for one font-owned technique/program variant, field-major glyph/strike/resource lanes, scalable or ordered physical strikes, dense strike×glyph resource selection, and exact shaping-coverage validation. Rust hostile-wire and strike-selection tests diff --git a/docs/packages/text.md b/docs/packages/text.md index ef7098d1..63e2fdfa 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:0a4159ba082bac6e36ede27796435413d5baac4e15930500b51df468f575fbf3' +source_digest: 'sha256:4eeac6683ea12cc696436df84aa88ebb803aff998185177230d47e838bfae5e3' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -509,8 +509,9 @@ accepts an explicit text capacity for known large paragraphs. This removes the o without giving every text object the 25K-glyph benchmark footprint. Production analysis/shaping/layout scratch will be one synchronous engine-global 32,768-record workspace, reserved once when those arrays land and shared by every session. The compiler-published `initialize()` export is invoked by the standard host immediately after Wasm instantiation, so -module-owned state no longer allocates behind the first operational export. This checkpoint does not yet reserve the -unimplemented shaping lanes and therefore makes no first-shape allocation or latency claim. +module-owned state and the first concrete plan-glyph arena no longer allocate behind the first operational export. +Policy-specific gather lanes settle during cold registration. HarfRust/Unicode/layout lanes remain unimplemented, so +this still makes no first-shape allocation or latency claim. Ordered font stacks now have cold Rust lifecycle operations independent from frame updates. A stack is nonempty, duplicate-free, idempotent only for the same ordered handles, and retains its already registered shaping fonts until @@ -537,6 +538,17 @@ optimized module is 838,060 raw / 312,606 gzip / 246,732 Brotli bytes, +8,154 / policy-source checkpoint. Policy-directed gather and frame use remain open, so this is a size/ownership result rather than a layout-latency claim. +The policy gather now resolves each glyph's program-specific recipe across semantic, font-wide glyph, selected-strike, +and selected-resource fields into shared field slots, then lends those lanes directly to the existing plan compiler. +F32/U32 lanes use 16-byte-aligned four-record blocks with scalar tails; programs with fewer fields receive zeroes only +in unused shared slots, avoiding a built-in-technique union. `initialize()` reserves the policy-independent 32,768 × +60-byte `PlanGlyph` arena: compiled Wasm memory moves from 1,245,184 to 3,342,336 bytes once and a repeated initializer +does not grow. Registering the one-F32-field integration policy then settles its exact lane from 3,342,336 to 3,538,944 +bytes; identical registration does not grow. Rust gathers all four source scopes through a nonempty ordered plan and +pins exact payload bytes without changing capacity. The production frame invokes the same gather but still supplies no +layout glyphs. The optimized artifact is 845,580 raw / 315,285 gzip / 249,221 Brotli bytes, +7,520 / +2,679 / +2,489 +from the binding checkpoint. Nonempty frame latency remains unclaimed. + The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 3b26380e..bc9c370d 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -238,10 +238,11 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | | D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | | D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | -| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; when its production arrays land it prewarms once to 32,768 clusters/glyphs, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation, creates module state before any operational export, and will own that workspace reservation when its concrete SoA lanes land; it does not claim currently unimplemented arrays are prewarmed. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | +| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; each production array prewarms once to 32,768 clusters/glyphs as it lands, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation and owns policy-independent workspace reservation; D-178 lands the first concrete plan-glyph arena while HarfRust/Unicode/layout arrays remain open. Policy registration cold-reserves its exact field lanes. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | | D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | -| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and will direct the production gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable contract changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388). Registration is implemented; executing the gather waits for the render-binding tables and carries no frame timing claim. | Accepted | +| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | | D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | +| D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index b1d3f9f4..25abc1ba 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -165,6 +165,15 @@ and selected-resource F32/U32 data are field-major SoA tables with at most 32 la glyphs remain directly gatherable by the policy executor. The cold compiler-mapped decoder rejects noncanonical strikes, unsorted/invalid resources, nonfinite floats, invalid indices, field-shape mismatch, reserved data, overlap, and shaping glyph-count mismatch before publication. + +The gather is one engine-global reusable workspace rather than one table per program or paragraph. For each glyph, its +selected program's ordered source recipe fills the same `0..N` F32/U32 field slots; the plan compilers already group +execution by program, so no union schema is required. Each typed lane is stored as contiguous four-record blocks with +16-byte alignment and a scalar tail. The 32,768-entry 60-byte internal `PlanGlyph` arena is policy-independent and is +reserved by module `initialize()`. Policy registration reserves only the maximum F32/U32 lane counts declared by that +policy, each to the same record capacity. The production empty-frame path now passes through this gather before plan +compilation; a Rust proof gathers all four source scopes into a nonempty ordered plan and asserts exact packed bytes. +Nonempty shaping/layout frame input is still open and no end-to-end timing is inferred from the synthetic proof. - Result publication uses A/B Wasm buffers for synchronous reads only. A retained or asynchronous result is copied into a worker-owned transferable `ArrayBuffer`; root returns ownership of that same buffer to the worker on retirement so pooling or garbage collection occurs on the worker rather than root. @@ -357,8 +366,9 @@ inside declared capacities may not lazily settle another allocation. Module initialization is explicit rather than an incidental side effect of the first operational export. The generated ABI publishes `initialize()`, and the standard host calls it immediately after `WebAssembly.instantiate`; this eagerly creates module-owned state before a font registration, session operation, or update can be observed. At the current -checkpoint this moves only the state allocation. The 32,768-record claim begins when the concrete production SoA lanes -are created and reserved by that initializer, not before. +checkpoint it creates module state and reserves the first concrete 32,768-entry render-plan gather arena. +Policy-specific aligned field lanes settle at cold policy registration. HarfRust, Unicode, cluster, line, and geometry +arrays remain unimplemented and therefore are not yet included in the initialization claim. ## Rust layout pipeline diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index e7b0d170..72fe8fbd 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -22,6 +22,7 @@ pub mod ordered_plan; pub mod plan_input; mod plan_packing; pub mod policy; +pub mod policy_gather; pub mod render_plan; pub mod render_plan_compiler; pub(crate) mod render_plan_wire; diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs new file mode 100644 index 00000000..d43ba5dc --- /dev/null +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -0,0 +1,666 @@ +//! Policy-directed gather from semantic layout and normalized font bindings. + +use alloc::vec::Vec; + +use super::{ + font_binding::{FontRenderBinding, SelectedGlyphBinding}, + plan_input::{PlanGlyph, PlanInput}, + policy::{CapabilitySetId, InputScope, MAX_REGISTERS, ProgramDescriptor, ValidatedPolicy}, +}; + +pub const DEFAULT_GATHER_RECORD_CAPACITY: usize = 32_768; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct LayoutGlyph { + pub stable_id: u32, + pub content_revision: u32, + pub font_handle: u32, + pub glyph_id: u32, + pub semantic_id: u32, + pub material_id: u32, + pub clip_id: u32, + pub depth_key: u32, + pub font_size: f32, + pub raster_pixel_ratio: f32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +#[derive(Clone, Copy)] +pub struct LayoutPlanInput<'a> { + pub glyphs: &'a [LayoutGlyph], + pub semantic_f32: &'a [&'a [f32]], + pub semantic_u32: &'a [&'a [u32]], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GatherError { + AllocationFailed, + InvalidSemanticShape, + FontBindingMissing, + GlyphBindingMissing, + ProgramMissing, + SourceFieldMissing, +} + +#[derive(Default)] +pub struct PolicyGatherWorkspace { + glyphs: Vec, + f32_fields: Vec>, + u32_fields: Vec>, +} + +#[repr(C, align(16))] +struct AlignedBlock { + values: [T; 4], +} + +struct AlignedField { + blocks: Vec>, + len: usize, +} + +pub struct GatheredPlanInput<'a> { + glyphs: &'a [PlanGlyph], + f32_fields: [&'a [f32]; MAX_REGISTERS], + u32_fields: [&'a [u32]; MAX_REGISTERS], + f32_field_count: usize, + u32_field_count: usize, +} + +impl PolicyGatherWorkspace { + pub fn reserve_records(&mut self, record_capacity: usize) -> Result<(), GatherError> { + reserve(&mut self.glyphs, record_capacity) + } + + pub fn reserve_policy( + &mut self, + policy: &ValidatedPolicy, + record_capacity: usize, + ) -> Result<(), GatherError> { + let f32_fields = policy + .programs() + .iter() + .map(|program| usize::from(program.f32_input_count)) + .max() + .unwrap_or(0); + let u32_fields = policy + .programs() + .iter() + .map(|program| usize::from(program.u32_input_count)) + .max() + .unwrap_or(0); + reserve_fields(&mut self.f32_fields, f32_fields, record_capacity)?; + reserve_fields(&mut self.u32_fields, u32_fields, record_capacity)?; + self.reserve_records(record_capacity)?; + Ok(()) + } + + pub fn gather<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + mut binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result<(), GatherError> { + validate_semantic_shape(input)?; + self.reserve_policy(policy, input.glyphs.len())?; + self.clear(); + for glyph_index in 0..input.glyphs.len() { + let glyph = input.glyphs[glyph_index]; + let binding = + binding_for_font(glyph.font_handle).ok_or(GatherError::FontBindingMissing)?; + let selected = binding + .select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio) + .ok_or(GatherError::GlyphBindingMissing)?; + let program = policy + .program( + capability_set, + binding.technique(), + binding.program_variant(), + ) + .ok_or(GatherError::ProgramMissing)?; + self.gather_fields(input, glyph_index, binding, selected, program)?; + let resource = binding + .resources() + .get( + usize::try_from(selected.resource) + .map_err(|_| GatherError::GlyphBindingMissing)?, + ) + .ok_or(GatherError::GlyphBindingMissing)?; + self.glyphs.push(PlanGlyph { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + technique: binding.technique(), + program_variant: binding.program_variant(), + resource_id: resource.id, + resource_generation: resource.generation, + resource_kind: resource.kind, + resource_reference: resource.reference, + semantic_id: glyph.semantic_id, + material_id: glyph.material_id, + clip_id: glyph.clip_id, + depth_key: glyph.depth_key, + inline_start: glyph.inline_start, + block_start: glyph.block_start, + inline_extent: glyph.inline_extent, + block_extent: glyph.block_extent, + }); + } + Ok(()) + } + + pub fn view(&self) -> GatheredPlanInput<'_> { + let mut f32_fields = [&[][..]; MAX_REGISTERS]; + let mut u32_fields = [&[][..]; MAX_REGISTERS]; + for (target, field) in f32_fields.iter_mut().zip(&self.f32_fields) { + *target = field.as_slice(); + } + for (target, field) in u32_fields.iter_mut().zip(&self.u32_fields) { + *target = field.as_slice(); + } + GatheredPlanInput { + glyphs: &self.glyphs, + f32_fields, + u32_fields, + f32_field_count: self.f32_fields.len(), + u32_field_count: self.u32_fields.len(), + } + } + + fn gather_fields( + &mut self, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, + program: &ProgramDescriptor, + ) -> Result<(), GatherError> { + let f32_count = usize::from(program.f32_input_count); + let u32_count = usize::from(program.u32_input_count); + for field in 0..self.f32_fields.len() { + let value = if field < f32_count { + let source = program.inputs[field]; + source_f32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )? + } else { + 0.0 + }; + self.f32_fields[field].push(value)?; + } + for field in 0..self.u32_fields.len() { + let value = if field < u32_count { + let source = program.inputs[f32_count + field]; + source_u32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )? + } else { + 0 + }; + self.u32_fields[field].push(value)?; + } + Ok(()) + } + + fn clear(&mut self) { + self.glyphs.clear(); + for field in &mut self.f32_fields { + field.clear(); + } + for field in &mut self.u32_fields { + field.clear(); + } + } +} + +impl Default for AlignedField { + fn default() -> Self { + Self { + blocks: Vec::new(), + len: 0, + } + } +} + +impl AlignedField { + fn reserve(&mut self, record_capacity: usize) -> Result<(), GatherError> { + let block_capacity = record_capacity.div_ceil(4); + if self.blocks.capacity() < block_capacity { + self.blocks + .try_reserve_exact(block_capacity.saturating_sub(self.blocks.len())) + .map_err(|_| GatherError::AllocationFailed)?; + } + Ok(()) + } + + fn push(&mut self, value: T) -> Result<(), GatherError> { + let lane = self.len % 4; + if lane == 0 { + if self.blocks.len() == self.blocks.capacity() { + return Err(GatherError::AllocationFailed); + } + self.blocks.push(AlignedBlock { + values: [T::default(); 4], + }); + } + let block = self + .blocks + .last_mut() + .ok_or(GatherError::AllocationFailed)?; + block.values[lane] = value; + self.len += 1; + Ok(()) + } + + fn clear(&mut self) { + self.blocks.clear(); + self.len = 0; + } + + fn as_slice(&self) -> &[T] { + debug_assert_eq!( + core::mem::size_of::>(), + core::mem::size_of::() * 4 + ); + // SAFETY: `AlignedBlock` is exactly four contiguous `T` values with no trailing + // padding for the only instantiated 32-bit scalar types. `len` never exceeds the + // initialized block prefix, and blocks cannot move while this shared slice exists. + unsafe { core::slice::from_raw_parts(self.blocks.as_ptr().cast::(), self.len) } + } + + #[cfg(test)] + fn capacity(&self) -> usize { + self.blocks.capacity() * 4 + } +} + +impl GatheredPlanInput<'_> { + pub fn plan_input(&self) -> PlanInput<'_> { + PlanInput { + glyphs: self.glyphs, + f32_fields: &self.f32_fields[..self.f32_field_count], + u32_fields: &self.u32_fields[..self.u32_field_count], + } + } +} + +fn validate_semantic_shape(input: LayoutPlanInput<'_>) -> Result<(), GatherError> { + if input.semantic_f32.iter().any(|field| { + field.len() != input.glyphs.len() || field.iter().any(|value| !value.is_finite()) + }) || input + .semantic_u32 + .iter() + .any(|field| field.len() != input.glyphs.len()) + { + return Err(GatherError::InvalidSemanticShape); + } + Ok(()) +} + +fn source_f32( + scope: InputScope, + field: u8, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, +) -> Result { + let (table, row) = match scope { + InputScope::Semantic => { + return input + .semantic_f32 + .get(usize::from(field)) + .and_then(|values| values.get(glyph_index)) + .copied() + .ok_or(GatherError::SourceFieldMissing); + } + InputScope::Glyph => ( + binding.glyph_f32(), + binding_row(input.glyphs[glyph_index].glyph_id)?, + ), + InputScope::Strike => (binding.strike_f32(), binding_row(selected.strike_row)?), + InputScope::Resource => (binding.resource_f32(), binding_row(selected.resource)?), + }; + table + .field(field) + .and_then(|values| values.get(row)) + .copied() + .ok_or(GatherError::SourceFieldMissing) +} + +fn source_u32( + scope: InputScope, + field: u8, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, +) -> Result { + let (table, row) = match scope { + InputScope::Semantic => { + return input + .semantic_u32 + .get(usize::from(field)) + .and_then(|values| values.get(glyph_index)) + .copied() + .ok_or(GatherError::SourceFieldMissing); + } + InputScope::Glyph => ( + binding.glyph_u32(), + binding_row(input.glyphs[glyph_index].glyph_id)?, + ), + InputScope::Strike => (binding.strike_u32(), binding_row(selected.strike_row)?), + InputScope::Resource => (binding.resource_u32(), binding_row(selected.resource)?), + }; + table + .field(field) + .and_then(|values| values.get(row)) + .copied() + .ok_or(GatherError::SourceFieldMissing) +} + +fn binding_row(row: u32) -> Result { + usize::try_from(row).map_err(|_| GatherError::SourceFieldMissing) +} + +fn reserve_fields( + fields: &mut Vec>, + field_count: usize, + record_capacity: usize, +) -> Result<(), GatherError> { + reserve(fields, field_count)?; + while fields.len() < field_count { + fields.push(AlignedField::default()); + } + for field in fields { + field.reserve(record_capacity)?; + } + Ok(()) +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), GatherError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| GatherError::AllocationFailed)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + font_binding::{FieldTable, FontResource, FontStrike}, + policy::{ + ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, + CAP_ORDERED_DIRECT, CAP_STORAGE_BUFFERS, CapabilitySet, InputSource, Operation, + PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, + TechniqueId, + }, + render_plan_compiler::RenderPlanCompiler, + }; + use alloc::vec; + + const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + + #[test] + fn gathers_program_specific_sources_without_a_union_record() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let semantic_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace.reserve_policy(&policy, 8).unwrap(); + let capacities = workspace.capacities(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + glyphs: &glyphs, + semantic_f32: &[&semantic_x], + semantic_u32: &[&semantic_kind], + }, + |handle| (handle == 9).then_some(&binding), + ) + .unwrap(); + { + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(core::mem::size_of::(), 60); + assert!( + input + .f32_fields + .iter() + .all(|field| (field.as_ptr() as usize).is_multiple_of(16)) + ); + assert!( + input + .u32_fields + .iter() + .all(|field| (field.as_ptr() as usize).is_multiple_of(16)) + ); + assert_eq!(input.glyphs[0].resource_id, 71); + assert_eq!(input.glyphs[1].resource_reference, 901); + assert_eq!(input.f32_fields[0], &[10.0, 20.0]); + assert_eq!(input.f32_fields[1], &[1.0, 2.0]); + assert_eq!(input.f32_fields[2], &[3.0, 4.0]); + assert_eq!(input.f32_fields[3], &[5.0, 5.0]); + assert_eq!(input.u32_fields[0], &[100, 200]); + assert_eq!(input.u32_fields[1], &[11, 12]); + assert_eq!(input.u32_fields[2], &[13, 14]); + assert_eq!(input.u32_fields[3], &[15, 15]); + + let mut compiler = RenderPlanCompiler::default(); + compiler + .prepare(&policy, CAPABILITY, input, true, 1, 0) + .unwrap(); + let plan = compiler + .plan_view(3, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.draws.len(), 1); + assert_eq!(plan.patches.len(), 2); + assert_eq!( + &plan.payload[..8], + &[10.0_f32.to_le_bytes(), 20.0_f32.to_le_bytes(),].concat(), + ); + } + assert_eq!(workspace.capacities(), capacities); + } + + #[test] + fn missing_program_binding_and_source_are_explicit() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0)]; + let mut workspace = PolicyGatherWorkspace::default(); + assert_eq!( + workspace.gather( + &policy, + CAPABILITY, + LayoutPlanInput { + glyphs: &glyphs, + semantic_f32: &[], + semantic_u32: &[], + }, + |_| None, + ), + Err(GatherError::FontBindingMissing) + ); + assert_eq!( + workspace.gather( + &policy, + CapabilitySetId(2), + LayoutPlanInput { + glyphs: &glyphs, + semantic_f32: &[], + semantic_u32: &[], + }, + |_| Some(&binding), + ), + Err(GatherError::ProgramMissing) + ); + assert_eq!( + workspace.gather( + &policy, + CAPABILITY, + LayoutPlanInput { + glyphs: &glyphs, + semantic_f32: &[], + semantic_u32: &[], + }, + |_| Some(&binding), + ), + Err(GatherError::SourceFieldMissing) + ); + } + + impl PolicyGatherWorkspace { + fn capacities(&self) -> (usize, Vec, Vec) { + ( + self.glyphs.capacity(), + self.f32_fields.iter().map(AlignedField::capacity).collect(), + self.u32_fields.iter().map(AlignedField::capacity).collect(), + ) + } + } + + fn layout_glyph(stable_id: u32, glyph_id: u32) -> LayoutGlyph { + LayoutGlyph { + stable_id, + content_revision: 1, + font_handle: 9, + glyph_id, + semantic_id: 1, + material_id: 6, + clip_id: 0, + depth_key: 0, + font_size: 16.0, + raster_pixel_ratio: 2.0, + inline_start: glyph_id as f32 * 10.0, + block_start: 0.0, + inline_extent: 8.0, + block_extent: 16.0, + } + } + + fn binding() -> FontRenderBinding { + FontRenderBinding::new( + TechniqueId(7), + 2, + 2, + vec![FontStrike { ppem: 0 }], + vec![FontResource { + id: 71, + generation: 3, + kind: 2, + reference: 901, + }], + vec![0, 0], + FieldTable::new(2, 1, vec![1.0, 2.0]).unwrap(), + FieldTable::new(2, 1, vec![11, 12]).unwrap(), + FieldTable::new(2, 1, vec![3.0, 4.0]).unwrap(), + FieldTable::new(2, 1, vec![13, 14]).unwrap(), + FieldTable::new(1, 1, vec![5.0]).unwrap(), + FieldTable::new(1, 1, vec![15]).unwrap(), + ) + .unwrap() + } + + fn policy() -> ValidatedPolicy { + ValidatedPolicy::new(PolicyDescriptor { + capability_sets: vec![CapabilitySet { + id: CAPABILITY, + flags: CAP_ORDERED_DIRECT | CAP_STORAGE_BUFFERS, + max_buffer_bytes: 1 << 20, + update_alignment: 4, + coalesce_gap_bytes: 0, + range_call_penalty_bytes: 1, + max_buffers_per_draw: 16, + max_resources_per_draw: 1, + max_indirect_draws: 0, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 7_500, + }], + programs: vec![ProgramDescriptor { + technique: TechniqueId(7), + variant: 2, + id: ProgramId(1), + capability_set: CAPABILITY, + resource_kind_mask: 1 << 1, + semantic_view_mask: 0, + storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, + draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + allocation_strategy: ALLOCATION_ORDERED_DIRECT, + f32_input_count: 4, + u32_input_count: 4, + inputs: vec![ + InputSource::semantic(0), + InputSource { + scope: InputScope::Glyph, + field: 0, + }, + InputSource { + scope: InputScope::Strike, + field: 0, + }, + InputSource { + scope: InputScope::Resource, + field: 0, + }, + InputSource::semantic(0), + InputSource { + scope: InputScope::Glyph, + field: 0, + }, + InputSource { + scope: InputScope::Strike, + field: 0, + }, + InputSource { + scope: InputScope::Resource, + field: 0, + }, + ], + capabilities: ProgramCapabilities::default(), + buffers: vec![BufferSchema { + id: BufferId(1), + scalar: ScalarType::F32, + vector_width: 1, + alignment: 4, + stride: 4, + usage: BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + capacity_class: 1, + }], + operations: vec![ + Operation::LoadF32 { + target: 0, + field: 0, + }, + Operation::StoreF32 { + source: 0, + buffer: BufferId(1), + lane: 0, + }, + ], + }], + }) + .unwrap() + } +} diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 8386ce2f..bc72927e 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -3,8 +3,10 @@ use alloc::{collections::BTreeMap, vec::Vec}; use super::{ font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, - plan_input::PlanInput, policy::{CapabilitySetId, ValidatedPolicy}, + policy_gather::{ + DEFAULT_GATHER_RECORD_CAPACITY, GatherError, LayoutPlanInput, PolicyGatherWorkspace, + }, render_plan::RenderPlanView, render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, }; @@ -29,6 +31,7 @@ pub struct TextEngine { font_bindings: Vec, font_stacks: Vec, sessions: BTreeMap, + gather: PolicyGatherWorkspace, } struct RegisteredFontBinding { @@ -59,6 +62,12 @@ struct PolicyBinding { } impl TextEngine { + pub fn initialize(&mut self) -> Result<(), EngineError> { + self.gather + .reserve_records(DEFAULT_GATHER_RECORD_CAPACITY) + .map_err(gather_error) + } + pub fn register_font_binding( &mut self, handle: u32, @@ -185,6 +194,9 @@ impl TextEngine { Err(EngineError::HandleConflict) }; } + self.gather + .reserve_policy(&policy, DEFAULT_GATHER_RECORD_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; self.policies.insert(handle, policy); Ok(()) } @@ -271,6 +283,8 @@ impl TextEngine { return Err(EngineError::InvalidRequest); } let policy_fingerprint = policy.fingerprint(); + let font_bindings = &self.font_bindings; + let gather = &mut self.gather; let session = self .sessions .get_mut(&request.session_id) @@ -307,14 +321,29 @@ impl TextEngine { // plan preparation or publication later aborts. session.acknowledged_publication_generation = request.acknowledged_publication_generation; session.prepare_text(request.text_mutations)?; - if let Err(error) = session.plan.prepare( + if let Err(error) = gather.gather( policy, CapabilitySetId(request.capability_set), - PlanInput { + LayoutPlanInput { glyphs: &[], - f32_fields: &[], - u32_fields: &[], + semantic_f32: &[], + semantic_u32: &[], }, + |handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| &binding.binding) + }, + ) { + session.abort_text(); + return Err(gather_error(error)); + } + let gathered = gather.view(); + if let Err(error) = session.plan.prepare( + policy, + CapabilitySetId(request.capability_set), + gathered.plan_input(), checkpoint, publication_generation, request.acknowledged_publication_generation, @@ -500,6 +529,17 @@ fn plan_error(error: RenderPlanCompilerError) -> EngineError { } } +fn gather_error(error: GatherError) -> EngineError { + match error { + GatherError::AllocationFailed => EngineError::ResultTooLarge, + GatherError::InvalidSemanticShape + | GatherError::FontBindingMissing + | GatherError::GlyphBindingMissing + | GatherError::ProgramMissing + | GatherError::SourceFieldMissing => EngineError::InvalidRequest, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 53b990f5..229e8807 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -32,7 +32,10 @@ fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { #[unsafe(no_mangle)] pub extern "C" fn pmndrs_text_shaper_initialize() -> u32 { - with_state(|_| STATUS_OK) + with_state(|state| match state.engine.initialize() { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), + }) } #[unsafe(no_mangle)] diff --git a/packages/text/tests/integration/render-policy-registration.test.mjs b/packages/text/tests/integration/render-policy-registration.test.mjs index f059f160..8f03f8b2 100644 --- a/packages/text/tests/integration/render-policy-registration.test.mjs +++ b/packages/text/tests/integration/render-policy-registration.test.mjs @@ -12,12 +12,19 @@ test('registers compiler-mapped render policies as retained typed Wasm state', a const module = await WebAssembly.compile(wasm); const instance = await WebAssembly.instantiate(module, {}); const memory = instance.exports[abi.memory]; + const initialize = instance.exports[abi.functions.initialize]; const allocate = instance.exports[abi.functions.allocate]; const deallocate = instance.exports[abi.functions.deallocate]; const register = instance.exports[abi.functions.registerPolicy]; const dispose = instance.exports[abi.functions.disposePolicy]; const count = instance.exports[abi.functions.policyCount]; assert.ok(memory instanceof WebAssembly.Memory); + const initialMemoryBytes = memory.buffer.byteLength; + assert.equal(initialize(), abi.status.ok); + const initializedMemoryBytes = memory.buffer.byteLength; + assert.ok(initializedMemoryBytes > initialMemoryBytes, 'initialization must prewarm the shared record workspace'); + assert.equal(initialize(), abi.status.ok); + assert.equal(memory.buffer.byteLength, initializedMemoryBytes, 'repeated initialization must not grow memory'); assert.equal(typeof allocate, 'function'); assert.equal(typeof deallocate, 'function'); assert.equal(typeof register, 'function'); @@ -32,10 +39,14 @@ test('registers compiler-mapped render policies as retained typed Wasm state', a const pointer = allocate(bytes.byteLength); assert.notEqual(pointer, 0); new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); + const beforePolicyMemoryBytes = memory.buffer.byteLength; assert.equal(count(), 0); assert.equal(register(7, pointer, bytes.byteLength), abi.status.ok); + const registeredPolicyMemoryBytes = memory.buffer.byteLength; + assert.ok(registeredPolicyMemoryBytes > beforePolicyMemoryBytes, 'policy registration must prewarm its exact lanes'); assert.equal(register(7, pointer, bytes.byteLength), abi.status.ok, 'identical registration is idempotent'); + assert.equal(memory.buffer.byteLength, registeredPolicyMemoryBytes, 'idempotent registration must not grow memory'); assert.equal(count(), 1); const request = abi.layouts.policyRequest; From b7a383eaf41935f311d4ac57b3ecefaf703dd8ca Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 12:48:56 -0400 Subject: [PATCH 024/128] perf(text): prewarm reusable shaping workspace --- docs/log.md | 8 + docs/packages/text.md | 14 +- docs/planning/decision-register.md | 182 +++++++++++------------ docs/planning/rust-layout-engine.md | 13 +- packages/text/rust/shaper/src/lib.rs | 206 +++++++++++++++++++------- packages/text/rust/shaper/src/wasm.rs | 11 +- 6 files changed, 277 insertions(+), 157 deletions(-) diff --git a/docs/log.md b/docs/log.md index 8404c452..6d5d8be8 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Reusable HarfRust initialization workspace** — Module initialization now reserves HarfRust's real 32,768-codepoint + info/position allocation and a reusable UTF-16 context array beside the existing plan/gather arena. Segment shaping + returns that allocation through `GlyphBuffer::clear` on success and restores it on fallible setup without boxing. + Optimized Wasm initialization grows 57 pages in total (25 new pages for shaping/context), repeated initialization + preserves `memory.buffer`, focused compiled-Wasm shaping/frame tests pass 11/11, and the module measures 847,814 raw / + 315,809 gzip / 249,629 Brotli bytes. Legacy batch-result vectors and the not-yet-landed bidi/layout arrays remain + explicit allocation gaps rather than being included in the claim. + - **Connected policy-directed gather to the Rust plan pipeline** — One reusable workspace now resolves every program's semantic/glyph/strike/resource recipe into 16-byte-aligned four-record F32/U32 lanes and feeds the plan compiler. A Rust proof emits a nonempty ordered plan with exact packed bytes across all source scopes and unchanged warm capacity. diff --git a/docs/packages/text.md b/docs/packages/text.md index 63e2fdfa..dac1153a 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:4eeac6683ea12cc696436df84aa88ebb803aff998185177230d47e838bfae5e3' +source_digest: 'sha256:25c46ab6db745a3d82eded9d1d3a3579546689abffbc052daaedbbce98845e6e' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -508,10 +508,14 @@ Session creation also prewarms both retained UTF-16 buffers to 1,024 units by de accepts an explicit text capacity for known large paragraphs. This removes the observed second-buffer lazy allocation without giving every text object the 25K-glyph benchmark footprint. Production analysis/shaping/layout scratch will be one synchronous engine-global 32,768-record workspace, reserved once when those arrays land and shared by every session. -The compiler-published `initialize()` export is invoked by the standard host immediately after Wasm instantiation, so -module-owned state and the first concrete plan-glyph arena no longer allocate behind the first operational export. -Policy-specific gather lanes settle during cold registration. HarfRust/Unicode/layout lanes remain unimplemented, so -this still makes no first-shape allocation or latency claim. +The compiler-published `initialize()` export is invoked by the standard host immediately after Wasm instantiation. It +now reserves both the plan-glyph arena and HarfRust's actual 32,768-codepoint internal info/position allocation plus one +equally sized decoded-context array. Each segment returns the same allocation through `GlyphBuffer::clear`, including +restoration after fallible setup. Initialization grows linear memory from 1,245,184 to 4,980,736 bytes (57 pages), and +a repeated call preserves buffer identity. Policy-specific gather lanes settle during cold registration. The optimized +artifact is 847,814 raw / 315,809 gzip / 249,629 Brotli bytes, +2,234 / +524 / +408 over the policy-gather checkpoint. +The old three-export batch result still allocates its temporary output vectors, while bidi/layout arrays remain open; +this is HarfRust workspace evidence, not yet a complete allocation-free `text_update` or latency result. Ordered font stacks now have cold Rust lifecycle operations independent from frame updates. A stack is nonempty, duplicate-free, idempotent only for the same ordered handles, and retains its already registered shaping fonts until diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index bc9c370d..9326ebc4 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -152,97 +152,97 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. ## Raster -| ID | Decision | Status | -| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | -| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | -| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | -| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | -| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | -| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | -| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | -| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | -| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | -| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | -| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | -| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | -| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | -| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | -| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | -| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | -| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | -| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | -| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | -| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | -| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | -| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | -| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | -| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | -| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | -| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | -| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | -| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | -| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | -| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | -| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | -| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | -| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | -| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | -| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | -| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | -| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | -| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | -| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | -| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | -| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | -| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | -| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | -| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | -| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | -| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | -| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | -| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | -| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | -| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | -| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | -| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | -| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | -| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | -| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | -| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | -| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | -| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | -| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | -| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | -| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | -| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | -| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | -| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | -| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | -| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | -| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | -| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | -| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | -| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | -| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | -| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | -| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | -| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | -| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | -| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | -| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | -| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | -| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | -| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | -| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | -| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | -| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | -| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | -| D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | -| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; each production array prewarms once to 32,768 clusters/glyphs as it lands, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation and owns policy-independent workspace reservation; D-178 lands the first concrete plan-glyph arena while HarfRust/Unicode/layout arrays remain open. Policy registration cold-reserves its exact field lanes. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | -| D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | -| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | -| D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | -| D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | +| ID | Decision | Status | +| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | +| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | +| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | +| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | +| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | +| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | +| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | +| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | +| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | +| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | +| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | +| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | +| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | +| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | +| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | +| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | +| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | +| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | +| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | +| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | +| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | +| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | +| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | +| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | +| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | +| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | +| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | +| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | +| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | +| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | +| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | +| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | +| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | +| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | +| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | +| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | +| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | +| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | +| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | +| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | +| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | +| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | +| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | +| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | +| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | +| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | +| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | +| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | +| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | +| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | +| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | +| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | +| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | +| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | +| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | +| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | +| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | +| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | +| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | +| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | +| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | +| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | +| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | +| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | +| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | +| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | +| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | +| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | +| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | +| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | +| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | +| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | +| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | +| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | +| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | +| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | +| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | +| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | +| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | +| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | +| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | +| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | +| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | +| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | +| D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | +| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; each production array prewarms once to 32,768 clusters/glyphs as it lands, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation and owns policy-independent workspace reservation; D-178 lands the plan-glyph arena, and the following checkpoint reserves and reuses HarfRust's actual internal buffer plus UTF-16 context scratch. Initialization now settles 57 Wasm pages and is identity-stable when repeated. Policy registration cold-reserves its exact field lanes. Bidi, cluster, line, and geometry workspaces remain open; legacy batch-result vectors are outside the final frame claim. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | +| D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | +| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | +| D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | +| D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 25abc1ba..05f3ac41 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -174,6 +174,7 @@ reserved by module `initialize()`. Policy registration reserves only the maximum policy, each to the same record capacity. The production empty-frame path now passes through this gather before plan compilation; a Rust proof gathers all four source scopes into a nonempty ordered plan and asserts exact packed bytes. Nonempty shaping/layout frame input is still open and no end-to-end timing is inferred from the synthetic proof. + - Result publication uses A/B Wasm buffers for synchronous reads only. A retained or asynchronous result is copied into a worker-owned transferable `ArrayBuffer`; root returns ownership of that same buffer to the worker on retirement so pooling or garbage collection occurs on the worker rather than root. @@ -366,9 +367,15 @@ inside declared capacities may not lazily settle another allocation. Module initialization is explicit rather than an incidental side effect of the first operational export. The generated ABI publishes `initialize()`, and the standard host calls it immediately after `WebAssembly.instantiate`; this eagerly creates module-owned state before a font registration, session operation, or update can be observed. At the current -checkpoint it creates module state and reserves the first concrete 32,768-entry render-plan gather arena. -Policy-specific aligned field lanes settle at cold policy registration. HarfRust, Unicode, cluster, line, and geometry -arrays remain unimplemented and therefore are not yet included in the initialization claim. +checkpoint it creates module state, reserves the 32,768-entry render-plan gather arena, reserves HarfRust's actual +32,768-codepoint internal info/position buffer, and reserves one 32,768-codepoint UTF-16 context scratch array. HarfRust +consumes that buffer by value and returns the same allocation through `GlyphBuffer::clear`; the registry restores it +after every successful segment and every fallible setup path instead of constructing a fresh buffer per segment. +Initialization grows the optimized module from 1,245,184 to 4,980,736 linear-memory bytes (57 pages), of which 25 pages +are the HarfRust/context addition, and repeated initialization preserves byte length and `memory.buffer` identity. +Policy-specific aligned field lanes settle at cold policy registration. The legacy exported batch-result vectors still +settle independently and are not evidence for the new frame path; Unicode bidi, clusters, lines, and geometry arrays +remain unimplemented and therefore are not yet included in the zero-allocation frame claim. ## Rust layout pipeline diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 1a56153a..ed96cfe4 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -38,11 +38,24 @@ pub const STATUS_FONT_IN_USE: u32 = 14; const BUFFER_FLAGS_MASK: u32 = 0xff; const MAX_CACHED_PLANS_PER_FONT: usize = 64; +const DEFAULT_SHAPE_BUFFER_CAPACITY: usize = 32_768; -#[derive(Default)] pub struct ShaperRegistry { fonts: BTreeMap, result: ResultArena, + shape_buffer: Option, + context_codepoints: Vec, +} + +impl Default for ShaperRegistry { + fn default() -> Self { + Self { + fonts: BTreeMap::new(), + result: ResultArena::default(), + shape_buffer: Some(UnicodeBuffer::new()), + context_codepoints: Vec::new(), + } + } } #[derive(Default)] @@ -137,6 +150,20 @@ pub struct ShapeBatchOutput { } impl ShaperRegistry { + pub fn initialize(&mut self) -> Result<(), u32> { + let Some(shape_buffer) = self.shape_buffer.as_mut() else { + return Err(STATUS_INVALID_REQUEST); + }; + if !shape_buffer.reserve(DEFAULT_SHAPE_BUFFER_CAPACITY) { + return Err(STATUS_RESULT_TOO_LARGE); + } + self.context_codepoints + .try_reserve_exact( + DEFAULT_SHAPE_BUFFER_CAPACITY.saturating_sub(self.context_codepoints.len()), + ) + .map_err(|_| STATUS_RESULT_TOO_LARGE) + } + pub fn register_font( &mut self, handle: u32, @@ -258,24 +285,38 @@ impl ShaperRegistry { .fonts .get_mut(&run.font_handle) .ok_or(STATUS_FONT_MISSING)?; - let shaped = shape_segment(font, &request.text, run, range)?; - let glyph_count = u32::try_from(shaped.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - output.run_font_slots.push(slot); - output.run_glyph_starts.push(glyph_start); - output.run_glyph_counts.push(glyph_count); - for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { - output - .glyph_ids - .push(u16::try_from(info.glyph_id).map_err(|_| STATUS_RESULT_TOO_LARGE)?); - output.clusters.push(info.cluster); - output.x_advances.push(position.x_advance); - output.y_advances.push(position.y_advance); - output.x_offsets.push(position.x_offset); - output.y_offsets.push(position.y_offset); - output.glyph_flags.push( - u16::try_from(info.flags().to_bits()).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - } + let shaped = shape_segment( + font, + &request.text, + run, + range, + &mut self.shape_buffer, + &mut self.context_codepoints, + )?; + let append_result: Result<(), u32> = (|| { + let glyph_count = + u32::try_from(shaped.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; + output.run_font_slots.push(slot); + output.run_glyph_starts.push(glyph_start); + output.run_glyph_counts.push(glyph_count); + for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + output + .glyph_ids + .push(u16::try_from(info.glyph_id).map_err(|_| STATUS_RESULT_TOO_LARGE)?); + output.clusters.push(info.cluster); + output.x_advances.push(position.x_advance); + output.y_advances.push(position.y_advance); + output.x_offsets.push(position.x_offset); + output.y_offsets.push(position.y_offset); + output.glyph_flags.push( + u16::try_from(info.flags().to_bits()) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?, + ); + } + Ok(()) + })(); + self.shape_buffer = Some(shaped.clear()); + append_result?; } Ok(output) } @@ -435,7 +476,52 @@ fn shape_segment( text: &[u16], run: &RunRequest, range: SegmentRange, + buffer_slot: &mut Option, + context_codepoints: &mut Vec, ) -> Result { + let mut buffer = buffer_slot.take().ok_or(STATUS_INVALID_REQUEST)?; + let features = + match shape_segment_inner(font, text, run, range, &mut buffer, context_codepoints) { + Ok(features) => features, + Err(status) => { + *buffer_slot = Some(buffer); + return Err(status); + } + }; + + let font_ref = match FontRef::new(&font.sfnt) { + Ok(font_ref) => font_ref, + Err(_) => { + *buffer_slot = Some(buffer); + return Err(STATUS_INVALID_FONT); + } + }; + let shaper = font.data.shaper(&font_ref).build(); + let Some(plan) = font.plans.last().map(|cached| &cached.plan) else { + *buffer_slot = Some(buffer); + return Err(STATUS_INVALID_REQUEST); + }; + let mut extents = FlatExtents { + records: &font.extents, + availability: &font.availability, + }; + Ok(shaper.shape( + buffer, + ShapeOptions::new() + .features(&features) + .plan(Some(plan)) + .font_funcs(Some(&mut extents)), + )) +} + +fn shape_segment_inner( + font: &mut RegisteredFont, + text: &[u16], + run: &RunRequest, + range: SegmentRange, + buffer: &mut UnicodeBuffer, + context_codepoints: &mut Vec, +) -> Result, u32> { let direction = match run.direction { 0 => Direction::LeftToRight, 1 => Direction::RightToLeft, @@ -448,19 +534,7 @@ fn shape_segment( .as_ref() .map(|value| parse_language(value).ok_or(STATUS_INVALID_REQUEST)) .transpose()?; - let features = run - .features - .iter() - .map(|feature| { - let global = feature.start <= range.item_start && feature.end >= range.item_end; - Feature { - tag: Tag::from_be_bytes(feature.tag.to_be_bytes()), - value: feature.value, - start: if global { 0 } else { feature.start }, - end: if global { u32::MAX } else { feature.end }, - } - }) - .collect::>(); + let features = shape_features(run, range); let key = PlanKey { direction: run.direction, script: run.script, @@ -493,16 +567,21 @@ fn shape_segment( font.plans.push(CachedPlan { key, plan }); } - let mut buffer = UnicodeBuffer::new(); - add_utf16_range(&mut buffer, text, range.item_start, range.item_end)?; + buffer.clear(); + add_utf16_range(buffer, text, range.item_start, range.item_end)?; if range.context_start < range.item_start { - let mut pre_context = decode_utf16_range(text, range.context_start, range.item_start)?; - pre_context.reverse(); - buffer.set_pre_context_codepoints(&pre_context); + decode_utf16_range_into( + text, + range.context_start, + range.item_start, + context_codepoints, + )?; + context_codepoints.reverse(); + buffer.set_pre_context_codepoints(context_codepoints); } if range.item_end < range.context_end { - let post_context = decode_utf16_range(text, range.item_end, range.context_end)?; - buffer.set_post_context_codepoints(&post_context); + decode_utf16_range_into(text, range.item_end, range.context_end, context_codepoints)?; + buffer.set_post_context_codepoints(context_codepoints); } buffer.set_direction(direction); buffer.set_script(script); @@ -518,20 +597,22 @@ fn shape_segment( }); buffer.set_flags(BufferFlags::from_bits(range.flags).ok_or(STATUS_INVALID_REQUEST)?); - let font_ref = FontRef::new(&font.sfnt).map_err(|_| STATUS_INVALID_FONT)?; - let shaper = font.data.shaper(&font_ref).build(); - let plan = &font.plans.last().ok_or(STATUS_INVALID_REQUEST)?.plan; - let mut extents = FlatExtents { - records: &font.extents, - availability: &font.availability, - }; - Ok(shaper.shape( - buffer, - ShapeOptions::new() - .features(&features) - .plan(Some(plan)) - .font_funcs(Some(&mut extents)), - )) + Ok(features) +} + +fn shape_features(run: &RunRequest, range: SegmentRange) -> Vec { + run.features + .iter() + .map(|feature| { + let global = feature.start <= range.item_start && feature.end >= range.item_end; + Feature { + tag: Tag::from_be_bytes(feature.tag.to_be_bytes()), + value: feature.value, + start: if global { 0 } else { feature.start }, + end: if global { u32::MAX } else { feature.end }, + } + }) + .collect() } struct FlatExtents<'a> { @@ -595,18 +676,33 @@ fn add_utf16_range( Ok(()) } +#[cfg(test)] fn decode_utf16_range(text: &[u16], start: u32, end: u32) -> Result, u32> { + let mut decoded = Vec::new(); + decode_utf16_range_into(text, start, end, &mut decoded)?; + Ok(decoded) +} + +fn decode_utf16_range_into( + text: &[u16], + start: u32, + end: u32, + decoded: &mut Vec, +) -> Result<(), u32> { let start = usize::try_from(start).map_err(|_| STATUS_INVALID_REQUEST)?; let end = usize::try_from(end).map_err(|_| STATUS_INVALID_REQUEST)?; let units = text.get(start..end).ok_or(STATUS_INVALID_REQUEST)?; - let mut decoded = Vec::new(); + decoded.clear(); + decoded + .try_reserve(units.len()) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; let mut local = 0; while local < units.len() { let (character, consumed) = decode_scalar(units, local); decoded.push(character as u32); local += consumed; } - Ok(decoded) + Ok(()) } fn decode_scalar(units: &[u16], index: usize) -> (char, usize) { diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 229e8807..8910ce31 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -32,9 +32,14 @@ fn panic(_info: &core::panic::PanicInfo<'_>) -> ! { #[unsafe(no_mangle)] pub extern "C" fn pmndrs_text_shaper_initialize() -> u32 { - with_state(|state| match state.engine.initialize() { - Ok(()) => STATUS_OK, - Err(error) => engine_status(error), + with_state(|state| { + if let Err(status) = state.registry.initialize() { + return status; + } + match state.engine.initialize() { + Ok(()) => STATUS_OK, + Err(error) => engine_status(error), + } }) } From 9941a41c586d87849c279123173e442a385e64ab Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 13:09:25 -0400 Subject: [PATCH 025/128] feat(text): admit editorial frame geometry --- docs/log.md | 9 + docs/packages/text.md | 20 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 6 +- packages/text/rust/shaper/src/abi_contract.rs | 55 +- packages/text/rust/shaper/src/engine/frame.rs | 28 + .../text/rust/shaper/src/engine/frame_wire.rs | 41 +- .../rust/shaper/src/engine/semantic_wire.rs | 1083 ++++++++++++++++- packages/text/rust/shaper/src/engine/state.rs | 42 + packages/text/rust/shaper/src/wire.rs | 4 + .../text/src/generated/text-shaper-abi.ts | 43 + .../render-plan-frame-abi.test.mjs | 123 +- 12 files changed, 1411 insertions(+), 44 deletions(-) diff --git a/docs/log.md b/docs/log.md index 6d5d8be8..24c0f18d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Admitted one-call editorial geometry into the Rust frame transaction** — Constraints, regions, exclusions, bounded + rectangle/polygon vertices, and inline objects now decode as borrowed records from one pinned request. Validation + covers limits, finite ordered bounds, enum/reserved data, identities, region ownership/ranges, pending-text anchors, + and cross-section payload aliasing before mutation. Sessions stage a semantic fingerprint that excludes pointer-only + vertex offsets. Compiled Wasm commits a complete rectangle/exclusion/object update and rejects a forged region link + without advancing A/B publication. Optimized Wasm measures 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. The + still-TypeScript 25,515-glyph baseline records 54.02/12.29/8.50/39.12 ms cold/font-size/width/text medians; geometry + does not run there yet. Styles and actual layout consumption remain open, so plans are still empty. + - **Reusable HarfRust initialization workspace** — Module initialization now reserves HarfRust's real 32,768-codepoint info/position allocation and a reusable UTF-16 context array beside the existing plan/gather arena. Segment shaping returns that allocation through `GlyphBuffer::clear` on success and restores it on fallible setup without boxing. diff --git a/docs/packages/text.md b/docs/packages/text.md index dac1153a..ff2f3ba1 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:25c46ab6db745a3d82eded9d1d3a3579546689abffbc052daaedbbce98845e6e' +source_digest: 'sha256:132c8cd7ca9681ea8ea2dad94056b26d1a512a6bd2dc22e1666168ea66d85659' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -482,7 +482,8 @@ abort/retry, capability-set changes, and policy identity. A post-prepare Wasm ab semantic input exists, so that exact ordering remains an explicit test gap. The now-reachable planners increase the optimized artifact from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. This is a measured shared-runtime cost and a pending optimization -target. Ordered UTF-16 replacements are now retained transactionally, while style and geometry sections remain rejected. +target. Ordered UTF-16 replacements are now retained transactionally. Editorial constraints, regions, exclusions, and +inline objects now decode as borrowed one-call geometry; style mutations remain rejected. Sessions still publish an empty Rust plan because retained text is not yet shaped or laid out; there is no Rust shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. @@ -491,8 +492,8 @@ text replacements, 80-byte stable style mutations, 52-byte constraints, 8-byte f exclusions, and 56-byte inline objects. Region/exclusion rectangles use inline bounds, while bounded polygons reference vertices inside the same request. Styles include current shaping fields plus word spacing, material/color, and decoration inputs. The generated ABI and compiled-Wasm test pin every size, tag, and the inline-object -`baselineAlignment` offset. The following text-retention checkpoint admits only the text record; the remaining sections -still fail closed. +`baselineAlignment` offset. The generated engine vocabulary now also fixes axis, wrap, inline/block alignment, overflow, +writing, orientation, exclusion-side, and inline-object baseline tags rather than accepting renderer-local enum bytes. The frame decoder now borrows ordered UTF-16 replacement records and their offset-addressed payloads directly from the pinned request. It validates canonical empty offsets, opcode/encoding, reserved fields, bounds, alignment, arithmetic, @@ -517,6 +518,17 @@ artifact is 847,814 raw / 315,809 gzip / 249,629 Brotli bytes, +2,234 / +524 / + The old three-export batch result still allocates its temporary output vectors, while bidi/layout arrays remain open; this is HarfRust workspace evidence, not yet a complete allocation-free `text_update` or latency result. +The frame path now accepts complete editorial geometry in the same update as text. Rust borrows constraint, region, +exclusion, polygon-vertex, and inline-object records directly from the pinned request; it validates request limits, +finite ordered bounds, enum/reserved fields, identity and region ownership, vertex containment, text anchors after +pending mutations, and cross-section non-overlap before session mutation. A placement-independent fingerprint omits raw +vertex offsets, so repacking equal geometry does not manufacture invalidation. The compiled-Wasm proof commits one +rectangle region with an exclusion and inline object, then rejects a forged region reference without advancing the +published A/B revision. Layout does not consume these records yet, plans remain empty, and no latency claim is attached. +The optimized module is 856,832 raw / 318,999 gzip / 252,620 Brotli bytes, +9,018 / +3,190 / +2,991 over the shaping +workspace checkpoint. The still-TypeScript 25,515-glyph baseline measures 54.02/12.29/8.50/39.12 millisecond medians +for cold/font-size/width/text; it does not execute this geometry decoder and remains target evidence only. + Ordered font stacks now have cold Rust lifecycle operations independent from frame updates. A stack is nonempty, duplicate-free, idempotent only for the same ordered handles, and retains its already registered shaping fonts until stack disposal. A compiled-Wasm integration test uses the real baked Inter shaping payload to prove that a retained diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 9326ebc4..f6d48f51 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -243,6 +243,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | | D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | | D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | +| D-179 | Editorial flow geometry is a complete borrowed section of one `text_update`, never a host measurement callback. A nonempty geometry transaction contains at least one constraint and region and may contain bounded rectangle/polygon exclusions and text-anchored inline objects. Compiler-published enums define axis, wrap, inline/block alignment, overflow, writing mode, orientation, exclusion side, and baseline tags. Rust validates finite ordered bounds, polygon vertices, limits, unique identities, region/exclusion ranges, text offsets after pending mutations, reserved fields, and all table/payload overlap before state mutation. Sessions transactionally retain a semantic geometry fingerprint while layout will consume the borrowed records directly; pointer-only vertex offsets are excluded so equivalent repacking is deterministic. Compiled Wasm accepts a complete rectangle/exclusion/object request in one update and rejects a forged region reference without advancing the A/B publication. Optimized Wasm changes from 847,814 / 315,809 / 249,629 to 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. Styles remain rejected until their retained mutation model lands. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 05f3ac41..ef962114 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -823,8 +823,10 @@ requires a regression test once Rust shaping/layout can produce nonempty plan ou This makes the full retained plan compiler reachable: the optimized artifact changes from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. The 82,534 raw / 35,409 gzip / 28,052 Brotli increase is shared runtime code, not font-local shaping data, and is now an explicit size-optimization target. Ordered UTF-16 text -replacements now decode as borrowed records and commit into retained session scratch transactionally; style and geometry -sections remain rejected. Because retained text is not yet analyzed, shaped, or laid out, the Wasm path still emits an +replacements now decode as borrowed records and commit into retained session scratch transactionally. Constraints, +regions, exclusions, polygon vertices, and inline objects are borrowed from the same request, fully validated before +mutation, checked against pending text offsets, and committed as a placement-independent semantic fingerprint. Styles +remain rejected. Because retained text and geometry are not yet analyzed, shaped, or laid out, the Wasm path still emits an empty Rust plan. Rust shaping/layout → nonempty plan connection and its 25,515-glyph end-to-end timing remain open; the TypeScript layout benchmark is baseline evidence only. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index f13c8991..9a3eaeda 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -3,9 +3,15 @@ use core::mem::{align_of, offset_of, size_of}; use serde_json::json; use crate::engine::frame::{ - DEFAULT_SESSION_TEXT_CAPACITY, RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, - STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, - TEXT_MUTATION_REPLACE_UTF16, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, + ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START, AXIS_AT_MOST, AXIS_EXACT, + AXIS_UNCONSTRAINED, BASELINE_ALPHABETIC, BASELINE_MIDDLE, BASELINE_TEXT_BOTTOM, + BASELINE_TEXT_TOP, BLOCK_ALIGN_CENTER, BLOCK_ALIGN_END, BLOCK_ALIGN_START, + DEFAULT_SESSION_TEXT_CAPACITY, EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, + EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, + ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, + RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_MUTATION_REMOVE, + STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, + WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; use crate::engine::policy::{ ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, @@ -2513,6 +2519,49 @@ pub fn json() -> String { "verticalRl": WRITING_VERTICAL_RL, "verticalLr": WRITING_VERTICAL_LR }, + "textOrientations": { + "mixed": ORIENTATION_MIXED, + "upright": ORIENTATION_UPRIGHT, + "sideways": ORIENTATION_SIDEWAYS + }, + "axisModes": { + "unconstrained": AXIS_UNCONSTRAINED, + "atMost": AXIS_AT_MOST, + "exact": AXIS_EXACT + }, + "wrapModes": { + "none": WRAP_NONE, + "word": WRAP_WORD, + "character": WRAP_CHARACTER + }, + "inlineAlignments": { + "start": ALIGN_START, + "center": ALIGN_CENTER, + "end": ALIGN_END, + "justify": ALIGN_JUSTIFY + }, + "overflowModes": { + "visible": OVERFLOW_VISIBLE, + "clip": OVERFLOW_CLIP, + "ellipsis": OVERFLOW_ELLIPSIS + }, + "blockAlignments": { + "start": BLOCK_ALIGN_START, + "center": BLOCK_ALIGN_CENTER, + "end": BLOCK_ALIGN_END + }, + "exclusionWrapSides": { + "both": EXCLUSION_WRAP_BOTH, + "inlineStart": EXCLUSION_WRAP_INLINE_START, + "inlineEnd": EXCLUSION_WRAP_INLINE_END, + "largest": EXCLUSION_WRAP_LARGEST + }, + "inlineObjectBaselines": { + "alphabetic": BASELINE_ALPHABETIC, + "textTop": BASELINE_TEXT_TOP, + "middle": BASELINE_MIDDLE, + "textBottom": BASELINE_TEXT_BOTTOM + }, "resultFlags": { "checkpoint": RESULT_FLAG_CHECKPOINT }, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index d17f30b7..c1508262 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -9,6 +9,33 @@ pub(crate) const SHAPE_POLYGON: u8 = 2; pub(crate) const WRITING_HORIZONTAL_TB: u8 = 1; pub(crate) const WRITING_VERTICAL_RL: u8 = 2; pub(crate) const WRITING_VERTICAL_LR: u8 = 3; +pub(crate) const ORIENTATION_MIXED: u8 = 1; +pub(crate) const ORIENTATION_UPRIGHT: u8 = 2; +pub(crate) const ORIENTATION_SIDEWAYS: u8 = 3; +pub(crate) const AXIS_UNCONSTRAINED: u8 = 1; +pub(crate) const AXIS_AT_MOST: u8 = 2; +pub(crate) const AXIS_EXACT: u8 = 3; +pub(crate) const WRAP_NONE: u8 = 1; +pub(crate) const WRAP_WORD: u8 = 2; +pub(crate) const WRAP_CHARACTER: u8 = 3; +pub(crate) const ALIGN_START: u8 = 1; +pub(crate) const ALIGN_CENTER: u8 = 2; +pub(crate) const ALIGN_END: u8 = 3; +pub(crate) const ALIGN_JUSTIFY: u8 = 4; +pub(crate) const OVERFLOW_VISIBLE: u8 = 1; +pub(crate) const OVERFLOW_CLIP: u8 = 2; +pub(crate) const OVERFLOW_ELLIPSIS: u8 = 3; +pub(crate) const BLOCK_ALIGN_START: u8 = 1; +pub(crate) const BLOCK_ALIGN_CENTER: u8 = 2; +pub(crate) const BLOCK_ALIGN_END: u8 = 3; +pub(crate) const EXCLUSION_WRAP_BOTH: u8 = 1; +pub(crate) const EXCLUSION_WRAP_INLINE_START: u8 = 2; +pub(crate) const EXCLUSION_WRAP_INLINE_END: u8 = 3; +pub(crate) const EXCLUSION_WRAP_LARGEST: u8 = 4; +pub(crate) const BASELINE_ALPHABETIC: u8 = 1; +pub(crate) const BASELINE_TEXT_TOP: u8 = 2; +pub(crate) const BASELINE_MIDDLE: u8 = 3; +pub(crate) const BASELINE_TEXT_BOTTOM: u8 = 4; pub(crate) const DEFAULT_SESSION_TEXT_CAPACITY: u32 = 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -21,6 +48,7 @@ pub(crate) struct UpdateRequest<'a> { pub capability_set: u32, pub limits: UpdateLimits, pub text_mutations: super::semantic_wire::TextMutationBatch<'a>, + pub geometry: super::semantic_wire::GeometryBatch<'a>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 084e2299..a3d8d6ab 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -26,7 +26,7 @@ use crate::{ }, engine::{ frame::{UpdateLimits, UpdateRequest}, - semantic_wire::parse_text_mutations, + semantic_wire::{parse_geometry, parse_text_mutations}, }, wire::read_u32, }; @@ -53,19 +53,6 @@ pub(crate) fn parse_update_request( ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, ENGINE_UPDATE_STYLE_MUTATION_COUNT, ), - ( - ENGINE_UPDATE_CONSTRAINTS_OFFSET, - ENGINE_UPDATE_CONSTRAINT_COUNT, - ), - (ENGINE_UPDATE_REGIONS_OFFSET, ENGINE_UPDATE_REGION_COUNT), - ( - ENGINE_UPDATE_EXCLUSIONS_OFFSET, - ENGINE_UPDATE_EXCLUSION_COUNT, - ), - ( - ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, - ENGINE_UPDATE_INLINE_OBJECT_COUNT, - ), ( ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, @@ -99,7 +86,30 @@ pub(crate) fn parse_update_request( read_u32(bytes, ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET)?, text_mutation_count, )?; - if text_mutation_count == 0 && bytes.len() != ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize { + let constraint_count = read_u32(bytes, ENGINE_UPDATE_CONSTRAINT_COUNT)?; + let region_count = read_u32(bytes, ENGINE_UPDATE_REGION_COUNT)?; + let exclusion_count = read_u32(bytes, ENGINE_UPDATE_EXCLUSION_COUNT)?; + let inline_object_count = read_u32(bytes, ENGINE_UPDATE_INLINE_OBJECT_COUNT)?; + let geometry = parse_geometry( + bytes, + read_u32(bytes, ENGINE_UPDATE_CONSTRAINTS_OFFSET)?, + constraint_count, + read_u32(bytes, ENGINE_UPDATE_REGIONS_OFFSET)?, + region_count, + read_u32(bytes, ENGINE_UPDATE_EXCLUSIONS_OFFSET)?, + exclusion_count, + read_u32(bytes, ENGINE_UPDATE_INLINE_OBJECTS_OFFSET)?, + inline_object_count, + limits, + )?; + text_mutations.validate_disjoint_geometry(geometry)?; + if text_mutation_count == 0 + && constraint_count == 0 + && region_count == 0 + && exclusion_count == 0 + && inline_object_count == 0 + && bytes.len() != ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + { return Err(STATUS_INVALID_REQUEST); } Ok(UpdateRequest { @@ -114,6 +124,7 @@ pub(crate) fn parse_update_request( capability_set: positive(bytes, ENGINE_UPDATE_CAPABILITY_SET)?, limits, text_mutations, + geometry, }) } diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index b2fea6e3..1a2082e0 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -3,15 +3,24 @@ use crate::{ STATUS_INVALID_REQUEST, abi_contract::{ - ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, + self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_RESERVED0, ENGINE_TEXT_MUTATION_RESERVED1, ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, - engine::frame::{TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16}, - wire::{array, read_u16, read_u32}, + engine::frame::{ + ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START, AXIS_AT_MOST, AXIS_EXACT, + AXIS_UNCONSTRAINED, BASELINE_ALPHABETIC, BASELINE_MIDDLE, BASELINE_TEXT_BOTTOM, + BASELINE_TEXT_TOP, BLOCK_ALIGN_CENTER, BLOCK_ALIGN_END, BLOCK_ALIGN_START, + EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, + EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, + OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, SHAPE_POLYGON, SHAPE_RECTANGLE, + TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, UpdateLimits, WRAP_CHARACTER, + WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, + }, + wire::{array, read_f32, read_u16, read_u32}, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -27,6 +36,110 @@ pub(crate) struct TextMutation<'a> { pub insert_utf16_le: &'a [u8], } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct GeometryBatch<'a> { + request: &'a [u8], + constraints: &'a [u8], + regions: &'a [u8], + exclusions: &'a [u8], + inline_objects: &'a [u8], +} + +impl GeometryBatch<'_> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + constraints: &[], + regions: &[], + exclusions: &[], + inline_objects: &[], + } + } + + pub(crate) fn validate_text_length(self, text_length: usize) -> Result<(), u32> { + let text_length = u32::try_from(text_length).map_err(|_| STATUS_INVALID_REQUEST)?; + for record in self + .constraints + .chunks_exact(abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize) + { + if read_u32(record, abi::ENGINE_CONSTRAINT_RESUME_CLUSTER)? > text_length { + return Err(STATUS_INVALID_REQUEST); + } + } + let mut previous_offset = None; + for record in self + .inline_objects + .chunks_exact(abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize) + { + let offset = read_u32(record, abi::ENGINE_INLINE_OBJECT_TEXT_OFFSET)?; + if offset > text_length || previous_offset.is_some_and(|previous| offset <= previous) { + return Err(STATUS_INVALID_REQUEST); + } + previous_offset = Some(offset); + } + Ok(()) + } + + pub(crate) fn fingerprint(self) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + for section in [self.constraints, self.inline_objects] { + mix_bytes(&mut hash, section); + } + for record in self + .regions + .chunks_exact(abi::ENGINE_REGION_RECORD_SIZE as usize) + { + mix_record_without_u32(&mut hash, record, abi::ENGINE_REGION_VERTICES_OFFSET); + mix_vertex_payload( + &mut hash, + self.request, + record, + abi::ENGINE_REGION_SHAPE, + abi::ENGINE_REGION_VERTICES_OFFSET, + abi::ENGINE_REGION_VERTEX_COUNT, + ); + } + for record in self + .exclusions + .chunks_exact(abi::ENGINE_EXCLUSION_RECORD_SIZE as usize) + { + mix_record_without_u32(&mut hash, record, abi::ENGINE_EXCLUSION_VERTICES_OFFSET); + mix_vertex_payload( + &mut hash, + self.request, + record, + abi::ENGINE_EXCLUSION_SHAPE, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + abi::ENGINE_EXCLUSION_VERTEX_COUNT, + ); + } + hash + } + + fn overlaps_range(self, range: (usize, usize)) -> Result { + for section in [ + self.constraints, + self.regions, + self.exclusions, + self.inline_objects, + ] { + if !section.is_empty() && overlaps(range, byte_range(self.request, section)?) { + return Ok(true); + } + } + let total = self.regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize + + self.exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + for index in 0..total { + if indexed_vertex_range(self.request, self.regions, self.exclusions, index)? + .is_some_and(|vertices| overlaps(range, vertices)) + { + return Ok(true); + } + } + Ok(false) + } +} + impl<'a> TextMutationBatch<'a> { pub(crate) const fn empty() -> Self { Self { @@ -62,6 +175,25 @@ impl<'a> TextMutationBatch<'a> { insert_utf16_le, }) } + + pub(crate) fn validate_disjoint_geometry(self, geometry: GeometryBatch<'_>) -> Result<(), u32> { + if !self.records.is_empty() + && geometry.overlaps_range(byte_range(self.request, self.records)?)? + { + return Err(STATUS_INVALID_REQUEST); + } + for record in self + .records + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + { + if let Some(range) = text_payload_range(self.request, record)? + && geometry.overlaps_range(range)? + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) + } } pub(crate) fn parse_text_mutations( @@ -90,7 +222,10 @@ pub(crate) fn parse_text_mutations( let record_end = record_start .checked_add(records.len()) .ok_or(STATUS_INVALID_REQUEST)?; - for record in records.chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) { + for (index, record) in records + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + .enumerate() + { if record[ENGINE_TEXT_MUTATION_OPCODE] != TEXT_MUTATION_REPLACE_UTF16 || record[ENGINE_TEXT_MUTATION_ENCODING] != TEXT_ENCODING_UTF16_LE || read_u16(record, ENGINE_TEXT_MUTATION_RESERVED0)? != 0 @@ -107,17 +242,634 @@ pub(crate) fn parse_text_mutations( continue; } let payload = array(request, insert_offset, insert_count, 2, 2)?; - let payload_start = insert_offset as usize; - let payload_end = payload_start - .checked_add(payload.len()) - .ok_or(STATUS_INVALID_REQUEST)?; - if payload_start < record_end && record_start < payload_end { + let payload_range = byte_range(request, payload)?; + if overlaps(payload_range, (record_start, record_end)) { return Err(STATUS_INVALID_REQUEST); } + for previous in records[..index * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize] + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + { + if text_payload_range(request, previous)? + .is_some_and(|range| overlaps(payload_range, range)) + { + return Err(STATUS_INVALID_REQUEST); + } + } } Ok(TextMutationBatch { request, records }) } +fn text_payload_range(request: &[u8], record: &[u8]) -> Result, u32> { + let count = read_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT)?; + if count == 0 { + return Ok(None); + } + let payload = array( + request, + read_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET)?, + count, + 2, + 2, + )?; + Ok(Some(byte_range(request, payload)?)) +} + +#[allow(clippy::too_many_arguments)] +pub(crate) fn parse_geometry( + request: &[u8], + constraints_offset: u32, + constraint_count: u32, + regions_offset: u32, + region_count: u32, + exclusions_offset: u32, + exclusion_count: u32, + inline_objects_offset: u32, + inline_object_count: u32, + limits: UpdateLimits, +) -> Result, u32> { + if region_count > limits.max_regions + || exclusion_count > limits.max_exclusions + || inline_object_count > limits.max_inline_objects + || constraint_count > limits.max_regions + { + return Err(STATUS_INVALID_REQUEST); + } + let all_empty = constraint_count == 0 + && region_count == 0 + && exclusion_count == 0 + && inline_object_count == 0; + if all_empty { + return if constraints_offset == 0 + && regions_offset == 0 + && exclusions_offset == 0 + && inline_objects_offset == 0 + { + Ok(GeometryBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if constraint_count == 0 || region_count == 0 { + return Err(STATUS_INVALID_REQUEST); + } + let constraints = record_table( + request, + constraints_offset, + constraint_count, + abi::ENGINE_CONSTRAINT_RECORD_SIZE, + abi::ENGINE_CONSTRAINT_RECORD_ALIGNMENT, + )?; + let regions = record_table( + request, + regions_offset, + region_count, + abi::ENGINE_REGION_RECORD_SIZE, + abi::ENGINE_REGION_RECORD_ALIGNMENT, + )?; + let exclusions = record_table( + request, + exclusions_offset, + exclusion_count, + abi::ENGINE_EXCLUSION_RECORD_SIZE, + abi::ENGINE_EXCLUSION_RECORD_ALIGNMENT, + )?; + let inline_objects = record_table( + request, + inline_objects_offset, + inline_object_count, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + abi::ENGINE_INLINE_OBJECT_RECORD_ALIGNMENT, + )?; + let fixed = [constraints, regions, exclusions, inline_objects]; + reject_overlapping_slices(request, &fixed)?; + validate_constraints(constraints, region_count, limits)?; + validate_regions(request, regions, exclusions, &fixed)?; + validate_exclusions(request, exclusions, regions, &fixed)?; + validate_inline_objects(inline_objects)?; + reject_overlapping_vertex_payloads(request, regions, exclusions)?; + Ok(GeometryBatch { + request, + constraints, + regions, + exclusions, + inline_objects, + }) +} + +fn record_table( + request: &[u8], + offset: u32, + count: u32, + stride: u32, + alignment: u32, +) -> Result<&[u8], u32> { + if count == 0 { + return if offset == 0 { + Ok(&[]) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < abi::ENGINE_UPDATE_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + array(request, offset, count, stride, alignment) +} + +fn validate_constraints( + constraints: &[u8], + region_count: u32, + limits: UpdateLimits, +) -> Result<(), u32> { + for (index, record) in constraints + .chunks_exact(abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize) + .enumerate() + { + let flow_thread_id = read_u32(record, abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID)?; + if flow_thread_id == 0 + || prior_u32_duplicate( + constraints, + abi::ENGINE_CONSTRAINT_RECORD_SIZE, + abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID, + index, + flow_thread_id, + )? + { + return Err(STATUS_INVALID_REQUEST); + } + let width_mode = byte(record, abi::ENGINE_CONSTRAINT_WIDTH_MODE)?; + let height_mode = byte(record, abi::ENGINE_CONSTRAINT_HEIGHT_MODE)?; + let width = finite(record, abi::ENGINE_CONSTRAINT_WIDTH)?; + let height = finite(record, abi::ENGINE_CONSTRAINT_HEIGHT)?; + if !valid_axis(width_mode, width) || !valid_axis(height_mode, height) { + return Err(STATUS_INVALID_REQUEST); + } + let viewport_start = finite(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START)?; + let viewport_end = finite(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END)?; + if viewport_start > viewport_end + || !finite(record, abi::ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET)?.is_finite() + || read_u32(record, abi::ENGINE_CONSTRAINT_MAX_LINES)? > limits.max_lines + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_WRAP)?, + WRAP_NONE | WRAP_WORD | WRAP_CHARACTER + ) + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_ALIGN)?, + ALIGN_START | ALIGN_CENTER | ALIGN_END | ALIGN_JUSTIFY + ) + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_OVERFLOW)?, + OVERFLOW_VISIBLE | OVERFLOW_CLIP | OVERFLOW_ELLIPSIS + ) + || !matches!( + byte(record, abi::ENGINE_CONSTRAINT_BLOCK_ALIGN)?, + BLOCK_ALIGN_START | BLOCK_ALIGN_CENTER | BLOCK_ALIGN_END + ) + || read_u16(record, abi::ENGINE_CONSTRAINT_FLAGS)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let region_start = read_u32(record, abi::ENGINE_CONSTRAINT_REGION_START)?; + let selected_count = u32::from(read_u16(record, abi::ENGINE_CONSTRAINT_REGION_COUNT)?); + let resume_region = u32::from(read_u16(record, abi::ENGINE_CONSTRAINT_RESUME_REGION)?); + if selected_count == 0 + || region_start + .checked_add(selected_count) + .is_none_or(|end| end > region_count) + || resume_region > selected_count + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn validate_regions( + request: &[u8], + regions: &[u8], + exclusions: &[u8], + fixed: &[&[u8]], +) -> Result<(), u32> { + let exclusion_total = exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + for (index, record) in regions + .chunks_exact(abi::ENGINE_REGION_RECORD_SIZE as usize) + .enumerate() + { + let id = read_u32(record, abi::ENGINE_REGION_ID)?; + if id == 0 + || prior_u32_duplicate( + regions, + abi::ENGINE_REGION_RECORD_SIZE, + abi::ENGINE_REGION_ID, + index, + id, + )? + || read_u16(record, abi::ENGINE_REGION_FLAGS)? != 0 + || byte(record, abi::ENGINE_REGION_RESERVED0)? != 0 + || !matches!( + byte(record, abi::ENGINE_REGION_WRITING_MODE)?, + WRITING_HORIZONTAL_TB | WRITING_VERTICAL_RL | WRITING_VERTICAL_LR + ) + || !matches!( + byte(record, abi::ENGINE_REGION_TEXT_ORIENTATION)?, + ORIENTATION_MIXED | ORIENTATION_UPRIGHT | ORIENTATION_SIDEWAYS + ) + { + return Err(STATUS_INVALID_REQUEST); + } + let region_bounds = bounds( + record, + abi::ENGINE_REGION_INLINE_START, + abi::ENGINE_REGION_BLOCK_START, + abi::ENGINE_REGION_INLINE_END, + abi::ENGINE_REGION_BLOCK_END, + )?; + let clip = bounds( + record, + abi::ENGINE_REGION_CLIP_INLINE_START, + abi::ENGINE_REGION_CLIP_BLOCK_START, + abi::ENGINE_REGION_CLIP_INLINE_END, + abi::ENGINE_REGION_CLIP_BLOCK_END, + )?; + if clip.0 < region_bounds.0 + || clip.1 < region_bounds.1 + || clip.2 > region_bounds.2 + || clip.3 > region_bounds.3 + { + return Err(STATUS_INVALID_REQUEST); + } + validate_shape( + request, + record, + abi::ENGINE_REGION_SHAPE, + abi::ENGINE_REGION_VERTICES_OFFSET, + abi::ENGINE_REGION_VERTEX_COUNT, + region_bounds, + fixed, + )?; + let exclusion_start = usize::from(read_u16(record, abi::ENGINE_REGION_EXCLUSION_START)?); + let exclusion_count = usize::from(read_u16(record, abi::ENGINE_REGION_EXCLUSION_COUNT)?); + let end = exclusion_start + .checked_add(exclusion_count) + .ok_or(STATUS_INVALID_REQUEST)?; + if end > exclusion_total { + return Err(STATUS_INVALID_REQUEST); + } + for exclusion in exclusions[exclusion_start * abi::ENGINE_EXCLUSION_RECORD_SIZE as usize + ..end * abi::ENGINE_EXCLUSION_RECORD_SIZE as usize] + .chunks_exact(abi::ENGINE_EXCLUSION_RECORD_SIZE as usize) + { + if read_u32(exclusion, abi::ENGINE_EXCLUSION_REGION_ID)? != id { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn validate_exclusions( + request: &[u8], + exclusions: &[u8], + regions: &[u8], + fixed: &[&[u8]], +) -> Result<(), u32> { + for (index, record) in exclusions + .chunks_exact(abi::ENGINE_EXCLUSION_RECORD_SIZE as usize) + .enumerate() + { + let id = read_u32(record, abi::ENGINE_EXCLUSION_ID)?; + let region_id = read_u32(record, abi::ENGINE_EXCLUSION_REGION_ID)?; + if id == 0 + || prior_u32_duplicate( + exclusions, + abi::ENGINE_EXCLUSION_RECORD_SIZE, + abi::ENGINE_EXCLUSION_ID, + index, + id, + )? + || !contains_u32( + regions, + abi::ENGINE_REGION_RECORD_SIZE, + abi::ENGINE_REGION_ID, + region_id, + )? + || read_u16(record, abi::ENGINE_EXCLUSION_FLAGS)? != 0 + || read_u16(record, abi::ENGINE_EXCLUSION_RESERVED0)? != 0 + || !matches!( + byte(record, abi::ENGINE_EXCLUSION_WRAP_SIDE)?, + EXCLUSION_WRAP_BOTH + | EXCLUSION_WRAP_INLINE_START + | EXCLUSION_WRAP_INLINE_END + | EXCLUSION_WRAP_LARGEST + ) + { + return Err(STATUS_INVALID_REQUEST); + } + let shape_bounds = bounds( + record, + abi::ENGINE_EXCLUSION_INLINE_START, + abi::ENGINE_EXCLUSION_BLOCK_START, + abi::ENGINE_EXCLUSION_INLINE_END, + abi::ENGINE_EXCLUSION_BLOCK_END, + )?; + if finite(record, abi::ENGINE_EXCLUSION_MARGIN_INLINE)? < 0.0 + || finite(record, abi::ENGINE_EXCLUSION_MARGIN_BLOCK)? < 0.0 + { + return Err(STATUS_INVALID_REQUEST); + } + validate_shape( + request, + record, + abi::ENGINE_EXCLUSION_SHAPE, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + abi::ENGINE_EXCLUSION_VERTEX_COUNT, + shape_bounds, + fixed, + )?; + } + Ok(()) +} + +fn validate_inline_objects(records: &[u8]) -> Result<(), u32> { + for (index, record) in records + .chunks_exact(abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize) + .enumerate() + { + let id = read_u32(record, abi::ENGINE_INLINE_OBJECT_ID)?; + if id == 0 + || prior_u32_duplicate( + records, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + abi::ENGINE_INLINE_OBJECT_ID, + index, + id, + )? + || read_u32(record, abi::ENGINE_INLINE_OBJECT_RESOURCE_ID)? == 0 + || read_u32(record, abi::ENGINE_INLINE_OBJECT_RESOURCE_GENERATION)? == 0 + || finite(record, abi::ENGINE_INLINE_OBJECT_INLINE_EXTENT)? < 0.0 + || finite(record, abi::ENGINE_INLINE_OBJECT_BLOCK_EXTENT)? < 0.0 + || !finite(record, abi::ENGINE_INLINE_OBJECT_BASELINE_OFFSET)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_INLINE_START)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_INLINE_END)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_BLOCK_START)?.is_finite() + || !finite(record, abi::ENGINE_INLINE_OBJECT_MARGIN_BLOCK_END)?.is_finite() + || !matches!( + byte(record, abi::ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT)?, + BASELINE_ALPHABETIC | BASELINE_TEXT_TOP | BASELINE_MIDDLE | BASELINE_TEXT_BOTTOM + ) + || byte(record, abi::ENGINE_INLINE_OBJECT_FLAGS)? != 0 + || read_u16(record, abi::ENGINE_INLINE_OBJECT_RESERVED0)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn validate_shape( + request: &[u8], + record: &[u8], + shape_offset: usize, + vertices_offset: usize, + vertex_count_offset: usize, + shape_bounds: (f32, f32, f32, f32), + fixed: &[&[u8]], +) -> Result<(), u32> { + let shape = byte(record, shape_offset)?; + let offset = read_u32(record, vertices_offset)?; + let count = u32::from(read_u16(record, vertex_count_offset)?); + match shape { + SHAPE_RECTANGLE if offset == 0 && count == 0 => Ok(()), + SHAPE_POLYGON if count >= 3 => { + let vertices = record_table( + request, + offset, + count, + abi::ENGINE_FLOW_VERTEX_RECORD_SIZE, + abi::ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + )?; + reject_payload_overlap(request, vertices, fixed)?; + for vertex in vertices.chunks_exact(abi::ENGINE_FLOW_VERTEX_RECORD_SIZE as usize) { + let inline = finite(vertex, abi::ENGINE_FLOW_VERTEX_INLINE)?; + let block = finite(vertex, abi::ENGINE_FLOW_VERTEX_BLOCK)?; + if inline < shape_bounds.0 + || block < shape_bounds.1 + || inline > shape_bounds.2 + || block > shape_bounds.3 + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) + } + _ => Err(STATUS_INVALID_REQUEST), + } +} + +fn bounds( + record: &[u8], + inline_start: usize, + block_start: usize, + inline_end: usize, + block_end: usize, +) -> Result<(f32, f32, f32, f32), u32> { + let values = ( + finite(record, inline_start)?, + finite(record, block_start)?, + finite(record, inline_end)?, + finite(record, block_end)?, + ); + if values.0 >= values.2 || values.1 >= values.3 { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(values) + } +} + +fn valid_axis(mode: u8, value: f32) -> bool { + match mode { + AXIS_UNCONSTRAINED => value == 0.0, + AXIS_AT_MOST | AXIS_EXACT => value >= 0.0, + _ => false, + } +} + +fn finite(record: &[u8], offset: usize) -> Result { + let value = read_f32(record, offset)?; + if value.is_finite() { + Ok(value) + } else { + Err(STATUS_INVALID_REQUEST) + } +} + +fn byte(record: &[u8], offset: usize) -> Result { + record.get(offset).copied().ok_or(STATUS_INVALID_REQUEST) +} + +fn prior_u32_duplicate( + records: &[u8], + stride: u32, + field: usize, + index: usize, + value: u32, +) -> Result { + for record in records[..index * stride as usize].chunks_exact(stride as usize) { + if read_u32(record, field)? == value { + return Ok(true); + } + } + Ok(false) +} + +fn contains_u32(records: &[u8], stride: u32, field: usize, value: u32) -> Result { + for record in records.chunks_exact(stride as usize) { + if read_u32(record, field)? == value { + return Ok(true); + } + } + Ok(false) +} + +fn reject_overlapping_slices(request: &[u8], slices: &[&[u8]]) -> Result<(), u32> { + for (index, slice) in slices.iter().enumerate() { + if slice.is_empty() { + continue; + } + let current = byte_range(request, slice)?; + for other in &slices[..index] { + if !other.is_empty() && overlaps(current, byte_range(request, other)?) { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn reject_payload_overlap(request: &[u8], payload: &[u8], fixed: &[&[u8]]) -> Result<(), u32> { + let payload = byte_range(request, payload)?; + for table in fixed { + if !table.is_empty() && overlaps(payload, byte_range(request, table)?) { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn reject_overlapping_vertex_payloads( + request: &[u8], + regions: &[u8], + exclusions: &[u8], +) -> Result<(), u32> { + let total = regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize + + exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + for index in 0..total { + let Some(current) = indexed_vertex_range(request, regions, exclusions, index)? else { + continue; + }; + for previous in 0..index { + if indexed_vertex_range(request, regions, exclusions, previous)? + .is_some_and(|range| overlaps(current, range)) + { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) +} + +fn indexed_vertex_range( + request: &[u8], + regions: &[u8], + exclusions: &[u8], + index: usize, +) -> Result, u32> { + let region_count = regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize; + let (record, shape, offset, count) = if index < region_count { + let start = index * abi::ENGINE_REGION_RECORD_SIZE as usize; + ( + ®ions[start..start + abi::ENGINE_REGION_RECORD_SIZE as usize], + abi::ENGINE_REGION_SHAPE, + abi::ENGINE_REGION_VERTICES_OFFSET, + abi::ENGINE_REGION_VERTEX_COUNT, + ) + } else { + let start = (index - region_count) * abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + ( + &exclusions[start..start + abi::ENGINE_EXCLUSION_RECORD_SIZE as usize], + abi::ENGINE_EXCLUSION_SHAPE, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + abi::ENGINE_EXCLUSION_VERTEX_COUNT, + ) + }; + if byte(record, shape)? != SHAPE_POLYGON { + return Ok(None); + } + let vertices = array( + request, + read_u32(record, offset)?, + u32::from(read_u16(record, count)?), + abi::ENGINE_FLOW_VERTEX_RECORD_SIZE, + abi::ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + )?; + Ok(Some(byte_range(request, vertices)?)) +} + +fn byte_range(request: &[u8], slice: &[u8]) -> Result<(usize, usize), u32> { + let request_start = request.as_ptr() as usize; + let start = (slice.as_ptr() as usize) + .checked_sub(request_start) + .ok_or(STATUS_INVALID_REQUEST)?; + let end = start + .checked_add(slice.len()) + .ok_or(STATUS_INVALID_REQUEST)?; + if end > request.len() { + Err(STATUS_INVALID_REQUEST) + } else { + Ok((start, end)) + } +} + +fn overlaps(left: (usize, usize), right: (usize, usize)) -> bool { + left.0 < right.1 && right.0 < left.1 +} + +fn mix_bytes(hash: &mut u64, bytes: &[u8]) { + for byte in bytes { + *hash ^= u64::from(*byte); + *hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } +} + +fn mix_record_without_u32(hash: &mut u64, record: &[u8], offset: usize) { + mix_bytes(hash, &record[..offset]); + mix_bytes(hash, &record[offset + 4..]); +} + +fn mix_vertex_payload( + hash: &mut u64, + request: &[u8], + record: &[u8], + shape: usize, + offset: usize, + count: usize, +) { + if byte(record, shape).ok() != Some(SHAPE_POLYGON) { + return; + } + if let (Ok(offset), Ok(count)) = (read_u32(record, offset), read_u16(record, count)) + && let Ok(vertices) = array( + request, + offset, + u32::from(count), + abi::ENGINE_FLOW_VERTEX_RECORD_SIZE, + abi::ENGINE_FLOW_VERTEX_RECORD_ALIGNMENT, + ) + { + mix_bytes(hash, vertices); + } +} + #[cfg(test)] mod tests { use super::*; @@ -172,4 +924,317 @@ mod tests { write_u32(record, ENGINE_TEXT_MUTATION_INSERT_COUNT, 1); assert!(parse_text_mutations(&bytes, record_offset, 1).is_err()); } + + #[test] + fn validates_one_call_rectangle_flow_and_text_anchored_objects() { + let bytes = valid_geometry_bytes(); + let geometry = parse_valid_geometry(&bytes).unwrap(); + geometry.validate_text_length(0).unwrap(); + assert_ne!(geometry.fingerprint(), 0); + + let mut polygon = bytes.clone(); + polygon.resize( + polygon.len() + 3 * abi::ENGINE_FLOW_VERTEX_RECORD_SIZE as usize, + 0, + ); + polygon[REGION_OFFSET + abi::ENGINE_REGION_SHAPE] = SHAPE_POLYGON; + write_u32( + &mut polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTICES_OFFSET, + GEOMETRY_LENGTH as u32, + ); + write_u16( + &mut polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTEX_COUNT, + 3, + ); + for (index, (inline, block)) in [(0.0, 0.0), (100.0, 0.0), (0.0, 100.0)] + .into_iter() + .enumerate() + { + let offset = GEOMETRY_LENGTH + index * abi::ENGINE_FLOW_VERTEX_RECORD_SIZE as usize; + write_f32( + &mut polygon, + offset + abi::ENGINE_FLOW_VERTEX_INLINE, + inline, + ); + write_f32(&mut polygon, offset + abi::ENGINE_FLOW_VERTEX_BLOCK, block); + } + let polygon_geometry = parse_valid_geometry(&polygon).unwrap(); + assert_ne!(polygon_geometry.fingerprint(), geometry.fingerprint()); + let mut relocated_polygon = polygon[..GEOMETRY_LENGTH].to_vec(); + relocated_polygon.resize(GEOMETRY_LENGTH + 8, 0); + relocated_polygon.extend_from_slice(&polygon[GEOMETRY_LENGTH..]); + write_u32( + &mut relocated_polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTICES_OFFSET, + (GEOMETRY_LENGTH + 8) as u32, + ); + assert_eq!( + parse_valid_geometry(&relocated_polygon) + .unwrap() + .fingerprint(), + polygon_geometry.fingerprint(), + "request placement is not semantic geometry", + ); + + let mut outside_text = bytes.clone(); + let inline = INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_TEXT_OFFSET; + write_u32(&mut outside_text, inline, 1); + assert!( + parse_valid_geometry(&outside_text) + .unwrap() + .validate_text_length(0) + .is_err() + ); + } + + #[test] + fn rejects_invalid_geometry_relationships_and_payload_aliasing() { + let mut wrong_region = valid_geometry_bytes(); + write_u32( + &mut wrong_region, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_REGION_ID, + 9, + ); + assert!(parse_valid_geometry(&wrong_region).is_err()); + + let mut nonfinite = valid_geometry_bytes(); + write_f32( + &mut nonfinite, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_MARGIN_INLINE, + f32::NAN, + ); + assert!(parse_valid_geometry(&nonfinite).is_err()); + + let mut overlapping_polygon = valid_geometry_bytes(); + overlapping_polygon[REGION_OFFSET + abi::ENGINE_REGION_SHAPE] = SHAPE_POLYGON; + write_u32( + &mut overlapping_polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTICES_OFFSET, + CONSTRAINT_OFFSET as u32, + ); + write_u16( + &mut overlapping_polygon, + REGION_OFFSET + abi::ENGINE_REGION_VERTEX_COUNT, + 3, + ); + assert!(parse_valid_geometry(&overlapping_polygon).is_err()); + + let mut cross_section_alias = valid_geometry_bytes(); + let mutation_offset = cross_section_alias.len(); + cross_section_alias.resize( + mutation_offset + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize, + 0, + ); + cross_section_alias[mutation_offset + ENGINE_TEXT_MUTATION_OPCODE] = + TEXT_MUTATION_REPLACE_UTF16; + cross_section_alias[mutation_offset + ENGINE_TEXT_MUTATION_ENCODING] = + TEXT_ENCODING_UTF16_LE; + write_u32( + &mut cross_section_alias, + mutation_offset + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + CONSTRAINT_OFFSET as u32, + ); + write_u32( + &mut cross_section_alias, + mutation_offset + ENGINE_TEXT_MUTATION_INSERT_COUNT, + 2, + ); + let text = parse_text_mutations(&cross_section_alias, mutation_offset as u32, 1).unwrap(); + let geometry = parse_valid_geometry(&cross_section_alias).unwrap(); + assert!(text.validate_disjoint_geometry(geometry).is_err()); + + assert!( + parse_geometry( + &valid_geometry_bytes(), + CONSTRAINT_OFFSET as u32, + 1, + REGION_OFFSET as u32, + 1, + EXCLUSION_OFFSET as u32, + 1, + INLINE_OFFSET as u32, + 1, + UpdateLimits { + max_regions: 1, + max_exclusions: 0, + ..limits() + }, + ) + .is_err() + ); + } + + const CONSTRAINT_OFFSET: usize = abi::ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + const REGION_OFFSET: usize = CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize; + const EXCLUSION_OFFSET: usize = REGION_OFFSET + abi::ENGINE_REGION_RECORD_SIZE as usize; + const INLINE_OFFSET: usize = EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_RECORD_SIZE as usize; + const GEOMETRY_LENGTH: usize = INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize; + + fn parse_valid_geometry(bytes: &[u8]) -> Result, u32> { + parse_geometry( + bytes, + CONSTRAINT_OFFSET as u32, + 1, + REGION_OFFSET as u32, + 1, + EXCLUSION_OFFSET as u32, + 1, + INLINE_OFFSET as u32, + 1, + limits(), + ) + } + + fn limits() -> UpdateLimits { + UpdateLimits { + max_clusters: 16, + max_lines: 16, + max_regions: 4, + max_exclusions: 4, + max_inline_objects: 4, + max_slots_per_band: 4, + max_output_bytes: 4096, + } + } + + fn valid_geometry_bytes() -> Vec { + let mut bytes = vec![0; GEOMETRY_LENGTH]; + write_u32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID, + 1, + ); + write_f32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WIDTH, + 100.0, + ); + write_f32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_HEIGHT, + 100.0, + ); + write_f32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END, + 100.0, + ); + write_u32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_MAX_LINES, + 16, + ); + write_u16( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_REGION_COUNT, + 1, + ); + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WIDTH_MODE] = AXIS_EXACT; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_HEIGHT_MODE] = AXIS_EXACT; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WRAP] = WRAP_WORD; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_ALIGN] = ALIGN_START; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_OVERFLOW] = OVERFLOW_CLIP; + bytes[CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_BLOCK_ALIGN] = BLOCK_ALIGN_START; + + write_u32(&mut bytes, REGION_OFFSET + abi::ENGINE_REGION_ID, 1); + write_u32( + &mut bytes, + REGION_OFFSET + abi::ENGINE_REGION_GEOMETRY_REVISION, + 1, + ); + write_u16( + &mut bytes, + REGION_OFFSET + abi::ENGINE_REGION_EXCLUSION_COUNT, + 1, + ); + bytes[REGION_OFFSET + abi::ENGINE_REGION_SHAPE] = SHAPE_RECTANGLE; + bytes[REGION_OFFSET + abi::ENGINE_REGION_WRITING_MODE] = WRITING_HORIZONTAL_TB; + bytes[REGION_OFFSET + abi::ENGINE_REGION_TEXT_ORIENTATION] = ORIENTATION_MIXED; + for field in [ + abi::ENGINE_REGION_INLINE_END, + abi::ENGINE_REGION_BLOCK_END, + abi::ENGINE_REGION_CLIP_INLINE_END, + abi::ENGINE_REGION_CLIP_BLOCK_END, + ] { + write_f32(&mut bytes, REGION_OFFSET + field, 100.0); + } + + write_u32(&mut bytes, EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_ID, 2); + write_u32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_REGION_ID, + 1, + ); + write_u32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_GEOMETRY_REVISION, + 1, + ); + bytes[EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_SHAPE] = SHAPE_RECTANGLE; + bytes[EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_WRAP_SIDE] = EXCLUSION_WRAP_BOTH; + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_INLINE_START, + 20.0, + ); + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_BLOCK_START, + 20.0, + ); + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_INLINE_END, + 40.0, + ); + write_f32( + &mut bytes, + EXCLUSION_OFFSET + abi::ENGINE_EXCLUSION_BLOCK_END, + 40.0, + ); + + write_u32(&mut bytes, INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_ID, 3); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_CONTENT_REVISION, + 1, + ); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_MATERIAL_ID, + 1, + ); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_RESOURCE_ID, + 4, + ); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_RESOURCE_GENERATION, + 1, + ); + write_f32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_INLINE_EXTENT, + 10.0, + ); + write_f32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_BLOCK_EXTENT, + 10.0, + ); + bytes[INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT] = BASELINE_ALPHABETIC; + bytes + } + + fn write_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn write_f32(bytes: &mut [u8], offset: usize, value: f32) { + write_u32(bytes, offset, value.to_bits()); + } } diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index bc72927e..71bfd4af 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -53,6 +53,9 @@ struct EngineSession { text: Vec, pending_text: Vec, text_prepared: bool, + geometry_fingerprint: u64, + pending_geometry_fingerprint: u64, + geometry_prepared: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -321,6 +324,10 @@ impl TextEngine { // plan preparation or publication later aborts. session.acknowledged_publication_generation = request.acknowledged_publication_generation; session.prepare_text(request.text_mutations)?; + if let Err(error) = session.prepare_geometry(request.geometry) { + session.abort_text(); + return Err(error); + } if let Err(error) = gather.gather( policy, CapabilitySetId(request.capability_set), @@ -337,6 +344,7 @@ impl TextEngine { }, ) { session.abort_text(); + session.abort_geometry(); return Err(gather_error(error)); } let gathered = gather.view(); @@ -349,6 +357,7 @@ impl TextEngine { request.acknowledged_publication_generation, ) { session.abort_text(); + session.abort_geometry(); return Err(plan_error(error)); } Ok(PreparedUpdate { @@ -394,6 +403,7 @@ impl TextEngine { } session.plan.abort(); session.abort_text(); + session.abort_geometry(); Ok(()) } @@ -410,6 +420,7 @@ impl TextEngine { } session.plan.commit().map_err(plan_error)?; session.commit_text(); + session.commit_geometry(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, @@ -465,6 +476,36 @@ impl EngineSession { } self.abort_text(); } + + fn prepare_geometry( + &mut self, + geometry: super::semantic_wire::GeometryBatch<'_>, + ) -> Result<(), EngineError> { + self.abort_geometry(); + let text_length = if self.text_prepared { + self.pending_text.len() + } else { + self.text.len() + }; + geometry + .validate_text_length(text_length) + .map_err(|_| EngineError::InvalidRequest)?; + self.pending_geometry_fingerprint = geometry.fingerprint(); + self.geometry_prepared = true; + Ok(()) + } + + fn abort_geometry(&mut self) { + self.pending_geometry_fingerprint = 0; + self.geometry_prepared = false; + } + + fn commit_geometry(&mut self) { + if self.geometry_prepared { + self.geometry_fingerprint = self.pending_geometry_fingerprint; + } + self.abort_geometry(); + } } fn apply_text_mutation( @@ -972,6 +1013,7 @@ mod tests { max_output_bytes: 128, }, text_mutations: super::super::semantic_wire::TextMutationBatch::empty(), + geometry: super::super::semantic_wire::GeometryBatch::empty(), } } diff --git a/packages/text/rust/shaper/src/wire.rs b/packages/text/rust/shaper/src/wire.rs index 5a02a334..9446bcfe 100644 --- a/packages/text/rust/shaper/src/wire.rs +++ b/packages/text/rust/shaper/src/wire.rs @@ -350,6 +350,10 @@ pub(crate) fn read_u32(bytes: &[u8], offset: usize) -> Result { Ok(u32::from_le_bytes([value[0], value[1], value[2], value[3]])) } +pub(crate) fn read_f32(bytes: &[u8], offset: usize) -> Result { + Ok(f32::from_bits(read_u32(bytes, offset)?)) +} + pub(crate) fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 1c22a4bf..21015aa8 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -34,18 +34,51 @@ export const textShaperAbi = { }, "endianness": "little", "engine": { + "axisModes": { + "atMost": 2, + "exact": 3, + "unconstrained": 1 + }, + "blockAlignments": { + "center": 2, + "end": 3, + "start": 1 + }, "bufferStrategies": { "orderedDirect": 1, "stableIndirect": 2 }, "defaultSessionTextCapacity": 1024, + "exclusionWrapSides": { + "both": 1, + "inlineEnd": 3, + "inlineStart": 2, + "largest": 4 + }, "flowShapeKinds": { "polygon": 2, "rectangle": 1 }, + "inlineAlignments": { + "center": 2, + "end": 3, + "justify": 4, + "start": 1 + }, + "inlineObjectBaselines": { + "alphabetic": 1, + "middle": 3, + "textBottom": 4, + "textTop": 2 + }, "internalBufferBindings": { "order": 65535 }, + "overflowModes": { + "clip": 2, + "ellipsis": 3, + "visible": 1 + }, "patchOpcodes": { "allocateOrResize": 1, "copy": 4, @@ -93,6 +126,16 @@ export const textShaperAbi = { "textMutationOpcodes": { "replaceUtf16": 1 }, + "textOrientations": { + "mixed": 1, + "sideways": 3, + "upright": 2 + }, + "wrapModes": { + "character": 3, + "none": 1, + "word": 2 + }, "writingModes": { "horizontalTb": 1, "verticalLr": 3, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 58d96762..39e2b0f1 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -52,6 +52,14 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.equal(abi.engine.styleMutationOpcodes.remove, 2); assert.deepEqual(abi.engine.flowShapeKinds, { polygon: 2, rectangle: 1 }); assert.deepEqual(abi.engine.writingModes, { horizontalTb: 1, verticalLr: 3, verticalRl: 2 }); + assert.deepEqual(abi.engine.textOrientations, { mixed: 1, sideways: 3, upright: 2 }); + assert.deepEqual(abi.engine.axisModes, { atMost: 2, exact: 3, unconstrained: 1 }); + assert.deepEqual(abi.engine.wrapModes, { character: 3, none: 1, word: 2 }); + assert.deepEqual(abi.engine.inlineAlignments, { center: 2, end: 3, justify: 4, start: 1 }); + assert.deepEqual(abi.engine.overflowModes, { clip: 2, ellipsis: 3, visible: 1 }); + assert.deepEqual(abi.engine.blockAlignments, { center: 2, end: 3, start: 1 }); + assert.deepEqual(abi.engine.exclusionWrapSides, { both: 1, inlineEnd: 3, inlineStart: 2, largest: 4 }); + assert.deepEqual(abi.engine.inlineObjectBaselines, { alphabetic: 1, middle: 3, textBottom: 4, textTop: 2 }); assert.equal(abi.engine.defaultSessionTextCapacity, 1024); assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size, 0), abi.status.ok); assert.equal(fn.sessionCount(), 1); @@ -132,9 +140,9 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as }); const checkpointHeader = resultBytes(memory, checkpointPointer, resultLayout).slice(); - assert.equal(fn.reserveSession(sessionId, 256, resultLayout.size, 8), abi.status.ok); + assert.equal(fn.reserveSession(sessionId, 512, resultLayout.size, 8), abi.status.ok); requestPointer = fn.requestPointer(sessionId); - assert.ok(fn.requestCapacity(sessionId) >= 256); + assert.ok(fn.requestCapacity(sessionId) >= 512); const textWarmBuffer = memory.buffer; const insertLength = writeRequest(memory, requestPointer, abi, 3, 3, 3, [ { start: 0, deleteCount: 0, insert: [0x61, 0x62, 0x63] }, @@ -183,19 +191,35 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as }); assert.deepEqual(resultBytes(memory, retainedEditPointer, resultLayout), retainedHeader); - writeRequest(memory, requestPointer, abi, 5, 5, 5); - new DataView(memory.buffer, requestPointer, requestLayout.size).setUint32(requestLayout.regionCount, 1, true); - const unsupportedPointer = fn.textUpdate(sessionId, requestPointer, requestLayout.size); - assertResult(memory, unsupportedPointer, abi, { - status: abi.status.invalidRequest, - engineRevision: 5, - planRevision: 5, + const geometry = geometryRequestBytes(abi, 5, 5, 5); + new Uint8Array(memory.buffer, requestPointer, geometry.byteLength).set(geometry); + const geometryPointer = fn.textUpdate(sessionId, requestPointer, geometry.byteLength); + assertResult(memory, geometryPointer, abi, { + status: abi.status.ok, + engineRevision: 6, + planRevision: 6, requiredBaseRevision: 5, - publicationGeneration: 5, + publicationGeneration: 6, outputSlot: 1, flags: 0, }); - assert.deepEqual(resultBytes(memory, retainedEditPointer, resultLayout), retainedHeader); + const geometryHeader = resultBytes(memory, geometryPointer, resultLayout).slice(); + + const invalidGeometry = geometryRequestBytes(abi, 6, 6, 6); + const exclusionOffset = new DataView(invalidGeometry.buffer).getUint32(requestLayout.exclusionsOffset, true); + new DataView(invalidGeometry.buffer).setUint32(exclusionOffset + abi.layouts.engineExclusion.regionId, 9, true); + new Uint8Array(memory.buffer, requestPointer, invalidGeometry.byteLength).set(invalidGeometry); + const invalidGeometryPointer = fn.textUpdate(sessionId, requestPointer, invalidGeometry.byteLength); + assertResult(memory, invalidGeometryPointer, abi, { + status: abi.status.invalidRequest, + engineRevision: 6, + planRevision: 6, + requiredBaseRevision: 6, + publicationGeneration: 6, + outputSlot: 0, + flags: 0, + }); + assert.deepEqual(resultBytes(memory, geometryPointer, resultLayout), geometryHeader); const oldBuffer = memory.buffer; const grownCapacity = 8 * 1024 * 1024; @@ -234,6 +258,83 @@ function writeRequest( return bytes.byteLength; } +function geometryRequestBytes(abi, expectedEngineRevision, consumedPlanRevision, acknowledgedPublicationGeneration) { + const request = abi.layouts.engineUpdateRequest; + const constraint = abi.layouts.engineConstraint; + const region = abi.layouts.engineRegion; + const exclusion = abi.layouts.engineExclusion; + const inlineObject = abi.layouts.engineInlineObject; + const constraintOffset = request.size; + const regionOffset = constraintOffset + constraint.size; + const exclusionOffset = regionOffset + region.size; + const inlineObjectOffset = exclusionOffset + exclusion.size; + const bytes = new Uint8Array(inlineObjectOffset + inlineObject.size); + bytes.set( + engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision, + consumedPlanRevision, + acknowledgedPublicationGeneration, + }), + ); + const view = new DataView(bytes.buffer); + view.setUint32(request.byteLength, bytes.byteLength, true); + for (const [offsetField, countField, offset] of [ + ['constraintsOffset', 'constraintCount', constraintOffset], + ['regionsOffset', 'regionCount', regionOffset], + ['exclusionsOffset', 'exclusionCount', exclusionOffset], + ['inlineObjectsOffset', 'inlineObjectCount', inlineObjectOffset], + ]) { + view.setUint32(request[offsetField], offset, true); + view.setUint32(request[countField], 1, true); + } + + view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); + view.setFloat32(constraintOffset + constraint.width, 100, true); + view.setFloat32(constraintOffset + constraint.height, 100, true); + view.setFloat32(constraintOffset + constraint.viewportBlockEnd, 100, true); + view.setUint32(constraintOffset + constraint.maxLines, 1, true); + view.setUint16(constraintOffset + constraint.regionCount, 1, true); + view.setUint8(constraintOffset + constraint.widthMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.heightMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.wrap, abi.engine.wrapModes.word); + view.setUint8(constraintOffset + constraint.align, abi.engine.inlineAlignments.start); + view.setUint8(constraintOffset + constraint.overflow, abi.engine.overflowModes.clip); + view.setUint8(constraintOffset + constraint.blockAlign, abi.engine.blockAlignments.start); + + view.setUint32(regionOffset + region.id, 1, true); + view.setUint32(regionOffset + region.geometryRevision, 1, true); + view.setUint16(regionOffset + region.exclusionCount, 1, true); + view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); + view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); + for (const field of ['inlineEnd', 'blockEnd', 'clipInlineEnd', 'clipBlockEnd']) { + view.setFloat32(regionOffset + region[field], 100, true); + } + + view.setUint32(exclusionOffset + exclusion.id, 2, true); + view.setUint32(exclusionOffset + exclusion.regionId, 1, true); + view.setUint32(exclusionOffset + exclusion.geometryRevision, 1, true); + view.setUint8(exclusionOffset + exclusion.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(exclusionOffset + exclusion.wrapSide, abi.engine.exclusionWrapSides.both); + view.setFloat32(exclusionOffset + exclusion.inlineStart, 20, true); + view.setFloat32(exclusionOffset + exclusion.blockStart, 20, true); + view.setFloat32(exclusionOffset + exclusion.inlineEnd, 40, true); + view.setFloat32(exclusionOffset + exclusion.blockEnd, 40, true); + + view.setUint32(inlineObjectOffset + inlineObject.id, 3, true); + view.setUint32(inlineObjectOffset + inlineObject.contentRevision, 1, true); + view.setUint32(inlineObjectOffset + inlineObject.textOffset, 1, true); + view.setUint32(inlineObjectOffset + inlineObject.materialId, 1, true); + view.setUint32(inlineObjectOffset + inlineObject.resourceId, 4, true); + view.setUint32(inlineObjectOffset + inlineObject.resourceGeneration, 1, true); + view.setFloat32(inlineObjectOffset + inlineObject.inlineExtent, 10, true); + view.setFloat32(inlineObjectOffset + inlineObject.blockExtent, 10, true); + view.setUint8(inlineObjectOffset + inlineObject.baselineAlignment, abi.engine.inlineObjectBaselines.alphabetic); + return bytes; +} + function assertResult(memory, pointer, abi, expected) { const layout = abi.layouts.engineResult; const view = new DataView(memory.buffer, pointer, layout.size); From cdd7fbc9e7d41ebe8285ec700523ba096a452a6a Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 13:19:14 -0400 Subject: [PATCH 026/128] feat(text): define retained style wire contract --- docs/log.md | 7 ++ docs/packages/text.md | 10 ++- docs/planning/decision-register.md | 3 +- docs/planning/rust-layout-engine.md | 2 +- packages/text/rust/shaper/src/abi_contract.rs | 69 +++++++++++++++-- packages/text/rust/shaper/src/engine/frame.rs | 26 +++++++ packages/text/rust/shaper/src/lib.rs | 27 ++++--- .../text/src/generated/text-shaper-abi.ts | 76 ++++++++++++++----- .../render-plan-frame-abi.test.mjs | 21 ++++- 9 files changed, 198 insertions(+), 43 deletions(-) diff --git a/docs/log.md b/docs/log.md index 24c0f18d..086bbe84 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Fixed the retained style wire semantics before admission** — The compiler-mapped style record is now 88 bytes and + separates stable `styleId` from authored `cascadeOrder`. A stated-property field mask preserves inheritance and + explicit zero values, target raster density is available for Rust-owned bitmap strike selection, and generated + vocabularies pin style, decoration-style, and decoration-line flags. Rust unit tests and the compiled-Wasm frame ABI + test pass. Nonempty styles remain rejected until the transactional retained arena consumes this contract, so no + shaping/layout or frame-latency claim is attached. + - **Admitted one-call editorial geometry into the Rust frame transaction** — Constraints, regions, exclusions, bounded rectangle/polygon vertices, and inline objects now decode as borrowed records from one pinned request. Validation covers limits, finite ordered bounds, enum/reserved data, identities, region ownership/ranges, pending-text anchors, diff --git a/docs/packages/text.md b/docs/packages/text.md index ff2f3ba1..687c3eca 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:132c8cd7ca9681ea8ea2dad94056b26d1a512a6bd2dc22e1666168ea66d85659' +source_digest: 'sha256:6ebded1e5e096e63022857f5f89225b703825ac7554918f14481780c8e30b8c8' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -488,10 +488,12 @@ Sessions still publish an empty Rust plan because retained text is not yet shape shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. The semantic request now has compiler-derived record layouts without a handwritten TypeScript mirror: 24-byte UTF-16 -text replacements, 80-byte stable style mutations, 52-byte constraints, 8-byte flow vertices, 56-byte regions, 48-byte +text replacements, 88-byte stable style mutations, 52-byte constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Region/exclusion rectangles use inline bounds, while bounded polygons reference -vertices inside the same request. Styles include current shaping fields plus word spacing, material/color, and -decoration inputs. The generated ABI and compiled-Wasm test pin every size, tag, and the inline-object +vertices inside the same request. Styles separate stable identity from authored cascade order and include current +shaping fields plus word spacing, target raster density, material/color, and decoration inputs. A field mask records +which values were authored so absent values inherit rather than being confused with zero-valued declarations. The +generated ABI and compiled-Wasm test pin every size, tag, and the inline-object `baselineAlignment` offset. The generated engine vocabulary now also fixes axis, wrap, inline/block alignment, overflow, writing, orientation, exclusion-side, and inline-object baseline tags rather than accepting renderer-local enum bytes. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index f6d48f51..a1db0bc9 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -236,7 +236,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | | D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | | D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | -| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry shaping, spacing, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty sections and carries no shaping/layout timing claim. | Accepted | +| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 88-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry stable identity separately from authored cascade order, stated-field presence, shaping, spacing, raster density, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty style sections and carries no shaping/layout timing claim. | Accepted | | D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | | D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; each production array prewarms once to 32,768 clusters/glyphs as it lands, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation and owns policy-independent workspace reservation; D-178 lands the plan-glyph arena, and the following checkpoint reserves and reuses HarfRust's actual internal buffer plus UTF-16 context scratch. Initialization now settles 57 Wasm pages and is identity-stable when repeated. Policy registration cold-reserves its exact field lanes. Bidi, cluster, line, and geometry workspaces remain open; legacy batch-result vectors are outside the final frame claim. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | | D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | @@ -244,6 +244,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | | D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | | D-179 | Editorial flow geometry is a complete borrowed section of one `text_update`, never a host measurement callback. A nonempty geometry transaction contains at least one constraint and region and may contain bounded rectangle/polygon exclusions and text-anchored inline objects. Compiler-published enums define axis, wrap, inline/block alignment, overflow, writing mode, orientation, exclusion side, and baseline tags. Rust validates finite ordered bounds, polygon vertices, limits, unique identities, region/exclusion ranges, text offsets after pending mutations, reserved fields, and all table/payload overlap before state mutation. Sessions transactionally retain a semantic geometry fingerprint while layout will consume the borrowed records directly; pointer-only vertex offsets are excluded so equivalent repacking is deterministic. Compiled Wasm accepts a complete rectangle/exclusion/object request in one update and rejects a forged region reference without advancing the A/B publication. Optimized Wasm changes from 847,814 / 315,809 / 249,629 to 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. Styles remain rejected until their retained mutation model lands. | Accepted | +| D-180 | The retained style ABI uses an explicit authored `cascadeOrder` independent from stable `styleId`; ID allocation therefore cannot change equal-range precedence. Its `fieldMask` is stated-property presence, not a dirty hint, so absent values inherit and explicit zero-valued declarations remain representable. The 88-byte record also carries `rasterPixelRatio` for Rust-owned bitmap strike selection, although target density remains a root target property rather than a per-span typography feature. Compiler-published style, field, decoration-style, and decoration-flag vocabularies prevent host-local tag drift. This decision fixes the wire contract only: nonempty style sections remain rejected until validation and transactional retained storage land. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index ef962114..c47f2f26 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -293,7 +293,7 @@ The versioned request contains offsets to packed sections for: Stable fonts, policies, and capabilities are referenced by IDs. Repeating a large descriptor every frame would merely move host work into serialization. -The V0 compiler-mapped section records are 24-byte ordered UTF-16 replacements, 80-byte stable style upserts/removals, +The V0 compiler-mapped section records are 24-byte ordered UTF-16 replacements, 88-byte stable style upserts/removals, 52-byte flow constraints, 8-byte inline/block vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. UTF-16 payload preserves the public cluster coordinate without a host UTF-8 conversion. Styles carry current shaping fields plus word spacing, baseline shift, material, color, and decoration inputs; checked language and feature diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 9a3eaeda..59487d64 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -5,13 +5,20 @@ use serde_json::json; use crate::engine::frame::{ ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START, AXIS_AT_MOST, AXIS_EXACT, AXIS_UNCONSTRAINED, BASELINE_ALPHABETIC, BASELINE_MIDDLE, BASELINE_TEXT_BOTTOM, - BASELINE_TEXT_TOP, BLOCK_ALIGN_CENTER, BLOCK_ALIGN_END, BLOCK_ALIGN_START, - DEFAULT_SESSION_TEXT_CAPACITY, EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, - EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, - ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, - RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_MUTATION_REMOVE, - STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, - WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, + BASELINE_TEXT_TOP, BLOCK_ALIGN_CENTER, BLOCK_ALIGN_END, BLOCK_ALIGN_START, DECORATION_DASHED, + DECORATION_DOTTED, DECORATION_DOUBLE, DECORATION_FLAGS_MASK, DECORATION_LINE_THROUGH, + DECORATION_NONE, DECORATION_OVERLINE, DECORATION_SKIP_INK, DECORATION_SOLID, + DECORATION_UNDERLINE, DECORATION_WAVY, DEFAULT_SESSION_TEXT_CAPACITY, EXCLUSION_WRAP_BOTH, + EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, + ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, + OVERFLOW_VISIBLE, RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, + STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, + STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, + STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, + STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, + STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, + WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; use crate::engine::policy::{ ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, @@ -250,6 +257,7 @@ struct EngineStyleMutationRecord { decoration_style: u8, flags: u8, style_id: u32, + cascade_order: u32, field_mask: u32, text_start: u32, text_end: u32, @@ -264,6 +272,7 @@ struct EngineStyleMutationRecord { letter_spacing: f32, word_spacing: f32, baseline_shift: f32, + raster_pixel_ratio: f32, foreground_rgba: u32, decoration_rgba: u32, decoration_flags: u32, @@ -1182,6 +1191,11 @@ field_offset!( EngineStyleMutationRecord, style_id ); +field_offset!( + ENGINE_STYLE_MUTATION_CASCADE_ORDER, + EngineStyleMutationRecord, + cascade_order +); field_offset!( ENGINE_STYLE_MUTATION_FIELD_MASK, EngineStyleMutationRecord, @@ -1252,6 +1266,11 @@ field_offset!( EngineStyleMutationRecord, baseline_shift ); +field_offset!( + ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + EngineStyleMutationRecord, + raster_pixel_ratio +); field_offset!( ENGINE_STYLE_MUTATION_FOREGROUND_RGBA, EngineStyleMutationRecord, @@ -2095,6 +2114,7 @@ pub fn json() -> String { "decorationStyle": ENGINE_STYLE_MUTATION_DECORATION_STYLE, "flags": ENGINE_STYLE_MUTATION_FLAGS, "styleId": ENGINE_STYLE_MUTATION_STYLE_ID, + "cascadeOrder": ENGINE_STYLE_MUTATION_CASCADE_ORDER, "fieldMask": ENGINE_STYLE_MUTATION_FIELD_MASK, "textStart": ENGINE_STYLE_MUTATION_TEXT_START, "textEnd": ENGINE_STYLE_MUTATION_TEXT_END, @@ -2109,6 +2129,7 @@ pub fn json() -> String { "letterSpacing": ENGINE_STYLE_MUTATION_LETTER_SPACING, "wordSpacing": ENGINE_STYLE_MUTATION_WORD_SPACING, "baselineShift": ENGINE_STYLE_MUTATION_BASELINE_SHIFT, + "rasterPixelRatio": ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, "foregroundRgba": ENGINE_STYLE_MUTATION_FOREGROUND_RGBA, "decorationRgba": ENGINE_STYLE_MUTATION_DECORATION_RGBA, "decorationFlags": ENGINE_STYLE_MUTATION_DECORATION_FLAGS, @@ -2510,6 +2531,40 @@ pub fn json() -> String { "upsert": STYLE_MUTATION_UPSERT, "remove": STYLE_MUTATION_REMOVE }, + "styleFlags": { + "root": STYLE_FLAG_ROOT + }, + "styleFields": { + "fontStack": STYLE_FIELD_FONT_STACK, + "material": STYLE_FIELD_MATERIAL, + "language": STYLE_FIELD_LANGUAGE, + "features": STYLE_FIELD_FEATURES, + "fontSize": STYLE_FIELD_FONT_SIZE, + "lineHeight": STYLE_FIELD_LINE_HEIGHT, + "letterSpacing": STYLE_FIELD_LETTER_SPACING, + "wordSpacing": STYLE_FIELD_WORD_SPACING, + "baselineShift": STYLE_FIELD_BASELINE_SHIFT, + "rasterPixelRatio": STYLE_FIELD_RASTER_PIXEL_RATIO, + "direction": STYLE_FIELD_DIRECTION, + "foreground": STYLE_FIELD_FOREGROUND, + "decoration": STYLE_FIELD_DECORATION, + "all": STYLE_FIELD_MASK + }, + "decorationStyles": { + "none": DECORATION_NONE, + "solid": DECORATION_SOLID, + "double": DECORATION_DOUBLE, + "dotted": DECORATION_DOTTED, + "dashed": DECORATION_DASHED, + "wavy": DECORATION_WAVY + }, + "decorationFlags": { + "underline": DECORATION_UNDERLINE, + "overline": DECORATION_OVERLINE, + "lineThrough": DECORATION_LINE_THROUGH, + "skipInk": DECORATION_SKIP_INK, + "all": DECORATION_FLAGS_MASK + }, "flowShapeKinds": { "rectangle": SHAPE_RECTANGLE, "polygon": SHAPE_POLYGON diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index c1508262..c63dbb79 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -4,6 +4,32 @@ pub(crate) const TEXT_MUTATION_REPLACE_UTF16: u8 = 1; pub(crate) const TEXT_ENCODING_UTF16_LE: u8 = 1; pub(crate) const STYLE_MUTATION_UPSERT: u8 = 1; pub(crate) const STYLE_MUTATION_REMOVE: u8 = 2; +pub(crate) const STYLE_FLAG_ROOT: u8 = 1; +pub(crate) const STYLE_FIELD_FONT_STACK: u32 = 1 << 0; +pub(crate) const STYLE_FIELD_MATERIAL: u32 = 1 << 1; +pub(crate) const STYLE_FIELD_LANGUAGE: u32 = 1 << 2; +pub(crate) const STYLE_FIELD_FEATURES: u32 = 1 << 3; +pub(crate) const STYLE_FIELD_FONT_SIZE: u32 = 1 << 4; +pub(crate) const STYLE_FIELD_LINE_HEIGHT: u32 = 1 << 5; +pub(crate) const STYLE_FIELD_LETTER_SPACING: u32 = 1 << 6; +pub(crate) const STYLE_FIELD_WORD_SPACING: u32 = 1 << 7; +pub(crate) const STYLE_FIELD_BASELINE_SHIFT: u32 = 1 << 8; +pub(crate) const STYLE_FIELD_RASTER_PIXEL_RATIO: u32 = 1 << 9; +pub(crate) const STYLE_FIELD_DIRECTION: u32 = 1 << 10; +pub(crate) const STYLE_FIELD_FOREGROUND: u32 = 1 << 11; +pub(crate) const STYLE_FIELD_DECORATION: u32 = 1 << 12; +pub(crate) const STYLE_FIELD_MASK: u32 = (1 << 13) - 1; +pub(crate) const DECORATION_NONE: u8 = 0; +pub(crate) const DECORATION_SOLID: u8 = 1; +pub(crate) const DECORATION_DOUBLE: u8 = 2; +pub(crate) const DECORATION_DOTTED: u8 = 3; +pub(crate) const DECORATION_DASHED: u8 = 4; +pub(crate) const DECORATION_WAVY: u8 = 5; +pub(crate) const DECORATION_UNDERLINE: u32 = 1 << 0; +pub(crate) const DECORATION_OVERLINE: u32 = 1 << 1; +pub(crate) const DECORATION_LINE_THROUGH: u32 = 1 << 2; +pub(crate) const DECORATION_SKIP_INK: u32 = 1 << 3; +pub(crate) const DECORATION_FLAGS_MASK: u32 = (1 << 4) - 1; pub(crate) const SHAPE_RECTANGLE: u8 = 1; pub(crate) const SHAPE_POLYGON: u8 = 2; pub(crate) const WRITING_HORIZONTAL_TB: u8 = 1; diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index ed96cfe4..3d341c53 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -728,35 +728,44 @@ fn decode_scalar(units: &[u16], index: usize) -> (char, usize) { } fn parse_language(bytes: &[u8]) -> Option { + if valid_language_bytes(bytes) { + Language::new(bytes) + } else { + None + } +} + +pub(crate) fn valid_language_bytes(bytes: &[u8]) -> bool { if bytes.len() > u16::MAX as usize { - return None; + return false; } let mut subtags = bytes.split(|byte| *byte == b'-'); - let primary = subtags.next()?; + let Some(primary) = subtags.next() else { + return false; + }; let primary_is_private_or_grandfathered = matches!(primary, [b'x' | b'X'] | [b'i' | b'I']); if !(2..=8).contains(&primary.len()) && !primary_is_private_or_grandfathered { - return None; + return false; } if !primary.iter().all(u8::is_ascii_alphabetic) { - return None; + return false; } let mut subtag_count = 0; for subtag in subtags { if subtag.is_empty() || subtag.len() > 8 || !subtag.iter().all(u8::is_ascii_alphanumeric) { - return None; + return false; } subtag_count += 1; } if primary_is_private_or_grandfathered && subtag_count == 0 { - return None; + return false; } - - Language::new(bytes) + true } -fn valid_tag(tag: u32) -> bool { +pub(crate) fn valid_tag(tag: u32) -> bool { tag.to_be_bytes() .iter() .all(|byte| (0x20..=0x7e).contains(byte)) diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 21015aa8..769c6f59 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -48,6 +48,21 @@ export const textShaperAbi = { "orderedDirect": 1, "stableIndirect": 2 }, + "decorationFlags": { + "all": 15, + "lineThrough": 4, + "overline": 2, + "skipInk": 8, + "underline": 1 + }, + "decorationStyles": { + "dashed": 4, + "dotted": 3, + "double": 2, + "none": 0, + "solid": 1, + "wavy": 5 + }, "defaultSessionTextCapacity": 1024, "exclusionWrapSides": { "both": 1, @@ -116,6 +131,25 @@ export const textShaperAbi = { "run": 3, "selection": 6 }, + "styleFields": { + "all": 8191, + "baselineShift": 256, + "decoration": 4096, + "direction": 1024, + "features": 8, + "fontSize": 16, + "fontStack": 1, + "foreground": 2048, + "language": 4, + "letterSpacing": 64, + "lineHeight": 32, + "material": 2, + "rasterPixelRatio": 512, + "wordSpacing": 128 + }, + "styleFlags": { + "root": 1 + }, "styleMutationOpcodes": { "remove": 2, "upsert": 1 @@ -449,31 +483,33 @@ export const textShaperAbi = { }, "engineStyleMutation": { "alignment": 4, - "baselineShift": 56, - "decorationFlags": 68, - "decorationOffset": 76, - "decorationRgba": 64, + "baselineShift": 60, + "cascadeOrder": 8, + "decorationFlags": 76, + "decorationOffset": 84, + "decorationRgba": 72, "decorationStyle": 2, - "decorationThickness": 72, + "decorationThickness": 80, "direction": 1, - "featureCount": 34, - "featuresOffset": 36, - "fieldMask": 8, + "featureCount": 38, + "featuresOffset": 40, + "fieldMask": 12, "flags": 3, - "fontSize": 40, - "fontStackHandle": 20, - "foregroundRgba": 60, - "languageLength": 32, - "languageOffset": 28, - "letterSpacing": 48, - "lineHeight": 44, - "materialId": 24, + "fontSize": 44, + "fontStackHandle": 24, + "foregroundRgba": 68, + "languageLength": 36, + "languageOffset": 32, + "letterSpacing": 52, + "lineHeight": 48, + "materialId": 28, "opcode": 0, - "size": 80, + "rasterPixelRatio": 64, + "size": 88, "styleId": 4, - "textEnd": 16, - "textStart": 12, - "wordSpacing": 52 + "textEnd": 20, + "textStart": 16, + "wordSpacing": 56 }, "engineTextMutation": { "alignment": 4, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 39e2b0f1..45e23190 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -42,7 +42,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as abi.layouts.engineExclusion.size, abi.layouts.engineInlineObject.size, ], - [24, 80, 52, 8, 56, 48, 56], + [24, 88, 52, 8, 56, 48, 56], ); assert.equal(abi.layouts.engineInlineObject.alignment, 4); assert.equal(abi.layouts.engineInlineObject.baselineAlignment, 52); @@ -50,6 +50,25 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.equal(abi.engine.textEncodings.utf16Le, 1); assert.equal(abi.engine.styleMutationOpcodes.upsert, 1); assert.equal(abi.engine.styleMutationOpcodes.remove, 2); + assert.deepEqual(abi.engine.styleFlags, { root: 1 }); + assert.deepEqual(abi.engine.decorationStyles, { + dashed: 4, + dotted: 3, + double: 2, + none: 0, + solid: 1, + wavy: 5, + }); + assert.deepEqual(abi.engine.decorationFlags, { + all: 15, + lineThrough: 4, + overline: 2, + skipInk: 8, + underline: 1, + }); + assert.equal(abi.engine.styleFields.all, 8191); + assert.equal(abi.layouts.engineStyleMutation.cascadeOrder, 8); + assert.equal(abi.layouts.engineStyleMutation.rasterPixelRatio, 64); assert.deepEqual(abi.engine.flowShapeKinds, { polygon: 2, rectangle: 1 }); assert.deepEqual(abi.engine.writingModes, { horizontalTb: 1, verticalLr: 3, verticalRl: 2 }); assert.deepEqual(abi.engine.textOrientations, { mixed: 1, sideways: 3, upright: 2 }); From 32d72deff7373e6f9d1be339ae5ba1bc000861a4 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 13:44:52 -0400 Subject: [PATCH 027/128] feat(text): retain rust style mutations --- docs/log.md | 9 + docs/packages/text.md | 17 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 7 + packages/text/rust/shaper/src/engine/frame.rs | 1 + .../text/rust/shaper/src/engine/frame_wire.rs | 26 +- packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/semantic_wire.rs | 635 +++++++++++++++++- packages/text/rust/shaper/src/engine/state.rs | 261 ++++++- .../rust/shaper/src/engine/style_state.rs | 390 +++++++++++ packages/text/rust/shaper/src/lib.rs | 4 +- .../integration/shaper-registration.test.mjs | 123 +++- 12 files changed, 1432 insertions(+), 43 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/style_state.rs diff --git a/docs/log.md b/docs/log.md index 086bbe84..4a77953f 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Admitted transactional retained styles in Rust** — `text_update` now decodes canonical style snapshots and + removals without allocation, merge-compacts them by stable ID into pre-reserved flat A/B session arenas, and validates + authored cascade order, nesting, UTF-16 ranges, language/features, registered stacks, root completeness, numeric + domains, and request aliasing before commit. A real-font compiled-Wasm transaction commits text plus its root style, + rejects root removal without revision advance, and preserves `memory.buffer` after session creation. The module is + 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes (+31,592 / +13,737 / +10,512). Payload admission is linear and + retained validation uses one reusable-scratch O(n log n) sort. Layout does not consume styles yet, + so no frame-latency claim is attached. + - **Fixed the retained style wire semantics before admission** — The compiler-mapped style record is now 88 bytes and separates stable `styleId` from authored `cascadeOrder`. A stated-property field mask preserves inheritance and explicit zero values, target raster density is available for Rust-owned bitmap strike selection, and generated diff --git a/docs/packages/text.md b/docs/packages/text.md index 687c3eca..8c76fdd1 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6ebded1e5e096e63022857f5f89225b703825ac7554918f14481780c8e30b8c8' +source_digest: 'sha256:3e73bcdbe8c884a3fae45b6883ee7d4df14c03f24e7dbccf8157d41ca873d030' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -483,7 +483,7 @@ semantic input exists, so that exact ordering remains an explicit test gap. The optimized artifact from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. This is a measured shared-runtime cost and a pending optimization target. Ordered UTF-16 replacements are now retained transactionally. Editorial constraints, regions, exclusions, and -inline objects now decode as borrowed one-call geometry; style mutations remain rejected. +inline objects now decode as borrowed one-call geometry; style upserts/removals are decoded and retained transactionally. Sessions still publish an empty Rust plan because retained text is not yet shaped or laid out; there is no Rust shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. @@ -497,6 +497,19 @@ generated ABI and compiled-Wasm test pin every size, tag, and the inline-object `baselineAlignment` offset. The generated engine vocabulary now also fixes axis, wrap, inline/block alignment, overflow, writing, orientation, exclusion-side, and inline-object baseline tags rather than accepting renderer-local enum bytes. +Style decoding is borrowed and allocation-free. Session creation pre-reserves two flat 64-style arenas, 512 language +bytes and 128 OpenType feature records per arena, plus reusable mutation, cascade-order, and nesting scratch. A style +update sorts mutations by stable ID, retains only the final operation for each ID, and merge-walks them with committed +styles into the inactive arena; language and feature payloads are compacted during the merge rather than retained as a +`Vec` per span or allowed to accumulate stale bytes. Validation covers canonical absent fields, finite/positive values, +language/tags, feature and UTF-16 boundaries, registered font stacks, one complete root, unambiguous equal-range cascade order, +nested rather than partially overlapping ranges, and cross-section payload aliasing. Commit swaps arenas; abort clears +only pending lengths. Once styles exist, a text edit must leave all retained ranges valid and cannot remove the sole +root. A real-font compiled-Wasm transaction proves the first combined text/root update and an invalid root removal do +not grow memory after session creation. Reachability changes optimized Wasm from 856,831 / 319,003 / 252,236 to +888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes (+31,592 / +13,737 / +10,512). Plans remain empty, so this is retained +state evidence, not a shaping/layout latency result. + The frame decoder now borrows ordered UTF-16 replacement records and their offset-addressed payloads directly from the pinned request. It validates canonical empty offsets, opcode/encoding, reserved fields, bounds, alignment, arithmetic, and record/payload non-overlap before the session transaction. Rust applies sequential replacements into retained diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index a1db0bc9..f4eb5b03 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -245,6 +245,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | | D-179 | Editorial flow geometry is a complete borrowed section of one `text_update`, never a host measurement callback. A nonempty geometry transaction contains at least one constraint and region and may contain bounded rectangle/polygon exclusions and text-anchored inline objects. Compiler-published enums define axis, wrap, inline/block alignment, overflow, writing mode, orientation, exclusion side, and baseline tags. Rust validates finite ordered bounds, polygon vertices, limits, unique identities, region/exclusion ranges, text offsets after pending mutations, reserved fields, and all table/payload overlap before state mutation. Sessions transactionally retain a semantic geometry fingerprint while layout will consume the borrowed records directly; pointer-only vertex offsets are excluded so equivalent repacking is deterministic. Compiled Wasm accepts a complete rectangle/exclusion/object request in one update and rejects a forged region reference without advancing the A/B publication. Optimized Wasm changes from 847,814 / 315,809 / 249,629 to 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. Styles remain rejected until their retained mutation model lands. | Accepted | | D-180 | The retained style ABI uses an explicit authored `cascadeOrder` independent from stable `styleId`; ID allocation therefore cannot change equal-range precedence. Its `fieldMask` is stated-property presence, not a dirty hint, so absent values inherit and explicit zero-valued declarations remain representable. The 88-byte record also carries `rasterPixelRatio` for Rust-owned bitmap strike selection, although target density remains a root target property rather than a per-span typography feature. Compiler-published style, field, decoration-style, and decoration-flag vocabularies prevent host-local tag drift. This decision fixes the wire contract only: nonempty style sections remain rejected until validation and transactional retained storage land. | Accepted | +| D-181 | Nonempty style mutations are admitted only with their Rust consumer. Decoding borrows canonical fixed records and monotonically packed offset payloads without allocation. Each session pre-reserves two flat 64-style/512-language-byte/128-feature arenas and reusable mutation/order/nesting scratch. Mutations collapse by stable ID and merge with committed ID-sorted state into a compact inactive arena; no per-style vector or stale replaced payload survives. Rust validates stated versus absent bytes, numeric domains, language/tags, UTF-16 feature/range boundaries, binary-searched font-stack reachability, one complete root, unambiguous equal-range cascade order, proper nesting, and all request-section aliasing before plan preparation. Payload admission is linear and retained validation is O(n log n), with one reusable-scratch sort. Commit swaps arenas and abort preserves committed state. A compiled-Wasm real-font transaction commits text plus root style and rejects root removal without revision advance or post-creation memory growth. Optimized Wasm changes from 856,831 / 319,003 / 252,236 to 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes. Layout consumption and latency remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index c47f2f26..c8fa7058 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -301,6 +301,13 @@ payloads are offset-addressed. Constraint records carry the complete region rang call. Rectangle bounds are inline; bounded polygons reference vertex records in the same request. Defining this wire grammar does not make a section valid until its Rust decoder and retained transaction land. +The retained style transaction uses two flat pre-reserved arenas per session rather than allocating a language or +feature vector per span. Stable IDs drive an allocation-reusing mutation merge; authored cascade order remains a +separate value used to validate nesting and later resolve inheritance. Payload compaction happens while building the +inactive arena, so replacing a style cannot accumulate dead language or feature bytes. Root target density is retained +for bitmap strike selection but rejected on non-root spans. Commit is an arena swap and abort does not touch committed +styles. + All offsets and lengths are range-checked before use. Enum tags, alignment, multiplication, and revision relationships are validated at the Wasm boundary. Failure returns a typed result without exposing partially mutated state. diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index c63dbb79..3f3872e5 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -74,6 +74,7 @@ pub(crate) struct UpdateRequest<'a> { pub capability_set: u32, pub limits: UpdateLimits, pub text_mutations: super::semantic_wire::TextMutationBatch<'a>, + pub style_mutations: super::semantic_wire::StyleMutationBatch<'a>, pub geometry: super::semantic_wire::GeometryBatch<'a>, } diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index a3d8d6ab..8c2c58e0 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -48,16 +48,10 @@ pub(crate) fn parse_update_request( return Err(STATUS_INVALID_REQUEST); } - for (offset, count) in [ - ( - ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, - ENGINE_UPDATE_STYLE_MUTATION_COUNT, - ), - ( - ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, - ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, - ), - ] { + for (offset, count) in [( + ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, + ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + )] { if read_u32(bytes, offset)? != 0 || read_u32(bytes, count)? != 0 { return Err(STATUS_INVALID_REQUEST); } @@ -86,6 +80,15 @@ pub(crate) fn parse_update_request( read_u32(bytes, ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET)?, text_mutation_count, )?; + let style_mutation_count = read_u32(bytes, ENGINE_UPDATE_STYLE_MUTATION_COUNT)?; + if style_mutation_count > limits.max_clusters { + return Err(STATUS_INVALID_REQUEST); + } + let style_mutations = super::semantic_wire::parse_style_mutations( + bytes, + read_u32(bytes, ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET)?, + style_mutation_count, + )?; let constraint_count = read_u32(bytes, ENGINE_UPDATE_CONSTRAINT_COUNT)?; let region_count = read_u32(bytes, ENGINE_UPDATE_REGION_COUNT)?; let exclusion_count = read_u32(bytes, ENGINE_UPDATE_EXCLUSION_COUNT)?; @@ -103,7 +106,9 @@ pub(crate) fn parse_update_request( limits, )?; text_mutations.validate_disjoint_geometry(geometry)?; + style_mutations.validate_disjoint_semantics(text_mutations, geometry)?; if text_mutation_count == 0 + && style_mutation_count == 0 && constraint_count == 0 && region_count == 0 && exclusion_count == 0 @@ -124,6 +129,7 @@ pub(crate) fn parse_update_request( capability_set: positive(bytes, ENGINE_UPDATE_CAPABILITY_SET)?, limits, text_mutations, + style_mutations, geometry, }) } diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 72fe8fbd..a0c92fa9 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -32,6 +32,7 @@ mod stable_order; pub mod stable_plan; #[cfg_attr(not(test), allow(dead_code))] mod stable_pool; +mod style_state; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod wire; diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 1a2082e0..ec5da6bc 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -1,7 +1,7 @@ //! Borrowed semantic update records decoded from the compiler-mapped frame ABI. use crate::{ - STATUS_INVALID_REQUEST, + FeatureRecord, STATUS_INVALID_REQUEST, abi_contract::{ self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, @@ -10,16 +10,26 @@ use crate::{ ENGINE_TEXT_MUTATION_RESERVED1, ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, + bidi::{DIRECTION_AUTO, DIRECTION_LTR, DIRECTION_RTL}, engine::frame::{ ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START, AXIS_AT_MOST, AXIS_EXACT, AXIS_UNCONSTRAINED, BASELINE_ALPHABETIC, BASELINE_MIDDLE, BASELINE_TEXT_BOTTOM, BASELINE_TEXT_TOP, BLOCK_ALIGN_CENTER, BLOCK_ALIGN_END, BLOCK_ALIGN_START, - EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, - EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, - OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, SHAPE_POLYGON, SHAPE_RECTANGLE, - TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, UpdateLimits, WRAP_CHARACTER, - WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, + DECORATION_DASHED, DECORATION_DOTTED, DECORATION_DOUBLE, DECORATION_FLAGS_MASK, + DECORATION_NONE, DECORATION_SOLID, DECORATION_WAVY, EXCLUSION_WRAP_BOTH, + EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, + ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, + OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, SHAPE_POLYGON, SHAPE_RECTANGLE, + STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, + STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, + STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, + STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + TEXT_MUTATION_REPLACE_UTF16, UpdateLimits, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, + WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }, + valid_language_bytes, valid_tag, wire::{array, read_f32, read_u16, read_u32}, }; @@ -36,6 +46,45 @@ pub(crate) struct TextMutation<'a> { pub insert_utf16_le: &'a [u8], } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct StyleMutationBatch<'a> { + request: &'a [u8], + records: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum StyleMutation<'a> { + Remove { style_id: u32 }, + Upsert(StyleValue<'a>), +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct StyleValue<'a> { + pub style_id: u32, + pub cascade_order: u32, + pub field_mask: u32, + pub text_start: u32, + pub text_end: u32, + pub font_stack_handle: u32, + pub material_id: u32, + pub language: &'a [u8], + pub features: &'a [u8], + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub word_spacing: f32, + pub baseline_shift: f32, + pub raster_pixel_ratio: f32, + pub direction: u8, + pub foreground_rgba: u32, + pub decoration_rgba: u32, + pub decoration_flags: u32, + pub decoration_style: u8, + pub decoration_thickness: f32, + pub decoration_offset: f32, + pub root: bool, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct GeometryBatch<'a> { request: &'a [u8], @@ -194,6 +243,454 @@ impl<'a> TextMutationBatch<'a> { } Ok(()) } + + fn overlaps_range(self, range: (usize, usize)) -> Result { + if !self.records.is_empty() && overlaps(range, byte_range(self.request, self.records)?) { + return Ok(true); + } + for record in self + .records + .chunks_exact(ENGINE_TEXT_MUTATION_RECORD_SIZE as usize) + { + if text_payload_range(self.request, record)? + .is_some_and(|payload| overlaps(range, payload)) + { + return Ok(true); + } + } + Ok(false) + } +} + +impl<'a> StyleMutationBatch<'a> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + records: &[], + } + } + + pub(crate) fn len(self) -> usize { + self.records.len() / abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize + } + + pub(crate) fn get(self, index: usize) -> Option> { + let stride = abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize; + let start = index.checked_mul(stride)?; + let record = self.records.get(start..start.checked_add(stride)?)?; + let style_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_STYLE_ID).ok()?; + if record[abi::ENGINE_STYLE_MUTATION_OPCODE] == STYLE_MUTATION_REMOVE { + return Some(StyleMutation::Remove { style_id }); + } + let language_length = + u32::from(read_u16(record, abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH).ok()?); + let language = if language_length == 0 { + &[] + } else { + array( + self.request, + read_u32(record, abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET).ok()?, + language_length, + 1, + 1, + ) + .ok()? + }; + let feature_count = + u32::from(read_u16(record, abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT).ok()?); + let features = if feature_count == 0 { + &[] + } else { + array( + self.request, + read_u32(record, abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET).ok()?, + feature_count, + abi::FEATURE_RECORD_SIZE, + abi::FEATURE_RECORD_ALIGNMENT, + ) + .ok()? + }; + Some(StyleMutation::Upsert(StyleValue { + style_id, + cascade_order: read_u32(record, abi::ENGINE_STYLE_MUTATION_CASCADE_ORDER).ok()?, + field_mask: read_u32(record, abi::ENGINE_STYLE_MUTATION_FIELD_MASK).ok()?, + text_start: read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_START).ok()?, + text_end: read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_END).ok()?, + font_stack_handle: read_u32(record, abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE) + .ok()?, + material_id: read_u32(record, abi::ENGINE_STYLE_MUTATION_MATERIAL_ID).ok()?, + language, + features, + font_size: read_f32(record, abi::ENGINE_STYLE_MUTATION_FONT_SIZE).ok()?, + line_height: read_f32(record, abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT).ok()?, + letter_spacing: read_f32(record, abi::ENGINE_STYLE_MUTATION_LETTER_SPACING).ok()?, + word_spacing: read_f32(record, abi::ENGINE_STYLE_MUTATION_WORD_SPACING).ok()?, + baseline_shift: read_f32(record, abi::ENGINE_STYLE_MUTATION_BASELINE_SHIFT).ok()?, + raster_pixel_ratio: read_f32(record, abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO) + .ok()?, + direction: record[abi::ENGINE_STYLE_MUTATION_DIRECTION], + foreground_rgba: read_u32(record, abi::ENGINE_STYLE_MUTATION_FOREGROUND_RGBA).ok()?, + decoration_rgba: read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_RGBA).ok()?, + decoration_flags: read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_FLAGS).ok()?, + decoration_style: record[abi::ENGINE_STYLE_MUTATION_DECORATION_STYLE], + decoration_thickness: read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_THICKNESS) + .ok()?, + decoration_offset: read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_OFFSET) + .ok()?, + root: record[abi::ENGINE_STYLE_MUTATION_FLAGS] == STYLE_FLAG_ROOT, + })) + } + + pub(crate) fn feature(value: StyleValue<'_>, index: usize) -> Option { + let stride = abi::FEATURE_RECORD_SIZE as usize; + let start = index.checked_mul(stride)?; + let record = value.features.get(start..start.checked_add(stride)?)?; + Some(FeatureRecord { + tag: read_u32(record, abi::FEATURE_TAG).ok()?, + value: read_u32(record, abi::FEATURE_VALUE).ok()?, + start: read_u32(record, abi::FEATURE_START).ok()?, + end: read_u32(record, abi::FEATURE_END).ok()?, + }) + } + + pub(crate) fn validate_disjoint_semantics( + self, + text: TextMutationBatch<'_>, + geometry: GeometryBatch<'_>, + ) -> Result<(), u32> { + if !self.records.is_empty() { + let records = byte_range(self.request, self.records)?; + if text.overlaps_range(records)? || geometry.overlaps_range(records)? { + return Err(STATUS_INVALID_REQUEST); + } + } + for record in self + .records + .chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) + { + for range in style_payload_ranges(self.request, record)? + .into_iter() + .flatten() + { + if text.overlaps_range(range)? || geometry.overlaps_range(range)? { + return Err(STATUS_INVALID_REQUEST); + } + } + } + Ok(()) + } +} + +pub(crate) fn parse_style_mutations( + request: &[u8], + offset: u32, + count: u32, +) -> Result, u32> { + if count == 0 { + return if offset == 0 { + Ok(StyleMutationBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + let records = record_table( + request, + offset, + count, + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE, + abi::ENGINE_STYLE_MUTATION_RECORD_ALIGNMENT, + )?; + let records_range = byte_range(request, records)?; + let mut previous_payload_end = records_range.1; + for record in records.chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) { + let current = style_payload_ranges(request, record)?; + for range in current.into_iter().flatten() { + if range.0 < previous_payload_end { + return Err(STATUS_INVALID_REQUEST); + } + previous_payload_end = range.1; + } + } + for record in records.chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) { + validate_style_record(request, record)?; + } + Ok(StyleMutationBatch { request, records }) +} + +fn style_payload_ranges(request: &[u8], record: &[u8]) -> Result<[Option<(usize, usize)>; 2], u32> { + let mut ranges = [None, None]; + for (index, (offset_field, count_field, stride, alignment)) in [ + ( + abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + 1, + 1, + ), + ( + abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT, + abi::FEATURE_RECORD_SIZE, + abi::FEATURE_RECORD_ALIGNMENT, + ), + ] + .into_iter() + .enumerate() + { + let count = u32::from(read_u16(record, count_field)?); + if count != 0 { + let payload = array( + request, + read_u32(record, offset_field)?, + count, + stride, + alignment, + )?; + ranges[index] = Some(byte_range(request, payload)?); + } + } + Ok(ranges) +} + +fn validate_style_record(request: &[u8], record: &[u8]) -> Result<(), u32> { + let opcode = byte(record, abi::ENGINE_STYLE_MUTATION_OPCODE)?; + let style_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_STYLE_ID)?; + if style_id == 0 { + return Err(STATUS_INVALID_REQUEST); + } + if opcode == STYLE_MUTATION_REMOVE { + for (index, value) in record.iter().copied().enumerate() { + let identity = index == abi::ENGINE_STYLE_MUTATION_OPCODE + || (abi::ENGINE_STYLE_MUTATION_STYLE_ID..abi::ENGINE_STYLE_MUTATION_STYLE_ID + 4) + .contains(&index); + if !identity && value != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } + return Ok(()); + } + if opcode != STYLE_MUTATION_UPSERT { + return Err(STATUS_INVALID_REQUEST); + } + let flags = byte(record, abi::ENGINE_STYLE_MUTATION_FLAGS)?; + let root = flags == STYLE_FLAG_ROOT; + if flags & !STYLE_FLAG_ROOT != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let field_mask = read_u32(record, abi::ENGINE_STYLE_MUTATION_FIELD_MASK)?; + if field_mask & !STYLE_FIELD_MASK != 0 { + return Err(STATUS_INVALID_REQUEST); + } + let text_start = read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_START)?; + let text_end = read_u32(record, abi::ENGINE_STYLE_MUTATION_TEXT_END)?; + if text_start > text_end || (!root && text_start == text_end) { + return Err(STATUS_INVALID_REQUEST); + } + validate_stated_u32( + record, + field_mask, + STYLE_FIELD_FONT_STACK, + abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + true, + )?; + validate_stated_u32( + record, + field_mask, + STYLE_FIELD_MATERIAL, + abi::ENGINE_STYLE_MUTATION_MATERIAL_ID, + true, + )?; + validate_style_payload(request, record, field_mask, text_start, text_end)?; + validate_style_float( + record, + field_mask, + STYLE_FIELD_FONT_SIZE, + abi::ENGINE_STYLE_MUTATION_FONT_SIZE, + true, + )?; + validate_style_float( + record, + field_mask, + STYLE_FIELD_LINE_HEIGHT, + abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT, + true, + )?; + for (field, offset) in [ + ( + STYLE_FIELD_LETTER_SPACING, + abi::ENGINE_STYLE_MUTATION_LETTER_SPACING, + ), + ( + STYLE_FIELD_WORD_SPACING, + abi::ENGINE_STYLE_MUTATION_WORD_SPACING, + ), + ( + STYLE_FIELD_BASELINE_SHIFT, + abi::ENGINE_STYLE_MUTATION_BASELINE_SHIFT, + ), + ] { + validate_style_float(record, field_mask, field, offset, false)?; + } + validate_style_float( + record, + field_mask, + STYLE_FIELD_RASTER_PIXEL_RATIO, + abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + true, + )?; + if field_mask & STYLE_FIELD_RASTER_PIXEL_RATIO != 0 && !root { + return Err(STATUS_INVALID_REQUEST); + } + let direction = byte(record, abi::ENGINE_STYLE_MUTATION_DIRECTION)?; + if field_mask & STYLE_FIELD_DIRECTION == 0 { + if direction != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } else if !matches!(direction, DIRECTION_AUTO | DIRECTION_LTR | DIRECTION_RTL) { + return Err(STATUS_INVALID_REQUEST); + } + if field_mask & STYLE_FIELD_FOREGROUND == 0 + && read_u32(record, abi::ENGINE_STYLE_MUTATION_FOREGROUND_RGBA)? != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + validate_decoration(record, field_mask)?; + Ok(()) +} + +fn validate_style_payload( + request: &[u8], + record: &[u8], + field_mask: u32, + text_start: u32, + text_end: u32, +) -> Result<(), u32> { + let language_offset = read_u32(record, abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET)?; + let language_length = u32::from(read_u16( + record, + abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + )?); + if field_mask & STYLE_FIELD_LANGUAGE == 0 { + if language_offset != 0 || language_length != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } else { + let language = array(request, language_offset, language_length, 1, 1)?; + if language.is_empty() || !valid_language_bytes(language) { + return Err(STATUS_INVALID_REQUEST); + } + } + let features_offset = read_u32(record, abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET)?; + let feature_count = u32::from(read_u16(record, abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT)?); + if field_mask & STYLE_FIELD_FEATURES == 0 { + if features_offset != 0 || feature_count != 0 { + return Err(STATUS_INVALID_REQUEST); + } + return Ok(()); + } + if feature_count == 0 { + return if features_offset == 0 { + Ok(()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + let features = array( + request, + features_offset, + feature_count, + abi::FEATURE_RECORD_SIZE, + abi::FEATURE_RECORD_ALIGNMENT, + )?; + for feature in features.chunks_exact(abi::FEATURE_RECORD_SIZE as usize) { + let start = read_u32(feature, abi::FEATURE_START)?; + let end = read_u32(feature, abi::FEATURE_END)?; + if !valid_tag(read_u32(feature, abi::FEATURE_TAG)?) + || start >= end + || start < text_start + || end > text_end + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(()) +} + +fn validate_stated_u32( + record: &[u8], + field_mask: u32, + field: u32, + offset: usize, + positive: bool, +) -> Result<(), u32> { + let value = read_u32(record, offset)?; + if field_mask & field == 0 { + if value != 0 { + return Err(STATUS_INVALID_REQUEST); + } + } else if positive && value == 0 { + return Err(STATUS_INVALID_REQUEST); + } + Ok(()) +} + +fn validate_style_float( + record: &[u8], + field_mask: u32, + field: u32, + offset: usize, + positive: bool, +) -> Result<(), u32> { + if field_mask & field == 0 { + return if read_u32(record, offset)? == 0 { + Ok(()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + let value = read_f32(record, offset)?; + if !value.is_finite() || (positive && value <= 0.0) { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(()) + } +} + +fn validate_decoration(record: &[u8], field_mask: u32) -> Result<(), u32> { + let style = byte(record, abi::ENGINE_STYLE_MUTATION_DECORATION_STYLE)?; + let rgba = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_RGBA)?; + let flags = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_FLAGS)?; + let thickness_bits = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_THICKNESS)?; + let offset_bits = read_u32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_OFFSET)?; + if field_mask & STYLE_FIELD_DECORATION == 0 { + return if style == 0 && rgba == 0 && flags == 0 && thickness_bits == 0 && offset_bits == 0 { + Ok(()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if !matches!( + style, + DECORATION_NONE + | DECORATION_SOLID + | DECORATION_DOUBLE + | DECORATION_DOTTED + | DECORATION_DASHED + | DECORATION_WAVY + ) || flags & !DECORATION_FLAGS_MASK != 0 + { + return Err(STATUS_INVALID_REQUEST); + } + let thickness = read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_THICKNESS)?; + let offset = read_f32(record, abi::ENGINE_STYLE_MUTATION_DECORATION_OFFSET)?; + if !thickness.is_finite() || thickness < 0.0 || !offset.is_finite() { + return Err(STATUS_INVALID_REQUEST); + } + if style == DECORATION_NONE + && (flags != 0 || rgba != 0 || thickness_bits != 0 || offset_bits != 0) + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(()) } pub(crate) fn parse_text_mutations( @@ -883,6 +1380,132 @@ mod tests { }; use alloc::vec; + #[test] + fn validates_borrowed_style_snapshots_and_canonical_removals() { + let bytes = valid_style_bytes(); + let batch = parse_style_mutations(&bytes, STYLE_OFFSET as u32, 1).unwrap(); + let StyleMutation::Upsert(style) = batch.get(0).unwrap() else { + panic!("upsert"); + }; + assert_eq!(style.style_id, 7); + assert_eq!(style.language, b"en"); + assert_eq!( + StyleMutationBatch::feature(style, 0).unwrap().tag, + u32::from_be_bytes(*b"kern") + ); + + let mut removal = vec![0; STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize]; + removal[STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_REMOVE; + write_u32( + &mut removal, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_STYLE_ID, + 7, + ); + assert!(parse_style_mutations(&removal, STYLE_OFFSET as u32, 1).is_ok()); + removal[STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_DIRECTION] = DIRECTION_LTR; + assert!(parse_style_mutations(&removal, STYLE_OFFSET as u32, 1).is_err()); + } + + #[test] + fn rejects_unstated_noncanonical_and_aliased_style_data() { + let mut unstated = valid_style_bytes(); + write_f32( + &mut unstated, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_WORD_SPACING, + 1.0, + ); + assert!(parse_style_mutations(&unstated, STYLE_OFFSET as u32, 1).is_err()); + + let mut density_span = valid_style_bytes(); + density_span[STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_FLAGS] = 0; + assert!(parse_style_mutations(&density_span, STYLE_OFFSET as u32, 1).is_err()); + + let mut aliased = valid_style_bytes(); + write_u32( + &mut aliased, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + STYLE_OFFSET as u32, + ); + assert!(parse_style_mutations(&aliased, STYLE_OFFSET as u32, 1).is_err()); + } + + const STYLE_OFFSET: usize = abi::ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + + fn valid_style_bytes() -> Vec { + let language_offset = STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize; + let features_offset = (language_offset + 2 + 3) & !3; + let mut bytes = vec![0; features_offset + abi::FEATURE_RECORD_SIZE as usize]; + let record = STYLE_OFFSET; + bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_UPSERT; + bytes[record + abi::ENGINE_STYLE_MUTATION_FLAGS] = STYLE_FLAG_ROOT; + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, 7); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_CASCADE_ORDER, + 3, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FIELD_MASK, + STYLE_FIELD_FONT_STACK + | STYLE_FIELD_LANGUAGE + | STYLE_FIELD_FEATURES + | STYLE_FIELD_FONT_SIZE + | STYLE_FIELD_LINE_HEIGHT + | STYLE_FIELD_RASTER_PIXEL_RATIO, + ); + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_TEXT_END, 4); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + 9, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LANGUAGE_OFFSET, + language_offset as u32, + ); + write_u16( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH, + 2, + ); + write_u16( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FEATURE_COUNT, + 1, + ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FEATURES_OFFSET, + features_offset as u32, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_SIZE, + 16.0, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT, + 1.2, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + 2.0, + ); + bytes[language_offset..language_offset + 2].copy_from_slice(b"en"); + write_u32( + &mut bytes, + features_offset + abi::FEATURE_TAG, + u32::from_be_bytes(*b"kern"), + ); + write_u32(&mut bytes, features_offset + abi::FEATURE_VALUE, 1); + write_u32(&mut bytes, features_offset + abi::FEATURE_END, 4); + bytes + } + #[test] fn validates_and_borrows_utf16_replacements_without_decoding_objects() { let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE; diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 71bfd4af..eb7504b7 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -9,6 +9,7 @@ use super::{ }, render_plan::RenderPlanView, render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, + style_state::{DEFAULT_STYLE_CAPACITY, MutationKey, StyleArena}, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -53,6 +54,12 @@ struct EngineSession { text: Vec, pending_text: Vec, text_prepared: bool, + styles: StyleArena, + pending_styles: StyleArena, + style_mutation_scratch: Vec, + style_order_scratch: Vec, + style_nesting_scratch: Vec, + styles_prepared: bool, geometry_fingerprint: u64, pending_geometry_fingerprint: u64, geometry_prepared: bool, @@ -132,13 +139,19 @@ impl TextEngine { { return Err(EngineError::InvalidRequest); } - if let Some(existing) = self.font_stacks.iter().find(|stack| stack.handle == handle) { - return if existing.fonts == fonts { - Ok(()) - } else { - Err(EngineError::HandleConflict) - }; - } + let insertion = match self + .font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + { + Ok(index) => { + return if self.font_stacks[index].fonts == fonts { + Ok(()) + } else { + Err(EngineError::HandleConflict) + }; + } + Err(index) => index, + }; let mut retained = Vec::new(); retained .try_reserve_exact(fonts.len()) @@ -147,28 +160,30 @@ impl TextEngine { self.font_stacks .try_reserve(1) .map_err(|_| EngineError::ResultTooLarge)?; - self.font_stacks.push(RegisteredFontStack { - handle, - fonts: retained, - }); + self.font_stacks.insert( + insertion, + RegisteredFontStack { + handle, + fonts: retained, + }, + ); Ok(()) } pub fn dispose_font_stack(&mut self, handle: u32) -> Result<(), EngineError> { let index = self .font_stacks - .iter() - .position(|stack| stack.handle == handle) - .ok_or(EngineError::FontStackMissing)?; - self.font_stacks.swap_remove(index); + .binary_search_by_key(&handle, |stack| stack.handle) + .map_err(|_| EngineError::FontStackMissing)?; + self.font_stacks.remove(index); Ok(()) } pub fn font_stack(&self, handle: u32) -> Result<&[u32], EngineError> { self.font_stacks - .iter() - .find(|stack| stack.handle == handle) - .map(|stack| stack.fonts.as_slice()) + .binary_search_by_key(&handle, |stack| stack.handle) + .ok() + .map(|index| self.font_stacks[index].fonts.as_slice()) .ok_or(EngineError::FontStackMissing) } @@ -226,7 +241,22 @@ impl TextEngine { if self.sessions.contains_key(&handle) { return Err(EngineError::SessionConflict); } - self.sessions.insert(handle, EngineSession::default()); + let mut session = EngineSession::default(); + session.styles.reserve_default()?; + session.pending_styles.reserve_default()?; + session + .style_mutation_scratch + .try_reserve_exact(DEFAULT_STYLE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + session + .style_order_scratch + .try_reserve_exact(DEFAULT_STYLE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + session + .style_nesting_scratch + .try_reserve_exact(DEFAULT_STYLE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + self.sessions.insert(handle, session); Ok(()) } @@ -263,6 +293,14 @@ impl TextEngine { .ok_or(EngineError::SessionMissing) } + #[cfg(test)] + pub(crate) fn session_style_count(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .map(|session| session.styles.len()) + .ok_or(EngineError::SessionMissing) + } + pub fn session_count(&self) -> u32 { self.sessions.len().try_into().unwrap_or(u32::MAX) } @@ -287,6 +325,7 @@ impl TextEngine { } let policy_fingerprint = policy.fingerprint(); let font_bindings = &self.font_bindings; + let font_stacks = &self.font_stacks; let gather = &mut self.gather; let session = self .sessions @@ -324,8 +363,17 @@ impl TextEngine { // plan preparation or publication later aborts. session.acknowledged_publication_generation = request.acknowledged_publication_generation; session.prepare_text(request.text_mutations)?; + if let Err(error) = session.prepare_styles(request.style_mutations, |handle| { + font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + .is_ok() + }) { + session.abort_text(); + return Err(error); + } if let Err(error) = session.prepare_geometry(request.geometry) { session.abort_text(); + session.abort_styles(); return Err(error); } if let Err(error) = gather.gather( @@ -344,6 +392,7 @@ impl TextEngine { }, ) { session.abort_text(); + session.abort_styles(); session.abort_geometry(); return Err(gather_error(error)); } @@ -357,6 +406,7 @@ impl TextEngine { request.acknowledged_publication_generation, ) { session.abort_text(); + session.abort_styles(); session.abort_geometry(); return Err(plan_error(error)); } @@ -403,6 +453,7 @@ impl TextEngine { } session.plan.abort(); session.abort_text(); + session.abort_styles(); session.abort_geometry(); Ok(()) } @@ -420,6 +471,7 @@ impl TextEngine { } session.plan.commit().map_err(plan_error)?; session.commit_text(); + session.commit_styles(); session.commit_geometry(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, @@ -470,6 +522,65 @@ impl EngineSession { self.text_prepared = false; } + fn prepare_styles( + &mut self, + mutations: super::semantic_wire::StyleMutationBatch<'_>, + font_stack_exists: impl FnMut(u32) -> bool, + ) -> Result<(), EngineError> { + self.abort_styles(); + if mutations.len() == 0 { + if !self.text_prepared || self.styles.len() == 0 { + return Ok(()); + } + return self.styles.validate( + self.pending_text.as_slice(), + font_stack_exists, + &mut self.style_order_scratch, + &mut self.style_nesting_scratch, + ); + } + self.pending_styles.prepare_from( + &self.styles, + mutations, + &mut self.style_mutation_scratch, + )?; + if self.styles.len() != 0 && self.pending_styles.len() == 0 { + self.abort_styles(); + return Err(EngineError::InvalidRequest); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + if let Err(error) = self.pending_styles.validate( + text, + font_stack_exists, + &mut self.style_order_scratch, + &mut self.style_nesting_scratch, + ) { + self.abort_styles(); + return Err(error); + } + self.styles_prepared = true; + Ok(()) + } + + fn abort_styles(&mut self) { + self.pending_styles.clear(); + self.style_mutation_scratch.clear(); + self.style_order_scratch.clear(); + self.style_nesting_scratch.clear(); + self.styles_prepared = false; + } + + fn commit_styles(&mut self) { + if self.styles_prepared { + core::mem::swap(&mut self.styles, &mut self.pending_styles); + } + self.abort_styles(); + } + fn commit_text(&mut self) { if self.text_prepared { core::mem::swap(&mut self.text, &mut self.pending_text); @@ -586,7 +697,7 @@ mod tests { use super::*; use crate::{ abi_contract::{ - ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, + self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, @@ -595,14 +706,18 @@ mod tests { font_binding::{ FieldTable, FontRenderBinding, FontResource, FontStrike, MISSING_RESOURCE_INDEX, }, - frame::{TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16}, + frame::{ + STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_LINE_HEIGHT, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, + STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, + }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CAP_ORDERED_DIRECT, CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, TechniqueId, }, - semantic_wire::parse_text_mutations, + semantic_wire::{parse_style_mutations, parse_text_mutations}, }, wire::write_u32, }; @@ -855,6 +970,53 @@ mod tests { ); } + #[test] + fn retained_style_upserts_commit_and_root_removal_aborts_transactionally() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.register_font_stack(7, &[42]).unwrap(); + engine.create_session(4).unwrap(); + + let initial_bytes = text_mutation_bytes(&[(0, 0, &[0x61, 0x62, 0x63, 0x64])]); + let mut initial = update(0, 0, 0); + initial.text_mutations = + parse_text_mutations(&initial_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let root_bytes = root_style_bytes(7); + let mut root = update(1, 1, 1); + root.style_mutations = + parse_style_mutations(&root_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(root, 2).unwrap(); + assert_eq!(engine.session_style_count(4), Ok(0)); + engine.commit_update(prepared).unwrap(); + assert_eq!(engine.session_style_count(4), Ok(1)); + + let remove_bytes = remove_style_bytes(1); + let mut remove = update(2, 2, 2); + remove.style_mutations = + parse_style_mutations(&remove_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + assert_eq!( + engine.prepare_update(remove, 3), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.session_style_count(4), Ok(1)); + + let missing_stack_bytes = root_style_bytes(99); + let mut missing_stack = update(2, 2, 2); + missing_stack.style_mutations = + parse_style_mutations(&missing_stack_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1) + .unwrap(); + assert_eq!( + engine.prepare_update(missing_stack, 3), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.session_style_count(4), Ok(1)); + } + #[test] fn an_invalid_later_replacement_cannot_partially_mutate_committed_text() { let mut engine = TextEngine::default(); @@ -1013,10 +1175,65 @@ mod tests { max_output_bytes: 128, }, text_mutations: super::super::semantic_wire::TextMutationBatch::empty(), + style_mutations: super::super::semantic_wire::StyleMutationBatch::empty(), geometry: super::super::semantic_wire::GeometryBatch::empty(), } } + fn root_style_bytes(font_stack_handle: u32) -> Vec { + let record = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let mut bytes = vec![0; record + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize]; + bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_UPSERT; + bytes[record + abi::ENGINE_STYLE_MUTATION_FLAGS] = STYLE_FLAG_ROOT; + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, 1); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FIELD_MASK, + STYLE_FIELD_FONT_STACK + | STYLE_FIELD_FONT_SIZE + | STYLE_FIELD_LINE_HEIGHT + | STYLE_FIELD_RASTER_PIXEL_RATIO, + ); + write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_TEXT_END, 4); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_STACK_HANDLE, + font_stack_handle, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_FONT_SIZE, + 16.0, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_LINE_HEIGHT, + 1.2, + ); + write_f32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, + 1.0, + ); + bytes + } + + fn write_f32(bytes: &mut [u8], offset: usize, value: f32) { + write_u32(bytes, offset, value.to_bits()); + } + + fn remove_style_bytes(style_id: u32) -> Vec { + let record = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let mut bytes = vec![0; record + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize]; + bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_REMOVE; + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, + style_id, + ); + bytes + } + fn text_mutation_bytes(records: &[(u32, u32, &[u16])]) -> Vec { let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; let records_length = records.len() * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs new file mode 100644 index 00000000..5968dcc4 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -0,0 +1,390 @@ +//! Flat retained style storage and transactional mutation merge. + +use alloc::vec::Vec; + +use crate::{ + FeatureRecord, + engine::{ + frame::{ + STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_LINE_HEIGHT, + STYLE_FIELD_RASTER_PIXEL_RATIO, + }, + semantic_wire::{StyleMutation, StyleMutationBatch, StyleValue}, + }, + valid_utf16_boundary, +}; + +use super::EngineError; + +const ROOT_REQUIRED_FIELDS: u32 = STYLE_FIELD_FONT_STACK + | STYLE_FIELD_FONT_SIZE + | STYLE_FIELD_LINE_HEIGHT + | STYLE_FIELD_RASTER_PIXEL_RATIO; +pub(crate) const DEFAULT_STYLE_CAPACITY: usize = 64; +const DEFAULT_LANGUAGE_CAPACITY: usize = 512; +const DEFAULT_FEATURE_CAPACITY: usize = 128; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RetainedStyle { + pub style_id: u32, + pub cascade_order: u32, + pub field_mask: u32, + pub text_start: u32, + pub text_end: u32, + pub font_stack_handle: u32, + pub material_id: u32, + pub language_start: u32, + pub language_length: u16, + pub feature_start: u32, + pub feature_count: u16, + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub word_spacing: f32, + pub baseline_shift: f32, + pub raster_pixel_ratio: f32, + pub direction: u8, + pub foreground_rgba: u32, + pub decoration_rgba: u32, + pub decoration_flags: u32, + pub decoration_style: u8, + pub decoration_thickness: f32, + pub decoration_offset: f32, + pub root: bool, +} + +#[derive(Default)] +pub(crate) struct StyleArena { + records: Vec, + languages: Vec, + features: Vec, +} + +#[derive(Clone, Copy)] +pub(crate) struct MutationKey { + style_id: u32, + request_index: usize, +} + +impl StyleArena { + pub(crate) fn len(&self) -> usize { + self.records.len() + } + + pub(crate) fn reserve_default(&mut self) -> Result<(), EngineError> { + self.records + .try_reserve_exact(DEFAULT_STYLE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + self.languages + .try_reserve_exact(DEFAULT_LANGUAGE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; + self.features + .try_reserve_exact(DEFAULT_FEATURE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge) + } + + pub(crate) fn clear(&mut self) { + self.records.clear(); + self.languages.clear(); + self.features.clear(); + } + + pub(crate) fn prepare_from( + &mut self, + committed: &Self, + mutations: StyleMutationBatch<'_>, + scratch: &mut Vec, + ) -> Result<(), EngineError> { + self.clear(); + scratch.clear(); + scratch + .try_reserve(mutations.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + for request_index in 0..mutations.len() { + let mutation = mutations + .get(request_index) + .ok_or(EngineError::InvalidRequest)?; + let style_id = match mutation { + StyleMutation::Remove { style_id } => style_id, + StyleMutation::Upsert(value) => value.style_id, + }; + scratch.push(MutationKey { + style_id, + request_index, + }); + } + scratch.sort_unstable_by_key(|key| (key.style_id, key.request_index)); + collapse_to_last_mutation(scratch); + + self.records + .try_reserve(committed.records.len().saturating_add(scratch.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + self.languages + .try_reserve( + committed + .languages + .len() + .saturating_add(total_language_bytes(mutations)?), + ) + .map_err(|_| EngineError::ResultTooLarge)?; + self.features + .try_reserve( + committed + .features + .len() + .saturating_add(total_feature_count(mutations)?), + ) + .map_err(|_| EngineError::ResultTooLarge)?; + + let mut committed_index = 0; + let mut mutation_index = 0; + while committed_index < committed.records.len() || mutation_index < scratch.len() { + let committed_id = committed + .records + .get(committed_index) + .map_or(u32::MAX, |style| style.style_id); + let mutation_id = scratch + .get(mutation_index) + .map_or(u32::MAX, |key| key.style_id); + if committed_id < mutation_id { + self.push_retained(committed, committed_index)?; + committed_index += 1; + } else if mutation_id < committed_id { + self.push_mutation(mutations, scratch[mutation_index].request_index)?; + mutation_index += 1; + } else { + self.push_mutation(mutations, scratch[mutation_index].request_index)?; + committed_index += 1; + mutation_index += 1; + } + } + Ok(()) + } + + pub(crate) fn validate( + &self, + text: &[u16], + mut font_stack_exists: impl FnMut(u32) -> bool, + order_scratch: &mut Vec, + nesting_scratch: &mut Vec, + ) -> Result<(), EngineError> { + if self.records.is_empty() { + return Ok(()); + } + let text_length = u32::try_from(text.len()).map_err(|_| EngineError::InvalidRequest)?; + let mut root_count = 0; + for style in &self.records { + if style.text_end > text_length + || !valid_utf16_boundary(text, style.text_start) + || !valid_utf16_boundary(text, style.text_end) + || style.field_mask & STYLE_FIELD_FONT_STACK != 0 + && !font_stack_exists(style.font_stack_handle) + { + return Err(EngineError::InvalidRequest); + } + if style.root { + root_count += 1; + if style.text_start != 0 + || style.text_end != text_length + || style.field_mask & ROOT_REQUIRED_FIELDS != ROOT_REQUIRED_FIELDS + { + return Err(EngineError::InvalidRequest); + } + } + for feature in self.features(*style) { + if !valid_utf16_boundary(text, feature.start) + || !valid_utf16_boundary(text, feature.end) + { + return Err(EngineError::InvalidRequest); + } + } + } + if root_count != 1 { + return Err(EngineError::InvalidRequest); + } + + order_scratch.clear(); + order_scratch + .try_reserve(self.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + order_scratch.extend(0..self.records.len()); + order_scratch.sort_unstable_by_key(|index| { + let style = self.records[*index]; + ( + style.text_start, + core::cmp::Reverse(style.text_end), + style.cascade_order, + ) + }); + if order_scratch.windows(2).any(|pair| { + let left = self.records[pair[0]]; + let right = self.records[pair[1]]; + left.text_start == right.text_start + && left.text_end == right.text_end + && left.cascade_order == right.cascade_order + }) { + return Err(EngineError::InvalidRequest); + } + nesting_scratch.clear(); + nesting_scratch + .try_reserve(self.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + for index in order_scratch.iter().copied() { + let style = self.records[index]; + while nesting_scratch + .last() + .is_some_and(|end| style.text_start >= *end) + { + nesting_scratch.pop(); + } + if nesting_scratch + .last() + .is_some_and(|end| style.text_end > *end) + { + return Err(EngineError::InvalidRequest); + } + nesting_scratch.push(style.text_end); + } + Ok(()) + } + + fn push_retained(&mut self, source: &Self, index: usize) -> Result<(), EngineError> { + let mut style = source.records[index]; + let language = source.language(style); + let features = source.features(style); + set_payload_ranges(self, &mut style, language, features)?; + self.records.push(style); + Ok(()) + } + + fn push_mutation( + &mut self, + mutations: StyleMutationBatch<'_>, + request_index: usize, + ) -> Result<(), EngineError> { + let mutation = mutations + .get(request_index) + .ok_or(EngineError::InvalidRequest)?; + let StyleMutation::Upsert(value) = mutation else { + return Ok(()); + }; + let mut style = retained(value); + style.language_start = + u32::try_from(self.languages.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.language_length = + u16::try_from(value.language.len()).map_err(|_| EngineError::ResultTooLarge)?; + self.languages.extend_from_slice(value.language); + style.feature_start = + u32::try_from(self.features.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.feature_count = + u16::try_from(value.features.len() / 16).map_err(|_| EngineError::ResultTooLarge)?; + for index in 0..value.features.len() / 16 { + self.features.push( + StyleMutationBatch::feature(value, index).ok_or(EngineError::InvalidRequest)?, + ); + } + self.records.push(style); + Ok(()) + } + + fn language(&self, style: RetainedStyle) -> &[u8] { + let start = style.language_start as usize; + &self.languages[start..start + usize::from(style.language_length)] + } + + fn features(&self, style: RetainedStyle) -> &[FeatureRecord] { + let start = style.feature_start as usize; + &self.features[start..start + usize::from(style.feature_count)] + } +} + +fn collapse_to_last_mutation(scratch: &mut Vec) { + let mut read = 0; + let mut write = 0; + while read < scratch.len() { + let mut next = read + 1; + while next < scratch.len() && scratch[next].style_id == scratch[read].style_id { + next += 1; + } + scratch[write] = scratch[next - 1]; + write += 1; + read = next; + } + scratch.truncate(write); +} + +fn total_language_bytes(mutations: StyleMutationBatch<'_>) -> Result { + let mut total = 0usize; + for index in 0..mutations.len() { + if let StyleMutation::Upsert(value) = + mutations.get(index).ok_or(EngineError::InvalidRequest)? + { + total = total + .checked_add(value.language.len()) + .ok_or(EngineError::ResultTooLarge)?; + } + } + Ok(total) +} + +fn total_feature_count(mutations: StyleMutationBatch<'_>) -> Result { + let mut total = 0usize; + for index in 0..mutations.len() { + if let StyleMutation::Upsert(value) = + mutations.get(index).ok_or(EngineError::InvalidRequest)? + { + total = total + .checked_add(value.features.len() / 16) + .ok_or(EngineError::ResultTooLarge)?; + } + } + Ok(total) +} + +fn set_payload_ranges( + arena: &mut StyleArena, + style: &mut RetainedStyle, + language: &[u8], + features: &[FeatureRecord], +) -> Result<(), EngineError> { + style.language_start = + u32::try_from(arena.languages.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.language_length = + u16::try_from(language.len()).map_err(|_| EngineError::ResultTooLarge)?; + arena.languages.extend_from_slice(language); + style.feature_start = + u32::try_from(arena.features.len()).map_err(|_| EngineError::ResultTooLarge)?; + style.feature_count = u16::try_from(features.len()).map_err(|_| EngineError::ResultTooLarge)?; + arena.features.extend_from_slice(features); + Ok(()) +} + +fn retained(value: StyleValue<'_>) -> RetainedStyle { + RetainedStyle { + style_id: value.style_id, + cascade_order: value.cascade_order, + field_mask: value.field_mask, + text_start: value.text_start, + text_end: value.text_end, + font_stack_handle: value.font_stack_handle, + material_id: value.material_id, + language_start: 0, + language_length: 0, + feature_start: 0, + feature_count: 0, + font_size: value.font_size, + line_height: value.line_height, + letter_spacing: value.letter_spacing, + word_spacing: value.word_spacing, + baseline_shift: value.baseline_shift, + raster_pixel_ratio: value.raster_pixel_ratio, + direction: value.direction, + foreground_rgba: value.foreground_rgba, + decoration_rgba: value.decoration_rgba, + decoration_flags: value.decoration_flags, + decoration_style: value.decoration_style, + decoration_thickness: value.decoration_thickness, + decoration_offset: value.decoration_offset, + root: value.root, + } +} diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 3d341c53..e061f2f2 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -92,7 +92,7 @@ struct PlanFeatureKey { global: bool, } -#[derive(Clone, Copy)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct FeatureRecord { pub tag: u32, pub value: u32, @@ -776,7 +776,7 @@ fn valid_script(tag: u32) -> bool { bytes[0].is_ascii_uppercase() && bytes[1..].iter().all(u8::is_ascii_lowercase) } -fn valid_utf16_boundary(text: &[u16], offset: u32) -> bool { +pub(crate) fn valid_utf16_boundary(text: &[u16], offset: u32) -> bool { let Ok(offset) = usize::try_from(offset) else { return false; }; diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 4432d76f..8e463ed8 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; import { createRuntimeShaper, FontRegistry } from '@pmndrs/text'; import { createFontBaker } from '@pmndrs/text-font-baker'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; -import { fontBindingBytes } from '../support/engine-abi.mjs'; +import { fontBindingBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; const fixtureDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); const shaperWasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); @@ -142,6 +142,46 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo assert.equal(fn.registerFontStack(17, stack.pointer, 1), abi.status.ok); fn.deallocate(stack.pointer, stack.length); assert.equal(fn.fontStackCount(), 1); + + const policyBytes = renderPolicyBytes(abi); + const policy = copyToWasm(memory, fn.allocate, policyBytes); + assert.equal(fn.registerPolicy(23, policy.pointer, policy.length), abi.status.ok); + fn.deallocate(policy.pointer, policy.length); + assert.equal(fn.createSession(29, 512, abi.layouts.engineResult.size, 4), abi.status.ok); + const styleWarmBuffer = memory.buffer; + const initialUpdate = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + text: [0x61, 0x62, 0x63, 0x64], + }); + let requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, initialUpdate.byteLength).set(initialUpdate); + let resultPointer = fn.textUpdate(29, requestPointer, initialUpdate.byteLength); + assert.strictEqual(memory.buffer, styleWarmBuffer, 'the pre-reserved first style update must not grow Wasm memory'); + let result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 1); + + const removeRoot = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + expectedEngineRevision: 1, + consumedPlanRevision: 1, + acknowledgedPublicationGeneration: 1, + removeRoot: true, + }); + requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, removeRoot.byteLength).set(removeRoot); + resultPointer = fn.textUpdate(29, requestPointer, removeRoot.byteLength); + assert.strictEqual(memory.buffer, styleWarmBuffer, 'an invalid retained style update must not grow Wasm memory'); + result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.invalidRequest); + assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 1); + assert.equal(fn.disposeSession(29), abi.status.ok); + assert.equal(fn.disposePolicy(23), abi.status.ok); + assert.equal(fn.disposeFont(101), abi.status.fontInUse); assert.equal(fn.disposeFontStack(17), abi.status.ok); assert.equal(fn.disposeFontStack(17), abi.status.fontStackMissing); @@ -169,6 +209,87 @@ function copyToWasm(memory, allocate, source) { return { pointer, length: bytes.byteLength }; } +function engineStyleUpdateBytes( + abi, + { + sessionId, + policyHandle, + fontStackHandle, + expectedEngineRevision = 0, + consumedPlanRevision = 0, + acknowledgedPublicationGeneration = 0, + text = [], + removeRoot = false, + }, +) { + const request = abi.layouts.engineUpdateRequest; + const textRecord = abi.layouts.engineTextMutation; + const styleRecord = abi.layouts.engineStyleMutation; + const textRecordOffset = text.length === 0 ? 0 : request.size; + const styleRecordOffset = align(request.size + (text.length === 0 ? 0 : textRecord.size), styleRecord.alignment); + const textPayloadOffset = styleRecordOffset + styleRecord.size; + const bytes = new Uint8Array(textPayloadOffset + text.length * 2); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, abi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.sessionId, sessionId, true); + view.setUint32(request.expectedEngineRevision, expectedEngineRevision, true); + view.setUint32(request.consumedPlanRevision, consumedPlanRevision, true); + view.setUint32(request.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); + view.setUint32(request.policyHandle, policyHandle, true); + view.setUint32(request.capabilitySet, 1, true); + for (const field of [ + 'maxClusters', + 'maxLines', + 'maxRegions', + 'maxExclusions', + 'maxInlineObjects', + 'maxSlotsPerBand', + ]) { + view.setUint32(request[field], field === 'maxClusters' ? 2 : 1, true); + } + view.setUint32(request.maxOutputBytes, abi.layouts.engineResult.size, true); + view.setUint32(request.textMutationsOffset, textRecordOffset, true); + view.setUint32(request.textMutationCount, text.length === 0 ? 0 : 1, true); + view.setUint32(request.styleMutationsOffset, styleRecordOffset, true); + view.setUint32(request.styleMutationCount, 1, true); + + if (text.length > 0) { + view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(textRecordOffset + textRecord.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(textRecordOffset + textRecord.insertOffset, textPayloadOffset, true); + view.setUint32(textRecordOffset + textRecord.insertCount, text.length, true); + for (const [index, unit] of text.entries()) view.setUint16(textPayloadOffset + index * 2, unit, true); + } + + view.setUint8( + styleRecordOffset + styleRecord.opcode, + removeRoot ? abi.engine.styleMutationOpcodes.remove : abi.engine.styleMutationOpcodes.upsert, + ); + view.setUint32(styleRecordOffset + styleRecord.styleId, 1, true); + if (!removeRoot) { + view.setUint8(styleRecordOffset + styleRecord.flags, abi.engine.styleFlags.root); + view.setUint32( + styleRecordOffset + styleRecord.fieldMask, + abi.engine.styleFields.fontStack | + abi.engine.styleFields.fontSize | + abi.engine.styleFields.lineHeight | + abi.engine.styleFields.rasterPixelRatio, + true, + ); + view.setUint32(styleRecordOffset + styleRecord.textEnd, text.length, true); + view.setUint32(styleRecordOffset + styleRecord.fontStackHandle, fontStackHandle, true); + view.setFloat32(styleRecordOffset + styleRecord.fontSize, 16, true); + view.setFloat32(styleRecordOffset + styleRecord.lineHeight, 1.2, true); + view.setFloat32(styleRecordOffset + styleRecord.rasterPixelRatio, 1, true); + } + return bytes; +} + +function align(value, alignment) { + return Math.ceil(value / alignment) * alignment; +} + test('re-registering the same artifact creates a new lifecycle without reviving stale handles', async () => { const { artifact, shaperWasm } = await fixture(); const registry = new FontRegistry(); From fde6aa86221511ddd49496273af4d1ad2a9f85bd Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 13:55:01 -0400 Subject: [PATCH 028/128] feat(text): resolve rust style cascade --- docs/log.md | 7 + docs/packages/text.md | 13 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 6 + packages/text/rust/shaper/src/engine/state.rs | 34 +- .../rust/shaper/src/engine/style_state.rs | 398 +++++++++++++++++- 6 files changed, 451 insertions(+), 8 deletions(-) diff --git a/docs/log.md b/docs/log.md index 4a77953f..00fc2bd5 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Resolved the retained style cascade in Rust** — A derived A/B segment arena now sweeps validated containment order + once, carries resolved parents in pre-reserved scope scratch, applies stated fields at scope entry, restores parents at + exit, and coalesces equal neighbors without copying retained language/features. A nested/equal-range proof emits five + exact segments and covers shaping, spacing, paint, material, and authored tie precedence. Absent root line height + remains natural-metrics state. Host/SIMD Clippy and real compiled-Wasm lifecycle tests pass. The module is 895,593 / + 335,396 / 264,355 raw/gzip/Brotli bytes (+7,170 / +2,656 / +1,607). Unicode/run intersection and shaping remain open. + - **Admitted transactional retained styles in Rust** — `text_update` now decodes canonical style snapshots and removals without allocation, merge-compacts them by stable ID into pre-reserved flat A/B session arenas, and validates authored cascade order, nesting, UTF-16 ranges, language/features, registered stacks, root completeness, numeric diff --git a/docs/packages/text.md b/docs/packages/text.md index 8c76fdd1..04814925 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:3e73bcdbe8c884a3fae45b6883ee7d4df14c03f24e7dbccf8157d41ca873d030' +source_digest: 'sha256:70d65d4c875fd84363587eccb3b66af48ceb138755ca79c362a2ad335724861e' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -510,6 +510,17 @@ not grow memory after session creation. Reachability changes optimized Wasm from 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes (+31,592 / +13,737 / +10,512). Plans remain empty, so this is retained state evidence, not a shaping/layout latency result. +Retained styles now resolve inside the same transaction into maximal flat segments. The resolver consumes the validated +containment order once, keeps inherited values in a pre-reserved scope stack, applies each stated field once when its +scope opens, restores its parent when it closes, and coalesces adjacent semantically equal results. The root must state +a font stack, logical font size, and raster density; line height may remain absent so later layout can use natural font +metrics, matching the existing public API. Language and feature results reference compact retained style storage rather +than copying payload per segment. A nested/equal-range Rust proof resolves five exact segments with per-property +inheritance and authored same-range precedence. Host and SIMD Clippy plus real compiled-Wasm lifecycle tests pass. +Reachability changes the optimized module from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 +raw/gzip/Brotli bytes (+7,170 / +2,656 / +1,607). The next open connection is Unicode/script/bidi run intersection and +HarfRust shaping; plan output is still empty. + The frame decoder now borrows ordered UTF-16 replacement records and their offset-addressed payloads directly from the pinned request. It validates canonical empty offsets, opcode/encoding, reserved fields, bounds, alignment, arithmetic, and record/payload non-overlap before the session transaction. Rust applies sequential replacements into retained diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index f4eb5b03..b6a2caec 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -246,6 +246,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-179 | Editorial flow geometry is a complete borrowed section of one `text_update`, never a host measurement callback. A nonempty geometry transaction contains at least one constraint and region and may contain bounded rectangle/polygon exclusions and text-anchored inline objects. Compiler-published enums define axis, wrap, inline/block alignment, overflow, writing mode, orientation, exclusion side, and baseline tags. Rust validates finite ordered bounds, polygon vertices, limits, unique identities, region/exclusion ranges, text offsets after pending mutations, reserved fields, and all table/payload overlap before state mutation. Sessions transactionally retain a semantic geometry fingerprint while layout will consume the borrowed records directly; pointer-only vertex offsets are excluded so equivalent repacking is deterministic. Compiled Wasm accepts a complete rectangle/exclusion/object request in one update and rejects a forged region reference without advancing the A/B publication. Optimized Wasm changes from 847,814 / 315,809 / 249,629 to 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. Styles remain rejected until their retained mutation model lands. | Accepted | | D-180 | The retained style ABI uses an explicit authored `cascadeOrder` independent from stable `styleId`; ID allocation therefore cannot change equal-range precedence. Its `fieldMask` is stated-property presence, not a dirty hint, so absent values inherit and explicit zero-valued declarations remain representable. The 88-byte record also carries `rasterPixelRatio` for Rust-owned bitmap strike selection, although target density remains a root target property rather than a per-span typography feature. Compiler-published style, field, decoration-style, and decoration-flag vocabularies prevent host-local tag drift. This decision fixes the wire contract only: nonempty style sections remain rejected until validation and transactional retained storage land. | Accepted | | D-181 | Nonempty style mutations are admitted only with their Rust consumer. Decoding borrows canonical fixed records and monotonically packed offset payloads without allocation. Each session pre-reserves two flat 64-style/512-language-byte/128-feature arenas and reusable mutation/order/nesting scratch. Mutations collapse by stable ID and merge with committed ID-sorted state into a compact inactive arena; no per-style vector or stale replaced payload survives. Rust validates stated versus absent bytes, numeric domains, language/tags, UTF-16 feature/range boundaries, binary-searched font-stack reachability, one complete root, unambiguous equal-range cascade order, proper nesting, and all request-section aliasing before plan preparation. Payload admission is linear and retained validation is O(n log n), with one reusable-scratch sort. Commit swaps arenas and abort preserves committed state. A compiled-Wasm real-font transaction commits text plus root style and rejects root removal without revision advance or post-creation memory growth. Optimized Wasm changes from 856,831 / 319,003 / 252,236 to 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes. Layout consumption and latency remain open. | Accepted | +| D-182 | Rust resolves retained stated styles into a derived A/B segment arena before shaping. One containment sweep stores the fully resolved parent in pre-reserved scope scratch, applies each stated field once, restores parent values on close, and coalesces adjacent semantically equal segments. Stable identity is irrelevant to precedence; containment and explicit authored order govern equal ranges. Language and feature results reference retained compact payloads rather than copying per segment. Root font stack, logical size, and target density are required; line height may remain absent to select natural font metrics. A nested/equal-range proof emits five exact maximal segments and verifies inherited shaping, spacing, paint, material, language, and features. Optimized Wasm changes from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 raw/gzip/Brotli bytes. Unicode/run intersection, shaping, layout, and nonempty plan output remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index c8fa7058..fcc7a99c 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -308,6 +308,12 @@ inactive arena, so replacing a style cannot accumulate dead language or feature for bitmap strike selection but rejected on non-root spans. Commit is an arena swap and abort does not touch committed styles. +Resolution is a separate derived A/B arena. One sweep of the validated containment order keeps the fully resolved parent +on a pre-reserved stack, applies only the fields stated by each opening scope, emits maximal segments at start/end +boundaries, and coalesces equal neighbors. Language and OpenType-feature values remain references into the compact +retained style arena. A root states font stack, logical size, and target density; absent line height deliberately means +natural font metrics rather than a fabricated multiplier. + All offsets and lengths are range-checked before use. Enum tags, alignment, multiplication, and revision relationships are validated at the Wasm boundary. Failure returns a typed result without exposing partially mutated state. diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index eb7504b7..b2f1b60a 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -9,7 +9,9 @@ use super::{ }, render_plan::RenderPlanView, render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, - style_state::{DEFAULT_STYLE_CAPACITY, MutationKey, StyleArena}, + style_state::{ + DEFAULT_STYLE_CAPACITY, MutationKey, ResolutionScope, ResolvedStyleArena, StyleArena, + }, }; #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -56,9 +58,12 @@ struct EngineSession { text_prepared: bool, styles: StyleArena, pending_styles: StyleArena, + resolved_styles: ResolvedStyleArena, + pending_resolved_styles: ResolvedStyleArena, style_mutation_scratch: Vec, style_order_scratch: Vec, style_nesting_scratch: Vec, + style_resolution_scratch: Vec, styles_prepared: bool, geometry_fingerprint: u64, pending_geometry_fingerprint: u64, @@ -244,6 +249,8 @@ impl TextEngine { let mut session = EngineSession::default(); session.styles.reserve_default()?; session.pending_styles.reserve_default()?; + session.resolved_styles.reserve_default()?; + session.pending_resolved_styles.reserve_default()?; session .style_mutation_scratch .try_reserve_exact(DEFAULT_STYLE_CAPACITY) @@ -256,6 +263,10 @@ impl TextEngine { .style_nesting_scratch .try_reserve_exact(DEFAULT_STYLE_CAPACITY) .map_err(|_| EngineError::ResultTooLarge)?; + session + .style_resolution_scratch + .try_reserve_exact(DEFAULT_STYLE_CAPACITY) + .map_err(|_| EngineError::ResultTooLarge)?; self.sessions.insert(handle, session); Ok(()) } @@ -301,6 +312,14 @@ impl TextEngine { .ok_or(EngineError::SessionMissing) } + #[cfg(test)] + pub(crate) fn session_style_segment_count(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .map(|session| session.resolved_styles.segments().len()) + .ok_or(EngineError::SessionMissing) + } + pub fn session_count(&self) -> u32 { self.sessions.len().try_into().unwrap_or(u32::MAX) } @@ -562,21 +581,32 @@ impl EngineSession { self.abort_styles(); return Err(error); } + if let Err(error) = self.pending_styles.resolve( + &self.style_order_scratch, + &mut self.pending_resolved_styles, + &mut self.style_resolution_scratch, + ) { + self.abort_styles(); + return Err(error); + } self.styles_prepared = true; Ok(()) } fn abort_styles(&mut self) { self.pending_styles.clear(); + self.pending_resolved_styles.clear(); self.style_mutation_scratch.clear(); self.style_order_scratch.clear(); self.style_nesting_scratch.clear(); + self.style_resolution_scratch.clear(); self.styles_prepared = false; } fn commit_styles(&mut self) { if self.styles_prepared { core::mem::swap(&mut self.styles, &mut self.pending_styles); + core::mem::swap(&mut self.resolved_styles, &mut self.pending_resolved_styles); } self.abort_styles(); } @@ -992,8 +1022,10 @@ mod tests { parse_style_mutations(&root_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); let prepared = engine.prepare_update(root, 2).unwrap(); assert_eq!(engine.session_style_count(4), Ok(0)); + assert_eq!(engine.session_style_segment_count(4), Ok(0)); engine.commit_update(prepared).unwrap(); assert_eq!(engine.session_style_count(4), Ok(1)); + assert_eq!(engine.session_style_segment_count(4), Ok(1)); let remove_bytes = remove_style_bytes(1); let mut remove = update(2, 2, 2); diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs index 5968dcc4..01df4779 100644 --- a/packages/text/rust/shaper/src/engine/style_state.rs +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -6,8 +6,11 @@ use crate::{ FeatureRecord, engine::{ frame::{ - STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_LINE_HEIGHT, - STYLE_FIELD_RASTER_PIXEL_RATIO, + DECORATION_NONE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, + STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, + STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, }, semantic_wire::{StyleMutation, StyleMutationBatch, StyleValue}, }, @@ -16,13 +19,12 @@ use crate::{ use super::EngineError; -const ROOT_REQUIRED_FIELDS: u32 = STYLE_FIELD_FONT_STACK - | STYLE_FIELD_FONT_SIZE - | STYLE_FIELD_LINE_HEIGHT - | STYLE_FIELD_RASTER_PIXEL_RATIO; +const ROOT_REQUIRED_FIELDS: u32 = + STYLE_FIELD_FONT_STACK | STYLE_FIELD_FONT_SIZE | STYLE_FIELD_RASTER_PIXEL_RATIO; pub(crate) const DEFAULT_STYLE_CAPACITY: usize = 64; const DEFAULT_LANGUAGE_CAPACITY: usize = 512; const DEFAULT_FEATURE_CAPACITY: usize = 128; +const NO_STYLE_SOURCE: u32 = u32::MAX; #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct RetainedStyle { @@ -66,6 +68,88 @@ pub(crate) struct MutationKey { request_index: usize, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ResolvedStyle { + pub font_stack_handle: u32, + pub material_id: u32, + pub font_size: f32, + pub line_height: f32, + pub letter_spacing: f32, + pub word_spacing: f32, + pub baseline_shift: f32, + pub raster_pixel_ratio: f32, + pub direction: u8, + pub foreground_rgba: u32, + pub decoration_rgba: u32, + pub decoration_flags: u32, + pub decoration_style: u8, + pub decoration_thickness: f32, + pub decoration_offset: f32, + language_source: u32, + features_source: u32, + pub has_line_height: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct StyleSegment { + pub text_start: u32, + pub text_end: u32, + pub style: ResolvedStyle, +} + +#[derive(Clone, Copy)] +pub(crate) struct ResolutionScope { + end: u32, + style: ResolvedStyle, +} + +#[derive(Default)] +pub(crate) struct ResolvedStyleArena { + segments: Vec, +} + +impl Default for ResolvedStyle { + fn default() -> Self { + Self { + font_stack_handle: 0, + material_id: 0, + font_size: 16.0, + line_height: 0.0, + letter_spacing: 0.0, + word_spacing: 0.0, + baseline_shift: 0.0, + raster_pixel_ratio: 1.0, + direction: 0, + foreground_rgba: u32::MAX, + decoration_rgba: 0, + decoration_flags: 0, + decoration_style: DECORATION_NONE, + decoration_thickness: 0.0, + decoration_offset: 0.0, + language_source: NO_STYLE_SOURCE, + features_source: NO_STYLE_SOURCE, + has_line_height: false, + } + } +} + +impl ResolvedStyleArena { + pub(crate) fn reserve_default(&mut self) -> Result<(), EngineError> { + self.segments + .try_reserve_exact(DEFAULT_STYLE_CAPACITY.saturating_mul(2)) + .map_err(|_| EngineError::ResultTooLarge) + } + + pub(crate) fn clear(&mut self) { + self.segments.clear(); + } + + #[cfg(test)] + pub(crate) fn segments(&self) -> &[StyleSegment] { + &self.segments + } +} + impl StyleArena { pub(crate) fn len(&self) -> usize { self.records.len() @@ -213,6 +297,7 @@ impl StyleArena { ( style.text_start, core::cmp::Reverse(style.text_end), + !style.root, style.cascade_order, ) }); @@ -248,6 +333,123 @@ impl StyleArena { Ok(()) } + pub(crate) fn resolve( + &self, + containment_order: &[usize], + output: &mut ResolvedStyleArena, + scope_scratch: &mut Vec, + ) -> Result<(), EngineError> { + output.clear(); + scope_scratch.clear(); + output + .segments + .try_reserve(self.records.len().saturating_mul(2)) + .map_err(|_| EngineError::ResultTooLarge)?; + scope_scratch + .try_reserve(self.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + let root = containment_order + .first() + .and_then(|index| self.records.get(*index)) + .filter(|style| style.root) + .ok_or(EngineError::InvalidRequest)?; + if root.text_end == 0 { + output.segments.push(StyleSegment { + text_start: 0, + text_end: 0, + style: apply_style(ResolvedStyle::default(), *root, containment_order[0]), + }); + return Ok(()); + } + + let mut current = 0; + let mut opening = 0; + while current < root.text_end { + while scope_scratch + .last() + .is_some_and(|scope| scope.end <= current) + { + scope_scratch.pop(); + } + while let Some(index) = containment_order.get(opening).copied() { + let style = self.records[index]; + if style.text_start != current { + break; + } + let inherited = scope_scratch + .last() + .map_or_else(ResolvedStyle::default, |scope| scope.style); + scope_scratch.push(ResolutionScope { + end: style.text_end, + style: apply_style(inherited, style, index), + }); + opening += 1; + } + let scope = scope_scratch + .last() + .copied() + .ok_or(EngineError::InvalidRequest)?; + let next_open = containment_order + .get(opening) + .map_or(root.text_end, |index| self.records[*index].text_start); + let next = scope.end.min(next_open); + if next <= current { + return Err(EngineError::InvalidRequest); + } + if output.segments.last().is_some_and(|previous| { + previous.text_end == current && self.same_resolved(previous.style, scope.style) + }) { + output + .segments + .last_mut() + .ok_or(EngineError::InvalidRequest)? + .text_end = next; + } else { + output.segments.push(StyleSegment { + text_start: current, + text_end: next, + style: scope.style, + }); + } + current = next; + } + if opening != containment_order.len() + || !scope_scratch.iter().all(|scope| scope.end == root.text_end) + { + return Err(EngineError::InvalidRequest); + } + Ok(()) + } + + pub(crate) fn resolved_language(&self, style: ResolvedStyle) -> Option<&[u8]> { + let index = usize::try_from(style.language_source).ok()?; + self.records.get(index).map(|source| self.language(*source)) + } + + pub(crate) fn resolved_features(&self, style: ResolvedStyle) -> &[FeatureRecord] { + let Ok(index) = usize::try_from(style.features_source) else { + return &[]; + }; + self.records + .get(index) + .map_or(&[], |source| self.features(*source)) + } + + fn same_resolved(&self, left: ResolvedStyle, right: ResolvedStyle) -> bool { + let same_scalars = ResolvedStyle { + language_source: NO_STYLE_SOURCE, + features_source: NO_STYLE_SOURCE, + ..left + } == ResolvedStyle { + language_source: NO_STYLE_SOURCE, + features_source: NO_STYLE_SOURCE, + ..right + }; + same_scalars + && self.resolved_language(left) == self.resolved_language(right) + && self.resolved_features(left) == self.resolved_features(right) + } + fn push_retained(&mut self, source: &Self, index: usize) -> Result<(), EngineError> { let mut style = source.records[index]; let language = source.language(style); @@ -298,6 +500,55 @@ impl StyleArena { } } +fn apply_style(mut resolved: ResolvedStyle, style: RetainedStyle, source: usize) -> ResolvedStyle { + let fields = style.field_mask; + if fields & STYLE_FIELD_FONT_STACK != 0 { + resolved.font_stack_handle = style.font_stack_handle; + } + if fields & STYLE_FIELD_MATERIAL != 0 { + resolved.material_id = style.material_id; + } + if fields & STYLE_FIELD_LANGUAGE != 0 { + resolved.language_source = u32::try_from(source).unwrap_or(NO_STYLE_SOURCE); + } + if fields & STYLE_FIELD_FEATURES != 0 { + resolved.features_source = u32::try_from(source).unwrap_or(NO_STYLE_SOURCE); + } + if fields & STYLE_FIELD_FONT_SIZE != 0 { + resolved.font_size = style.font_size; + } + if fields & STYLE_FIELD_LINE_HEIGHT != 0 { + resolved.line_height = style.line_height; + resolved.has_line_height = true; + } + if fields & STYLE_FIELD_LETTER_SPACING != 0 { + resolved.letter_spacing = style.letter_spacing; + } + if fields & STYLE_FIELD_WORD_SPACING != 0 { + resolved.word_spacing = style.word_spacing; + } + if fields & STYLE_FIELD_BASELINE_SHIFT != 0 { + resolved.baseline_shift = style.baseline_shift; + } + if fields & STYLE_FIELD_RASTER_PIXEL_RATIO != 0 { + resolved.raster_pixel_ratio = style.raster_pixel_ratio; + } + if fields & STYLE_FIELD_DIRECTION != 0 { + resolved.direction = style.direction; + } + if fields & STYLE_FIELD_FOREGROUND != 0 { + resolved.foreground_rgba = style.foreground_rgba; + } + if fields & STYLE_FIELD_DECORATION != 0 { + resolved.decoration_rgba = style.decoration_rgba; + resolved.decoration_flags = style.decoration_flags; + resolved.decoration_style = style.decoration_style; + resolved.decoration_thickness = style.decoration_thickness; + resolved.decoration_offset = style.decoration_offset; + } + resolved +} + fn collapse_to_last_mutation(scratch: &mut Vec) { let mut read = 0; let mut write = 0; @@ -388,3 +639,138 @@ fn retained(value: StyleValue<'_>) -> RetainedStyle { root: value.root, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::frame::{ + STYLE_FIELD_FEATURES, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_MATERIAL, STYLE_FIELD_WORD_SPACING, + }; + use alloc::vec; + + #[test] + fn resolves_nested_and_equal_range_styles_once_per_boundary() { + let root_fields = ROOT_REQUIRED_FIELDS + | STYLE_FIELD_LANGUAGE + | STYLE_FIELD_FEATURES + | STYLE_FIELD_FOREGROUND; + let arena = StyleArena { + records: vec![ + style(10, 0, 0, 8, root_fields, true), + RetainedStyle { + font_size: 20.0, + foreground_rgba: 0xff00_00ff, + ..style( + 20, + 1, + 2, + 6, + STYLE_FIELD_FONT_SIZE | STYLE_FIELD_FOREGROUND, + false, + ) + }, + RetainedStyle { + letter_spacing: 1.5, + word_spacing: 2.0, + ..style( + 30, + 2, + 3, + 5, + STYLE_FIELD_LETTER_SPACING | STYLE_FIELD_WORD_SPACING, + false, + ) + }, + RetainedStyle { + material_id: 9, + ..style(40, 3, 3, 5, STYLE_FIELD_MATERIAL, false) + }, + ], + languages: b"en".to_vec(), + features: vec![FeatureRecord { + tag: u32::from_be_bytes(*b"kern"), + value: 1, + start: 0, + end: 8, + }], + }; + let mut order = Vec::new(); + let mut nesting = Vec::new(); + arena + .validate(&[0x61; 8], |handle| handle == 7, &mut order, &mut nesting) + .unwrap(); + let mut resolved = ResolvedStyleArena::default(); + let mut scopes = Vec::new(); + arena.resolve(&order, &mut resolved, &mut scopes).unwrap(); + + assert_eq!( + resolved + .segments() + .iter() + .map(|segment| (segment.text_start, segment.text_end)) + .collect::>(), + [(0, 2), (2, 3), (3, 5), (5, 6), (6, 8)], + ); + let deepest = resolved.segments()[2].style; + assert_eq!(deepest.font_stack_handle, 7); + assert_eq!(deepest.font_size, 20.0); + assert_eq!(deepest.letter_spacing, 1.5); + assert_eq!(deepest.word_spacing, 2.0); + assert_eq!(deepest.material_id, 9); + assert_eq!(deepest.foreground_rgba, 0xff00_00ff); + assert_eq!(arena.resolved_language(deepest), Some(&b"en"[..])); + assert_eq!(arena.resolved_features(deepest), arena.features.as_slice()); + assert!(!deepest.has_line_height); + assert_eq!(resolved.segments()[3].style.material_id, 0); + } + + fn style( + style_id: u32, + cascade_order: u32, + text_start: u32, + text_end: u32, + field_mask: u32, + root: bool, + ) -> RetainedStyle { + RetainedStyle { + style_id, + cascade_order, + field_mask, + text_start, + text_end, + font_stack_handle: if field_mask & STYLE_FIELD_FONT_STACK != 0 { + 7 + } else { + 0 + }, + material_id: 0, + language_start: 0, + language_length: if field_mask & STYLE_FIELD_LANGUAGE != 0 { + 2 + } else { + 0 + }, + feature_start: 0, + feature_count: if field_mask & STYLE_FIELD_FEATURES != 0 { + 1 + } else { + 0 + }, + font_size: 16.0, + line_height: 1.2, + letter_spacing: 0.0, + word_spacing: 0.0, + baseline_shift: 0.0, + raster_pixel_ratio: 1.0, + direction: 0, + foreground_rgba: u32::MAX, + decoration_rgba: 0, + decoration_flags: 0, + decoration_style: DECORATION_NONE, + decoration_thickness: 0.0, + decoration_offset: 0.0, + root, + } + } +} From 71416fbb0db0f2aa052cfc252d35bf4c58489e39 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 14:11:48 -0400 Subject: [PATCH 029/128] feat(text): retain rust unicode analysis --- docs/log.md | 7 + docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 15 +- packages/text/rust/shaper/Cargo.lock | 7 + packages/text/rust/shaper/Cargo.toml | 1 + packages/text/rust/shaper/src/engine/state.rs | 80 ++++ .../rust/shaper/src/generated/script_data.rs | 360 ++++++++++++++++++ packages/text/rust/shaper/src/lib.rs | 1 + packages/text/rust/shaper/src/unicode.rs | 357 +++++++++++++++++ .../scripts/generate-unicode-script-data.mjs | 70 +++- 11 files changed, 886 insertions(+), 17 deletions(-) create mode 100644 packages/text/rust/shaper/src/generated/script_data.rs create mode 100644 packages/text/rust/shaper/src/unicode.rs diff --git a/docs/log.md b/docs/log.md index 00fc2bd5..e7494f5f 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Retained Unicode 17 analysis moved inside `text_update`** — The shared Unicode generator now emits compact Rust + Script/Script_Extensions partitions beside the TypeScript tables. A no-std Unicode 17 grapheme iterator validates + UTF-16, preserves UTF-16 boundaries, resolves contextual scripts, and reuses pre-reserved active/pending session + arrays. Analysis commits and aborts with text/styles and is skipped for unchanged text. Rust tests, host/SIMD Clippy, + and focused compiled-Wasm tests pass. Optimized Wasm is 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run + intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open. + - **Resolved the retained style cascade in Rust** — A derived A/B segment arena now sweeps validated containment order once, carries resolved parents in pre-reserved scope scratch, applies stated fields at scope entry, restores parents at exit, and coalesces equal neighbors without copying retained language/features. A nested/equal-range proof emits five diff --git a/docs/packages/text.md b/docs/packages/text.md index 04814925..5a39a349 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:70d65d4c875fd84363587eccb3b66af48ceb138755ca79c362a2ad335724861e' +source_digest: 'sha256:be6faa9b8d19faf5a430393a5a0732729388f324a502562309e8197c644f1df7' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -693,6 +693,8 @@ The canonical composed Inter fixture proves GLB → registry → public `Text` The five-line, 120-glyph text above is the bounded conformance specimen. The separate live benchmark ipsum exceeds 1,000 characters and renders 1,150 glyphs through the same one-draw public `Text` path. +The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. The optimized shaper is 964,019 raw, 360,765 gzip, and 288,742 Brotli bytes at this checkpoint. Bidi/run intersection, fallback shaping, layout, and nonempty plan output remain open, so this size evidence carries no new frame latency claim. + Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. `pnpm scripts run text:layout-benchmark` measures that path. It reports a median of warmed repetitions for each invalidation class separately, with the relative standard deviation beside it, because the classes invalidate different tiers and an average across them hides whichever one is slow. Boundary reshaping is gone: it requested the whole run as shaping context, which is the context the retained shape was produced with, so it returned the glyphs it already held on roughly every line of every layout. Measured on an identical workload against the pre-tiering commit at 25,515 glyphs, a reflow lays out in 8.12 ms against 110.40 ms, a resize in 11.98 ms against 103.54 ms, and a text edit in 31.85 ms against 109.66 ms, with the pinned layout hashes unchanged. Phase attribution came from opt-in spans that have since been removed once their evidence was recorded; reinstating them is a diagnostic change, not a shipped feature. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index b6a2caec..e18d5d6d 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -247,6 +247,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-180 | The retained style ABI uses an explicit authored `cascadeOrder` independent from stable `styleId`; ID allocation therefore cannot change equal-range precedence. Its `fieldMask` is stated-property presence, not a dirty hint, so absent values inherit and explicit zero-valued declarations remain representable. The 88-byte record also carries `rasterPixelRatio` for Rust-owned bitmap strike selection, although target density remains a root target property rather than a per-span typography feature. Compiler-published style, field, decoration-style, and decoration-flag vocabularies prevent host-local tag drift. This decision fixes the wire contract only: nonempty style sections remain rejected until validation and transactional retained storage land. | Accepted | | D-181 | Nonempty style mutations are admitted only with their Rust consumer. Decoding borrows canonical fixed records and monotonically packed offset payloads without allocation. Each session pre-reserves two flat 64-style/512-language-byte/128-feature arenas and reusable mutation/order/nesting scratch. Mutations collapse by stable ID and merge with committed ID-sorted state into a compact inactive arena; no per-style vector or stale replaced payload survives. Rust validates stated versus absent bytes, numeric domains, language/tags, UTF-16 feature/range boundaries, binary-searched font-stack reachability, one complete root, unambiguous equal-range cascade order, proper nesting, and all request-section aliasing before plan preparation. Payload admission is linear and retained validation is O(n log n), with one reusable-scratch sort. Commit swaps arenas and abort preserves committed state. A compiled-Wasm real-font transaction commits text plus root style and rejects root removal without revision advance or post-creation memory growth. Optimized Wasm changes from 856,831 / 319,003 / 252,236 to 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes. Layout consumption and latency remain open. | Accepted | | D-182 | Rust resolves retained stated styles into a derived A/B segment arena before shaping. One containment sweep stores the fully resolved parent in pre-reserved scope scratch, applies each stated field once, restores parent values on close, and coalesces adjacent semantically equal segments. Stable identity is irrelevant to precedence; containment and explicit authored order govern equal ranges. Language and feature results reference retained compact payloads rather than copying per segment. Root font stack, logical size, and target density are required; line height may remain absent to select natural font metrics. A nested/equal-range proof emits five exact maximal segments and verifies inherited shaping, spacing, paint, material, language, and features. Optimized Wasm changes from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 raw/gzip/Brotli bytes. Unicode/run intersection, shaping, layout, and nonempty plan output remain open. | Accepted | +| D-183 | Unicode analysis is derived session state inside the Rust frame transaction. The existing pinned Unicode 17 generator emits TypeScript tables and compact Rust Script/Script_Extensions partitions from one source; the Rust partition omits derivable starts. `unicode-segmentation` 1.13.3 runs under `no_std`, validates retained UTF-16 through a reusable UTF-8 scratch string, returns extended-grapheme boundaries to the public UTF-16 coordinate space, and feeds allocation-reusing contextual script itemization. Active and pending analysis arenas reserve with session text capacity, swap only on commit, abort with a failed frame, and are untouched when text is unchanged. Rust unit tests cover Emoji ZWJ, Indic/Kana scripts, shared marks, malformed surrogates, rollback, and capacity reuse; host/SIMD Clippy and compiled-Wasm lifecycle tests pass. Optimized Wasm changes from 895,593 / 335,396 / 264,355 to 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index fcc7a99c..040bb70c 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -839,15 +839,24 @@ runtime code, not font-local shaping data, and is now an explicit size-optimizat replacements now decode as borrowed records and commit into retained session scratch transactionally. Constraints, regions, exclusions, polygon vertices, and inline objects are borrowed from the same request, fully validated before mutation, checked against pending text offsets, and committed as a placement-independent semantic fingerprint. Styles -remain rejected. Because retained text and geometry are not yet analyzed, shaped, or laid out, the Wasm path still emits an -empty Rust plan. Rust shaping/layout → nonempty plan connection and its 25,515-glyph end-to-end timing remain open; the -TypeScript layout benchmark is baseline evidence only. +are retained and resolved into maximal derived segments. Retained text now drives transactional Unicode 17 +extended-grapheme segmentation and Script/Script_Extensions itemization in Rust, with malformed UTF-16 aborting the +same frame transaction. Bidi/run intersection, shaping, and layout are not yet connected, so the Wasm path still emits +an empty Rust plan. Rust shaping/layout → nonempty plan connection and its 25,515-glyph end-to-end timing remain open; +the TypeScript layout benchmark is baseline evidence only. The retained-text decoder, transaction buffers, and cold capacity control change the optimized artifact from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes, a shared-runtime delta of 2,829 / 1,528 / 616 bytes. The per-session 1,024-unit default is runtime memory rather than binary payload. This size checkpoint does not time shaping or layout because neither stage consumes the retained text yet. +The Unicode-analysis checkpoint uses `unicode-segmentation` 1.13.3 under `no_std`, generated Unicode 17 script tables +shared with the TypeScript generator, and flat reusable session arrays. Session text reservation prewarms both active +and pending analysis arenas; unchanged text does not re-run analysis. The compact Rust script partitions omit +derivable starts. Optimized Wasm measures 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes versus the prior +895,593 / 335,396 / 264,355 checkpoint. This is shared runtime data and code, not per-font shaping payload. The number +does not claim layout or shaping latency because neither has consumed these products yet. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/Cargo.lock b/packages/text/rust/shaper/Cargo.lock index 65171a09..ded0955d 100644 --- a/packages/text/rust/shaper/Cargo.lock +++ b/packages/text/rust/shaper/Cargo.lock @@ -149,6 +149,7 @@ dependencies = [ "serde_json", "talc", "unicode-bidi", + "unicode-segmentation", ] [[package]] @@ -285,6 +286,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "zmij" version = "1.0.23" diff --git a/packages/text/rust/shaper/Cargo.toml b/packages/text/rust/shaper/Cargo.toml index b4c780d8..cb1d63fd 100644 --- a/packages/text/rust/shaper/Cargo.toml +++ b/packages/text/rust/shaper/Cargo.toml @@ -26,6 +26,7 @@ harfrust = { version = "0.12.0", default-features = false, features = ["libm"] } read-fonts = { version = "0.41.0", default-features = false, features = ["libm"] } serde_json = { version = "1.0.145", default-features = false, features = ["alloc"] } unicode-bidi = { version = "0.3.18", default-features = false } +unicode-segmentation = { version = "=1.13.3", default-features = false, features = ["no_std"] } [build-dependencies] serde_json = "1.0.145" diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index b2f1b60a..c2bd3286 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1,5 +1,7 @@ use alloc::{collections::BTreeMap, vec::Vec}; +use crate::unicode::{UnicodeAnalysis, UnicodeError}; + use super::{ font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, @@ -60,11 +62,14 @@ struct EngineSession { pending_styles: StyleArena, resolved_styles: ResolvedStyleArena, pending_resolved_styles: ResolvedStyleArena, + unicode: UnicodeAnalysis, + pending_unicode: UnicodeAnalysis, style_mutation_scratch: Vec, style_order_scratch: Vec, style_nesting_scratch: Vec, style_resolution_scratch: Vec, styles_prepared: bool, + unicode_prepared: bool, geometry_fingerprint: u64, pending_geometry_fingerprint: u64, geometry_prepared: bool, @@ -286,6 +291,11 @@ impl TextEngine { .ok_or(EngineError::SessionMissing)?; reserve_text_buffer(&mut session.text, capacity)?; reserve_text_buffer(&mut session.pending_text, capacity)?; + session.unicode.reserve(capacity).map_err(unicode_error)?; + session + .pending_unicode + .reserve(capacity) + .map_err(unicode_error)?; Ok(()) } @@ -390,9 +400,15 @@ impl TextEngine { session.abort_text(); return Err(error); } + if let Err(error) = session.prepare_unicode() { + session.abort_text(); + session.abort_styles(); + return Err(error); + } if let Err(error) = session.prepare_geometry(request.geometry) { session.abort_text(); session.abort_styles(); + session.abort_unicode(); return Err(error); } if let Err(error) = gather.gather( @@ -412,6 +428,7 @@ impl TextEngine { ) { session.abort_text(); session.abort_styles(); + session.abort_unicode(); session.abort_geometry(); return Err(gather_error(error)); } @@ -426,6 +443,7 @@ impl TextEngine { ) { session.abort_text(); session.abort_styles(); + session.abort_unicode(); session.abort_geometry(); return Err(plan_error(error)); } @@ -473,6 +491,7 @@ impl TextEngine { session.plan.abort(); session.abort_text(); session.abort_styles(); + session.abort_unicode(); session.abort_geometry(); Ok(()) } @@ -491,6 +510,7 @@ impl TextEngine { session.plan.commit().map_err(plan_error)?; session.commit_text(); session.commit_styles(); + session.commit_unicode(); session.commit_geometry(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, @@ -618,6 +638,29 @@ impl EngineSession { self.abort_text(); } + fn prepare_unicode(&mut self) -> Result<(), EngineError> { + self.abort_unicode(); + if !self.text_prepared { + return Ok(()); + } + self.pending_unicode + .analyze(&self.pending_text) + .map_err(unicode_error)?; + self.unicode_prepared = true; + Ok(()) + } + + fn abort_unicode(&mut self) { + self.unicode_prepared = false; + } + + fn commit_unicode(&mut self) { + if self.unicode_prepared { + core::mem::swap(&mut self.unicode, &mut self.pending_unicode); + } + self.abort_unicode(); + } + fn prepare_geometry( &mut self, geometry: super::semantic_wire::GeometryBatch<'_>, @@ -697,6 +740,13 @@ fn reserve_text_buffer(text: &mut Vec, capacity: usize) -> Result<(), Engin Ok(()) } +fn unicode_error(error: UnicodeError) -> EngineError { + match error { + UnicodeError::InvalidUtf16 => EngineError::InvalidRequest, + UnicodeError::ResultTooLarge => EngineError::ResultTooLarge, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TextMutationError { Invalid, @@ -965,6 +1015,15 @@ mod tests { assert!(engine.session_text(4).unwrap().is_empty()); engine.commit_update(prepared).unwrap(); assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .unicode + .grapheme_boundaries(), + &[0, 1, 2, 3, 4] + ); let edit_bytes = text_mutation_bytes(&[(1, 2, &[0x58, 0x59]), (4, 0, &[0x21])]); let edit_batch = @@ -1000,6 +1059,27 @@ mod tests { ); } + #[test] + fn invalid_utf16_aborts_text_and_unicode_analysis_together() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + + let invalid_bytes = text_mutation_bytes(&[(0, 0, &[0xd800])]); + let mut invalid = update(0, 0, 0); + invalid.text_mutations = + parse_text_mutations(&invalid_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + assert_eq!( + engine.prepare_update(invalid, 1), + Err(EngineError::InvalidRequest) + ); + let session = engine.sessions.get(&4).unwrap(); + assert!(session.text.is_empty()); + assert!(session.unicode.grapheme_boundaries().is_empty()); + } + #[test] fn retained_style_upserts_commit_and_root_removal_aborts_transactionally() { let mut engine = TextEngine::default(); diff --git a/packages/text/rust/shaper/src/generated/script_data.rs b/packages/text/rust/shaper/src/generated/script_data.rs new file mode 100644 index 00000000..711773f4 --- /dev/null +++ b/packages/text/rust/shaper/src/generated/script_data.rs @@ -0,0 +1,360 @@ +// Generated by scripts/generate-unicode-script-data.mjs from +// @unicode/unicode-17.0.0@1.6.17 and unicode-property-value-aliases@3.9.0. +// Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const COMMON_SCRIPT: u32 = 1517910393; +pub const INHERITED_SCRIPT: u32 = 1516858984; +pub const UNKNOWN_SCRIPT: u32 = 1517976186; + +pub static SCRIPT_END_VALUES: &[u32] = &[ + 65, 1517910393, 91, 1281455214, 97, 1517910393, 123, 1281455214, 170, 1517910393, 171, 1281455214, 186, 1517910393, 187, 1281455214, 192, 1517910393, 215, 1281455214, 216, 1517910393, 247, 1281455214, + 248, 1517910393, 697, 1281455214, 736, 1517910393, 741, 1281455214, 746, 1517910393, 748, 1114599535, 768, 1517910393, 880, 1516858984, 884, 1198679403, 885, 1517910393, 888, 1198679403, 890, 1517976186, + 894, 1198679403, 895, 1517910393, 896, 1198679403, 900, 1517976186, 901, 1198679403, 902, 1517910393, 903, 1198679403, 904, 1517910393, 907, 1198679403, 908, 1517976186, 909, 1198679403, 910, 1517976186, + 930, 1198679403, 931, 1517976186, 994, 1198679403, 1008, 1131376756, 1024, 1198679403, 1157, 1132032620, 1159, 1516858984, 1328, 1132032620, 1329, 1517976186, 1367, 1098018158, 1369, 1517976186, 1419, 1098018158, + 1421, 1517976186, 1424, 1098018158, 1425, 1517976186, 1480, 1214603890, 1488, 1517976186, 1515, 1214603890, 1519, 1517976186, 1525, 1214603890, 1536, 1517976186, 1541, 1098015074, 1542, 1517910393, 1548, 1098015074, + 1549, 1517910393, 1563, 1098015074, 1564, 1517910393, 1567, 1098015074, 1568, 1517910393, 1600, 1098015074, 1601, 1517910393, 1611, 1098015074, 1622, 1516858984, 1648, 1098015074, 1649, 1516858984, 1757, 1098015074, + 1758, 1517910393, 1792, 1098015074, 1806, 1400468067, 1807, 1517976186, 1867, 1400468067, 1869, 1517976186, 1872, 1400468067, 1920, 1098015074, 1970, 1416126817, 1984, 1517976186, 2043, 1315663727, 2045, 1517976186, + 2048, 1315663727, 2094, 1398893938, 2096, 1517976186, 2111, 1398893938, 2112, 1517976186, 2140, 1298230884, 2142, 1517976186, 2143, 1298230884, 2144, 1517976186, 2155, 1400468067, 2160, 1517976186, 2194, 1098015074, + 2199, 1517976186, 2274, 1098015074, 2275, 1517910393, 2304, 1098015074, 2385, 1147500129, 2389, 1516858984, 2404, 1147500129, 2406, 1517910393, 2432, 1147500129, 2436, 1113943655, 2437, 1517976186, 2445, 1113943655, + 2447, 1517976186, 2449, 1113943655, 2451, 1517976186, 2473, 1113943655, 2474, 1517976186, 2481, 1113943655, 2482, 1517976186, 2483, 1113943655, 2486, 1517976186, 2490, 1113943655, 2492, 1517976186, 2501, 1113943655, + 2503, 1517976186, 2505, 1113943655, 2507, 1517976186, 2511, 1113943655, 2519, 1517976186, 2520, 1113943655, 2524, 1517976186, 2526, 1113943655, 2527, 1517976186, 2532, 1113943655, 2534, 1517976186, 2559, 1113943655, + 2561, 1517976186, 2564, 1198879349, 2565, 1517976186, 2571, 1198879349, 2575, 1517976186, 2577, 1198879349, 2579, 1517976186, 2601, 1198879349, 2602, 1517976186, 2609, 1198879349, 2610, 1517976186, 2612, 1198879349, + 2613, 1517976186, 2615, 1198879349, 2616, 1517976186, 2618, 1198879349, 2620, 1517976186, 2621, 1198879349, 2622, 1517976186, 2627, 1198879349, 2631, 1517976186, 2633, 1198879349, 2635, 1517976186, 2638, 1198879349, + 2641, 1517976186, 2642, 1198879349, 2649, 1517976186, 2653, 1198879349, 2654, 1517976186, 2655, 1198879349, 2662, 1517976186, 2679, 1198879349, 2689, 1517976186, 2692, 1198877298, 2693, 1517976186, 2702, 1198877298, + 2703, 1517976186, 2706, 1198877298, 2707, 1517976186, 2729, 1198877298, 2730, 1517976186, 2737, 1198877298, 2738, 1517976186, 2740, 1198877298, 2741, 1517976186, 2746, 1198877298, 2748, 1517976186, 2758, 1198877298, + 2759, 1517976186, 2762, 1198877298, 2763, 1517976186, 2766, 1198877298, 2768, 1517976186, 2769, 1198877298, 2784, 1517976186, 2788, 1198877298, 2790, 1517976186, 2802, 1198877298, 2809, 1517976186, 2816, 1198877298, + 2817, 1517976186, 2820, 1332902241, 2821, 1517976186, 2829, 1332902241, 2831, 1517976186, 2833, 1332902241, 2835, 1517976186, 2857, 1332902241, 2858, 1517976186, 2865, 1332902241, 2866, 1517976186, 2868, 1332902241, + 2869, 1517976186, 2874, 1332902241, 2876, 1517976186, 2885, 1332902241, 2887, 1517976186, 2889, 1332902241, 2891, 1517976186, 2894, 1332902241, 2901, 1517976186, 2904, 1332902241, 2908, 1517976186, 2910, 1332902241, + 2911, 1517976186, 2916, 1332902241, 2918, 1517976186, 2936, 1332902241, 2946, 1517976186, 2948, 1415671148, 2949, 1517976186, 2955, 1415671148, 2958, 1517976186, 2961, 1415671148, 2962, 1517976186, 2966, 1415671148, + 2969, 1517976186, 2971, 1415671148, 2972, 1517976186, 2973, 1415671148, 2974, 1517976186, 2976, 1415671148, 2979, 1517976186, 2981, 1415671148, 2984, 1517976186, 2987, 1415671148, 2990, 1517976186, 3002, 1415671148, + 3006, 1517976186, 3011, 1415671148, 3014, 1517976186, 3017, 1415671148, 3018, 1517976186, 3022, 1415671148, 3024, 1517976186, 3025, 1415671148, 3031, 1517976186, 3032, 1415671148, 3046, 1517976186, 3067, 1415671148, + 3072, 1517976186, 3085, 1415933045, 3086, 1517976186, 3089, 1415933045, 3090, 1517976186, 3113, 1415933045, 3114, 1517976186, 3130, 1415933045, 3132, 1517976186, 3141, 1415933045, 3142, 1517976186, 3145, 1415933045, + 3146, 1517976186, 3150, 1415933045, 3157, 1517976186, 3159, 1415933045, 3160, 1517976186, 3163, 1415933045, 3164, 1517976186, 3166, 1415933045, 3168, 1517976186, 3172, 1415933045, 3174, 1517976186, 3184, 1415933045, + 3191, 1517976186, 3200, 1415933045, 3213, 1265525857, 3214, 1517976186, 3217, 1265525857, 3218, 1517976186, 3241, 1265525857, 3242, 1517976186, 3252, 1265525857, 3253, 1517976186, 3258, 1265525857, 3260, 1517976186, + 3269, 1265525857, 3270, 1517976186, 3273, 1265525857, 3274, 1517976186, 3278, 1265525857, 3285, 1517976186, 3287, 1265525857, 3292, 1517976186, 3295, 1265525857, 3296, 1517976186, 3300, 1265525857, 3302, 1517976186, + 3312, 1265525857, 3313, 1517976186, 3316, 1265525857, 3328, 1517976186, 3341, 1298954605, 3342, 1517976186, 3345, 1298954605, 3346, 1517976186, 3397, 1298954605, 3398, 1517976186, 3401, 1298954605, 3402, 1517976186, + 3408, 1298954605, 3412, 1517976186, 3428, 1298954605, 3430, 1517976186, 3456, 1298954605, 3457, 1517976186, 3460, 1399418472, 3461, 1517976186, 3479, 1399418472, 3482, 1517976186, 3506, 1399418472, 3507, 1517976186, + 3516, 1399418472, 3517, 1517976186, 3518, 1399418472, 3520, 1517976186, 3527, 1399418472, 3530, 1517976186, 3531, 1399418472, 3535, 1517976186, 3541, 1399418472, 3542, 1517976186, 3543, 1399418472, 3544, 1517976186, + 3552, 1399418472, 3558, 1517976186, 3568, 1399418472, 3570, 1517976186, 3573, 1399418472, 3585, 1517976186, 3643, 1416126825, 3647, 1517976186, 3648, 1517910393, 3676, 1416126825, 3713, 1517976186, 3715, 1281453935, + 3716, 1517976186, 3717, 1281453935, 3718, 1517976186, 3723, 1281453935, 3724, 1517976186, 3748, 1281453935, 3749, 1517976186, 3750, 1281453935, 3751, 1517976186, 3774, 1281453935, 3776, 1517976186, 3781, 1281453935, + 3782, 1517976186, 3783, 1281453935, 3784, 1517976186, 3791, 1281453935, 3792, 1517976186, 3802, 1281453935, 3804, 1517976186, 3808, 1281453935, 3840, 1517976186, 3912, 1416192628, 3913, 1517976186, 3949, 1416192628, + 3953, 1517976186, 3992, 1416192628, 3993, 1517976186, 4029, 1416192628, 4030, 1517976186, 4045, 1416192628, 4046, 1517976186, 4053, 1416192628, 4057, 1517910393, 4059, 1416192628, 4096, 1517976186, 4256, 1299803506, + 4294, 1197830002, 4295, 1517976186, 4296, 1197830002, 4301, 1517976186, 4302, 1197830002, 4304, 1517976186, 4347, 1197830002, 4348, 1517910393, 4352, 1197830002, 4608, 1214344807, 4681, 1165256809, 4682, 1517976186, + 4686, 1165256809, 4688, 1517976186, 4695, 1165256809, 4696, 1517976186, 4697, 1165256809, 4698, 1517976186, 4702, 1165256809, 4704, 1517976186, 4745, 1165256809, 4746, 1517976186, 4750, 1165256809, 4752, 1517976186, + 4785, 1165256809, 4786, 1517976186, 4790, 1165256809, 4792, 1517976186, 4799, 1165256809, 4800, 1517976186, 4801, 1165256809, 4802, 1517976186, 4806, 1165256809, 4808, 1517976186, 4823, 1165256809, 4824, 1517976186, + 4881, 1165256809, 4882, 1517976186, 4886, 1165256809, 4888, 1517976186, 4955, 1165256809, 4957, 1517976186, 4989, 1165256809, 4992, 1517976186, 5018, 1165256809, 5024, 1517976186, 5110, 1130915186, 5112, 1517976186, + 5118, 1130915186, 5120, 1517976186, 5760, 1130458739, 5789, 1332175213, 5792, 1517976186, 5867, 1383427698, 5870, 1517910393, 5881, 1383427698, 5888, 1517976186, 5910, 1416064103, 5919, 1517976186, 5920, 1416064103, + 5941, 1214344815, 5943, 1517910393, 5952, 1517976186, 5972, 1114990692, 5984, 1517976186, 5997, 1415669602, 5998, 1517976186, 6001, 1415669602, 6002, 1517976186, 6004, 1415669602, 6016, 1517976186, 6110, 1265134962, + 6112, 1517976186, 6122, 1265134962, 6128, 1517976186, 6138, 1265134962, 6144, 1517976186, 6146, 1299148391, 6148, 1517910393, 6149, 1299148391, 6150, 1517910393, 6170, 1299148391, 6176, 1517976186, 6265, 1299148391, + 6272, 1517976186, 6315, 1299148391, 6320, 1517976186, 6390, 1130458739, 6400, 1517976186, 6431, 1281977698, 6432, 1517976186, 6444, 1281977698, 6448, 1517976186, 6460, 1281977698, 6464, 1517976186, 6465, 1281977698, + 6468, 1517976186, 6480, 1281977698, 6510, 1415670885, 6512, 1517976186, 6517, 1415670885, 6528, 1517976186, 6572, 1415670901, 6576, 1517976186, 6602, 1415670901, 6608, 1517976186, 6619, 1415670901, 6622, 1517976186, + 6624, 1415670901, 6656, 1265134962, 6684, 1114990441, 6686, 1517976186, 6688, 1114990441, 6751, 1281453665, 6752, 1517976186, 6781, 1281453665, 6783, 1517976186, 6794, 1281453665, 6800, 1517976186, 6810, 1281453665, + 6816, 1517976186, 6830, 1281453665, 6832, 1517976186, 6878, 1516858984, 6880, 1517976186, 6892, 1516858984, 6912, 1517976186, 6989, 1113681001, 6990, 1517976186, 7040, 1113681001, 7104, 1400204900, 7156, 1113683051, + 7164, 1517976186, 7168, 1113683051, 7224, 1281716323, 7227, 1517976186, 7242, 1281716323, 7245, 1517976186, 7248, 1281716323, 7296, 1332503403, 7307, 1132032620, 7312, 1517976186, 7355, 1197830002, 7357, 1517976186, + 7360, 1197830002, 7368, 1400204900, 7376, 1517976186, 7379, 1516858984, 7380, 1517910393, 7393, 1516858984, 7394, 1517910393, 7401, 1516858984, 7405, 1517910393, 7406, 1516858984, 7412, 1517910393, 7413, 1516858984, + 7416, 1517910393, 7418, 1516858984, 7419, 1517910393, 7424, 1517976186, 7462, 1281455214, 7467, 1198679403, 7468, 1132032620, 7517, 1281455214, 7522, 1198679403, 7526, 1281455214, 7531, 1198679403, 7544, 1281455214, + 7545, 1132032620, 7615, 1281455214, 7616, 1198679403, 7680, 1516858984, 7936, 1281455214, 7958, 1198679403, 7960, 1517976186, 7966, 1198679403, 7968, 1517976186, 8006, 1198679403, 8008, 1517976186, 8014, 1198679403, + 8016, 1517976186, 8024, 1198679403, 8025, 1517976186, 8026, 1198679403, 8027, 1517976186, 8028, 1198679403, 8029, 1517976186, 8030, 1198679403, 8031, 1517976186, 8062, 1198679403, 8064, 1517976186, 8117, 1198679403, + 8118, 1517976186, 8133, 1198679403, 8134, 1517976186, 8148, 1198679403, 8150, 1517976186, 8156, 1198679403, 8157, 1517976186, 8176, 1198679403, 8178, 1517976186, 8181, 1198679403, 8182, 1517976186, 8191, 1198679403, + 8192, 1517976186, 8204, 1517910393, 8206, 1516858984, 8293, 1517910393, 8294, 1517976186, 8305, 1517910393, 8306, 1281455214, 8308, 1517976186, 8319, 1517910393, 8320, 1281455214, 8335, 1517910393, 8336, 1517976186, + 8349, 1281455214, 8352, 1517976186, 8386, 1517910393, 8400, 1517976186, 8433, 1516858984, 8448, 1517976186, 8486, 1517910393, 8487, 1198679403, 8490, 1517910393, 8492, 1281455214, 8498, 1517910393, 8499, 1281455214, + 8526, 1517910393, 8527, 1281455214, 8544, 1517910393, 8585, 1281455214, 8588, 1517910393, 8592, 1517976186, 9258, 1517910393, 9280, 1517976186, 9291, 1517910393, 9312, 1517976186, 10240, 1517910393, 10496, 1114792297, + 11124, 1517910393, 11126, 1517976186, 11264, 1517910393, 11360, 1198285159, 11392, 1281455214, 11508, 1131376756, 11513, 1517976186, 11520, 1131376756, 11558, 1197830002, 11559, 1517976186, 11560, 1197830002, 11565, 1517976186, + 11566, 1197830002, 11568, 1517976186, 11624, 1415999079, 11631, 1517976186, 11633, 1415999079, 11647, 1517976186, 11648, 1415999079, 11671, 1165256809, 11680, 1517976186, 11687, 1165256809, 11688, 1517976186, 11695, 1165256809, + 11696, 1517976186, 11703, 1165256809, 11704, 1517976186, 11711, 1165256809, 11712, 1517976186, 11719, 1165256809, 11720, 1517976186, 11727, 1165256809, 11728, 1517976186, 11735, 1165256809, 11736, 1517976186, 11743, 1165256809, + 11744, 1517976186, 11776, 1132032620, 11870, 1517910393, 11904, 1517976186, 11930, 1214344809, 11931, 1517976186, 12020, 1214344809, 12032, 1517976186, 12246, 1214344809, 12272, 1517976186, 12293, 1517910393, 12294, 1214344809, + 12295, 1517910393, 12296, 1214344809, 12321, 1517910393, 12330, 1214344809, 12334, 1516858984, 12336, 1214344807, 12344, 1517910393, 12348, 1214344809, 12352, 1517910393, 12353, 1517976186, 12439, 1214870113, 12441, 1517976186, + 12443, 1516858984, 12445, 1517910393, 12448, 1214870113, 12449, 1517910393, 12539, 1264676449, 12541, 1517910393, 12544, 1264676449, 12549, 1517976186, 12592, 1114599535, 12593, 1517976186, 12687, 1214344807, 12688, 1517976186, + 12704, 1517910393, 12736, 1114599535, 12774, 1517910393, 12783, 1517976186, 12784, 1517910393, 12800, 1264676449, 12831, 1214344807, 12832, 1517976186, 12896, 1517910393, 12927, 1214344807, 13008, 1517910393, 13055, 1264676449, + 13056, 1517910393, 13144, 1264676449, 13312, 1517910393, 19904, 1214344809, 19968, 1517910393, 40960, 1214344809, 42125, 1500080489, 42128, 1517976186, 42183, 1500080489, 42192, 1517976186, 42240, 1281979253, 42540, 1449224553, + 42560, 1517976186, 42656, 1132032620, 42744, 1113681269, 42752, 1517976186, 42786, 1517910393, 42888, 1281455214, 42891, 1517910393, 42973, 1281455214, 42993, 1517976186, 43008, 1281455214, 43053, 1400466543, 43056, 1517976186, + 43066, 1517910393, 43072, 1517976186, 43128, 1349017959, 43136, 1517976186, 43206, 1398895986, 43214, 1517976186, 43226, 1398895986, 43232, 1517976186, 43264, 1147500129, 43310, 1264675945, 43311, 1517910393, 43312, 1264675945, + 43348, 1382706791, 43359, 1517976186, 43360, 1382706791, 43389, 1214344807, 43392, 1517976186, 43470, 1247901281, 43471, 1517976186, 43472, 1517910393, 43482, 1247901281, 43486, 1517976186, 43488, 1247901281, 43519, 1299803506, + 43520, 1517976186, 43575, 1130914157, 43584, 1517976186, 43598, 1130914157, 43600, 1517976186, 43610, 1130914157, 43612, 1517976186, 43616, 1130914157, 43648, 1299803506, 43715, 1415673460, 43739, 1517976186, 43744, 1415673460, + 43767, 1299473769, 43777, 1517976186, 43783, 1165256809, 43785, 1517976186, 43791, 1165256809, 43793, 1517976186, 43799, 1165256809, 43808, 1517976186, 43815, 1165256809, 43816, 1517976186, 43823, 1165256809, 43824, 1517976186, + 43867, 1281455214, 43868, 1517910393, 43877, 1281455214, 43878, 1198679403, 43882, 1281455214, 43884, 1517910393, 43888, 1517976186, 43968, 1130915186, 44014, 1299473769, 44016, 1517976186, 44026, 1299473769, 44032, 1517976186, + 55204, 1214344807, 55216, 1517976186, 55239, 1214344807, 55243, 1517976186, 55292, 1214344807, 63744, 1517976186, 64110, 1214344809, 64112, 1517976186, 64218, 1214344809, 64256, 1517976186, 64263, 1281455214, 64275, 1517976186, + 64280, 1098018158, 64285, 1517976186, 64311, 1214603890, 64312, 1517976186, 64317, 1214603890, 64318, 1517976186, 64319, 1214603890, 64320, 1517976186, 64322, 1214603890, 64323, 1517976186, 64325, 1214603890, 64326, 1517976186, + 64336, 1214603890, 64830, 1098015074, 64832, 1517910393, 64976, 1098015074, 65008, 1517976186, 65024, 1098015074, 65040, 1516858984, 65050, 1517910393, 65056, 1517976186, 65070, 1516858984, 65072, 1132032620, 65107, 1517910393, + 65108, 1517976186, 65127, 1517910393, 65128, 1517976186, 65132, 1517910393, 65136, 1517976186, 65141, 1098015074, 65142, 1517976186, 65277, 1098015074, 65279, 1517976186, 65280, 1517910393, 65281, 1517976186, 65313, 1517910393, + 65339, 1281455214, 65345, 1517910393, 65371, 1281455214, 65382, 1517910393, 65392, 1264676449, 65393, 1517910393, 65438, 1264676449, 65440, 1517910393, 65471, 1214344807, 65474, 1517976186, 65480, 1214344807, 65482, 1517976186, + 65488, 1214344807, 65490, 1517976186, 65496, 1214344807, 65498, 1517976186, 65501, 1214344807, 65504, 1517976186, 65511, 1517910393, 65512, 1517976186, 65519, 1517910393, 65529, 1517976186, 65534, 1517910393, 65536, 1517976186, + 65548, 1281977954, 65549, 1517976186, 65575, 1281977954, 65576, 1517976186, 65595, 1281977954, 65596, 1517976186, 65598, 1281977954, 65599, 1517976186, 65614, 1281977954, 65616, 1517976186, 65630, 1281977954, 65664, 1517976186, + 65787, 1281977954, 65792, 1517976186, 65795, 1517910393, 65799, 1517976186, 65844, 1517910393, 65847, 1517976186, 65856, 1517910393, 65935, 1198679403, 65936, 1517976186, 65949, 1517910393, 65952, 1517976186, 65953, 1198679403, + 66000, 1517976186, 66045, 1517910393, 66046, 1516858984, 66176, 1517976186, 66205, 1283023721, 66208, 1517976186, 66257, 1130459753, 66272, 1517976186, 66273, 1516858984, 66300, 1517910393, 66304, 1517976186, 66340, 1232363884, + 66349, 1517976186, 66352, 1232363884, 66379, 1198486632, 66384, 1517976186, 66427, 1348825709, 66432, 1517976186, 66462, 1432838514, 66463, 1517976186, 66464, 1432838514, 66500, 1483761007, 66504, 1517976186, 66518, 1483761007, + 66560, 1517976186, 66640, 1148416628, 66688, 1399349623, 66718, 1332964705, 66720, 1517976186, 66730, 1332964705, 66736, 1517976186, 66772, 1332963173, 66776, 1517976186, 66812, 1332963173, 66816, 1517976186, 66856, 1164730977, + 66864, 1517976186, 66916, 1097295970, 66927, 1517976186, 66928, 1097295970, 66939, 1449751656, 66940, 1517976186, 66955, 1449751656, 66956, 1517976186, 66963, 1449751656, 66964, 1517976186, 66966, 1449751656, 66967, 1517976186, + 66978, 1449751656, 66979, 1517976186, 66994, 1449751656, 66995, 1517976186, 67002, 1449751656, 67003, 1517976186, 67005, 1449751656, 67008, 1517976186, 67060, 1416586354, 67072, 1517976186, 67383, 1281977953, 67392, 1517976186, + 67414, 1281977953, 67424, 1517976186, 67432, 1281977953, 67456, 1517976186, 67462, 1281455214, 67463, 1517976186, 67505, 1281455214, 67506, 1517976186, 67515, 1281455214, 67584, 1517976186, 67590, 1131442804, 67592, 1517976186, + 67593, 1131442804, 67594, 1517976186, 67638, 1131442804, 67639, 1517976186, 67641, 1131442804, 67644, 1517976186, 67645, 1131442804, 67647, 1517976186, 67648, 1131442804, 67670, 1098018153, 67671, 1517976186, 67680, 1098018153, + 67712, 1348562029, 67743, 1315070324, 67751, 1517976186, 67760, 1315070324, 67808, 1517976186, 67827, 1214346354, 67828, 1517976186, 67830, 1214346354, 67835, 1517976186, 67840, 1214346354, 67868, 1349021304, 67871, 1517976186, + 67872, 1349021304, 67898, 1283023977, 67903, 1517976186, 67904, 1283023977, 67930, 1399415924, 67968, 1517976186, 68000, 1298494063, 68024, 1298494051, 68028, 1517976186, 68048, 1298494051, 68050, 1517976186, 68096, 1298494051, + 68100, 1265131890, 68101, 1517976186, 68103, 1265131890, 68108, 1517976186, 68116, 1265131890, 68117, 1517976186, 68120, 1265131890, 68121, 1517976186, 68150, 1265131890, 68152, 1517976186, 68155, 1265131890, 68159, 1517976186, + 68169, 1265131890, 68176, 1517976186, 68185, 1265131890, 68192, 1517976186, 68224, 1398895202, 68256, 1315009122, 68288, 1517976186, 68327, 1298230889, 68331, 1517976186, 68343, 1298230889, 68352, 1517976186, 68406, 1098281844, + 68409, 1517976186, 68416, 1098281844, 68438, 1349678185, 68440, 1517976186, 68448, 1349678185, 68467, 1349020777, 68472, 1517976186, 68480, 1349020777, 68498, 1349020784, 68505, 1517976186, 68509, 1349020784, 68521, 1517976186, + 68528, 1349020784, 68608, 1517976186, 68681, 1332898664, 68736, 1517976186, 68787, 1215655527, 68800, 1517976186, 68851, 1215655527, 68858, 1517976186, 68864, 1215655527, 68904, 1383032935, 68912, 1517976186, 68922, 1383032935, + 68928, 1517976186, 68966, 1197568609, 68969, 1517976186, 68998, 1197568609, 69006, 1517976186, 69008, 1197568609, 69216, 1517976186, 69247, 1098015074, 69248, 1517976186, 69290, 1499822697, 69291, 1517976186, 69294, 1499822697, + 69296, 1517976186, 69298, 1499822697, 69314, 1517976186, 69320, 1098015074, 69328, 1517976186, 69337, 1098015074, 69370, 1517976186, 69376, 1098015074, 69416, 1399809903, 69424, 1517976186, 69466, 1399809892, 69488, 1517976186, + 69514, 1333094258, 69552, 1517976186, 69580, 1130918515, 69600, 1517976186, 69623, 1164736877, 69632, 1517976186, 69710, 1114792296, 69714, 1517976186, 69750, 1114792296, 69759, 1517976186, 69760, 1114792296, 69827, 1265920105, + 69837, 1517976186, 69838, 1265920105, 69840, 1517976186, 69865, 1399812705, 69872, 1517976186, 69882, 1399812705, 69888, 1517976186, 69941, 1130457965, 69942, 1517976186, 69960, 1130457965, 69968, 1517976186, 70007, 1298229354, + 70016, 1517976186, 70112, 1399353956, 70113, 1517976186, 70133, 1399418472, 70144, 1517976186, 70162, 1265135466, 70163, 1517976186, 70210, 1265135466, 70272, 1517976186, 70279, 1299541108, 70280, 1517976186, 70281, 1299541108, + 70282, 1517976186, 70286, 1299541108, 70287, 1517976186, 70302, 1299541108, 70303, 1517976186, 70314, 1299541108, 70320, 1517976186, 70379, 1399418468, 70384, 1517976186, 70394, 1399418468, 70400, 1517976186, 70404, 1198678382, + 70405, 1517976186, 70413, 1198678382, 70415, 1517976186, 70417, 1198678382, 70419, 1517976186, 70441, 1198678382, 70442, 1517976186, 70449, 1198678382, 70450, 1517976186, 70452, 1198678382, 70453, 1517976186, 70458, 1198678382, + 70459, 1517976186, 70460, 1516858984, 70469, 1198678382, 70471, 1517976186, 70473, 1198678382, 70475, 1517976186, 70478, 1198678382, 70480, 1517976186, 70481, 1198678382, 70487, 1517976186, 70488, 1198678382, 70493, 1517976186, + 70500, 1198678382, 70502, 1517976186, 70509, 1198678382, 70512, 1517976186, 70517, 1198678382, 70528, 1517976186, 70538, 1416983655, 70539, 1517976186, 70540, 1416983655, 70542, 1517976186, 70543, 1416983655, 70544, 1517976186, + 70582, 1416983655, 70583, 1517976186, 70593, 1416983655, 70594, 1517976186, 70595, 1416983655, 70597, 1517976186, 70598, 1416983655, 70599, 1517976186, 70603, 1416983655, 70604, 1517976186, 70614, 1416983655, 70615, 1517976186, + 70617, 1416983655, 70625, 1517976186, 70627, 1416983655, 70656, 1517976186, 70748, 1315272545, 70749, 1517976186, 70754, 1315272545, 70784, 1517976186, 70856, 1416196712, 70864, 1517976186, 70874, 1416196712, 71040, 1517976186, + 71094, 1399415908, 71096, 1517976186, 71134, 1399415908, 71168, 1517976186, 71237, 1299145833, 71248, 1517976186, 71258, 1299145833, 71264, 1517976186, 71277, 1299148391, 71296, 1517976186, 71354, 1415670642, 71360, 1517976186, + 71370, 1415670642, 71376, 1517976186, 71396, 1299803506, 71424, 1517976186, 71451, 1097363309, 71453, 1517976186, 71468, 1097363309, 71472, 1517976186, 71495, 1097363309, 71680, 1517976186, 71740, 1148151666, 71840, 1517976186, + 71923, 1466004065, 71935, 1517976186, 71936, 1466004065, 71943, 1147756907, 71945, 1517976186, 71946, 1147756907, 71948, 1517976186, 71956, 1147756907, 71957, 1517976186, 71959, 1147756907, 71960, 1517976186, 71990, 1147756907, + 71991, 1517976186, 71993, 1147756907, 71995, 1517976186, 72007, 1147756907, 72016, 1517976186, 72026, 1147756907, 72096, 1517976186, 72104, 1315008100, 72106, 1517976186, 72152, 1315008100, 72154, 1517976186, 72165, 1315008100, + 72192, 1517976186, 72264, 1516334690, 72272, 1517976186, 72355, 1399814511, 72368, 1517976186, 72384, 1130458739, 72441, 1348564323, 72448, 1517976186, 72458, 1147500129, 72544, 1517976186, 72552, 1399353956, 72640, 1517976186, + 72674, 1400204917, 72688, 1517976186, 72698, 1400204917, 72704, 1517976186, 72713, 1114139507, 72714, 1517976186, 72759, 1114139507, 72760, 1517976186, 72774, 1114139507, 72784, 1517976186, 72813, 1114139507, 72816, 1517976186, + 72848, 1298231907, 72850, 1517976186, 72872, 1298231907, 72873, 1517976186, 72887, 1298231907, 72960, 1517976186, 72967, 1198485101, 72968, 1517976186, 72970, 1198485101, 72971, 1517976186, 73015, 1198485101, 73018, 1517976186, + 73019, 1198485101, 73020, 1517976186, 73022, 1198485101, 73023, 1517976186, 73032, 1198485101, 73040, 1517976186, 73050, 1198485101, 73056, 1517976186, 73062, 1198485095, 73063, 1517976186, 73065, 1198485095, 73066, 1517976186, + 73103, 1198485095, 73104, 1517976186, 73106, 1198485095, 73107, 1517976186, 73113, 1198485095, 73120, 1517976186, 73130, 1198485095, 73136, 1517976186, 73180, 1416588403, 73184, 1517976186, 73194, 1416588403, 73440, 1517976186, + 73465, 1298230113, 73472, 1517976186, 73489, 1264678761, 73490, 1517976186, 73531, 1264678761, 73534, 1517976186, 73563, 1264678761, 73648, 1517976186, 73649, 1281979253, 73664, 1517976186, 73714, 1415671148, 73727, 1517976186, + 73728, 1415671148, 74650, 1483961720, 74752, 1517976186, 74863, 1483961720, 74864, 1517976186, 74869, 1483961720, 74880, 1517976186, 75076, 1483961720, 77712, 1517976186, 77811, 1131441518, 77824, 1517976186, 78934, 1164409200, + 78944, 1517976186, 82939, 1164409200, 82944, 1517976186, 83527, 1215067511, 90368, 1517976186, 90426, 1198877544, 92160, 1517976186, 92729, 1113681269, 92736, 1517976186, 92767, 1299345263, 92768, 1517976186, 92778, 1299345263, + 92782, 1517976186, 92784, 1299345263, 92863, 1416524641, 92864, 1517976186, 92874, 1416524641, 92880, 1517976186, 92910, 1113682803, 92912, 1517976186, 92918, 1113682803, 92928, 1517976186, 92998, 1215131239, 93008, 1517976186, + 93018, 1215131239, 93019, 1517976186, 93026, 1215131239, 93027, 1517976186, 93048, 1215131239, 93053, 1517976186, 93072, 1215131239, 93504, 1517976186, 93562, 1265787241, 93760, 1517976186, 93851, 1298490470, 93856, 1517976186, + 93881, 1113944678, 93883, 1517976186, 93908, 1113944678, 93952, 1517976186, 94027, 1349284452, 94031, 1517976186, 94088, 1349284452, 94095, 1517976186, 94112, 1349284452, 94176, 1517976186, 94177, 1415671399, 94178, 1316186229, + 94180, 1214344809, 94181, 1265202291, 94192, 1517976186, 94199, 1214344809, 94208, 1517976186, 101120, 1415671399, 101590, 1265202291, 101631, 1517976186, 101632, 1265202291, 101663, 1415671399, 101760, 1517976186, 101875, 1415671399, + 110576, 1517976186, 110580, 1264676449, 110581, 1517976186, 110588, 1264676449, 110589, 1517976186, 110591, 1264676449, 110592, 1517976186, 110593, 1264676449, 110880, 1214870113, 110883, 1264676449, 110898, 1517976186, 110899, 1214870113, + 110928, 1517976186, 110931, 1214870113, 110933, 1517976186, 110934, 1264676449, 110948, 1517976186, 110952, 1264676449, 110960, 1517976186, 111356, 1316186229, 113664, 1517976186, 113771, 1148547180, 113776, 1517976186, 113789, 1148547180, + 113792, 1517976186, 113801, 1148547180, 113808, 1517976186, 113818, 1148547180, 113820, 1517976186, 113824, 1148547180, 113828, 1517910393, 117760, 1517976186, 118013, 1517910393, 118016, 1517976186, 118452, 1517910393, 118458, 1517976186, + 118481, 1517910393, 118496, 1517976186, 118513, 1517910393, 118528, 1517976186, 118574, 1516858984, 118576, 1517976186, 118599, 1516858984, 118608, 1517976186, 118724, 1517910393, 118784, 1517976186, 119030, 1517910393, 119040, 1517976186, + 119079, 1517910393, 119081, 1517976186, 119143, 1517910393, 119146, 1516858984, 119163, 1517910393, 119171, 1516858984, 119173, 1517910393, 119180, 1516858984, 119210, 1517910393, 119214, 1516858984, 119275, 1517910393, 119296, 1517976186, + 119366, 1198679403, 119488, 1517976186, 119508, 1517910393, 119520, 1517976186, 119540, 1517910393, 119552, 1517976186, 119639, 1517910393, 119648, 1517976186, 119673, 1517910393, 119808, 1517976186, 119893, 1517910393, 119894, 1517976186, + 119965, 1517910393, 119966, 1517976186, 119968, 1517910393, 119970, 1517976186, 119971, 1517910393, 119973, 1517976186, 119975, 1517910393, 119977, 1517976186, 119981, 1517910393, 119982, 1517976186, 119994, 1517910393, 119995, 1517976186, + 119996, 1517910393, 119997, 1517976186, 120004, 1517910393, 120005, 1517976186, 120070, 1517910393, 120071, 1517976186, 120075, 1517910393, 120077, 1517976186, 120085, 1517910393, 120086, 1517976186, 120093, 1517910393, 120094, 1517976186, + 120122, 1517910393, 120123, 1517976186, 120127, 1517910393, 120128, 1517976186, 120133, 1517910393, 120134, 1517976186, 120135, 1517910393, 120138, 1517976186, 120145, 1517910393, 120146, 1517976186, 120486, 1517910393, 120488, 1517976186, + 120780, 1517910393, 120782, 1517976186, 120832, 1517910393, 121484, 1399287415, 121499, 1517976186, 121504, 1399287415, 121505, 1517976186, 121520, 1399287415, 122624, 1517976186, 122655, 1281455214, 122661, 1517976186, 122667, 1281455214, + 122880, 1517976186, 122887, 1198285159, 122888, 1517976186, 122905, 1198285159, 122907, 1517976186, 122914, 1198285159, 122915, 1517976186, 122917, 1198285159, 122918, 1517976186, 122923, 1198285159, 122928, 1517976186, 122990, 1132032620, + 123023, 1517976186, 123024, 1132032620, 123136, 1517976186, 123181, 1215131248, 123184, 1517976186, 123198, 1215131248, 123200, 1517976186, 123210, 1215131248, 123214, 1517976186, 123216, 1215131248, 123536, 1517976186, 123567, 1416590447, + 123584, 1517976186, 123642, 1466132591, 123647, 1517976186, 123648, 1466132591, 124112, 1517976186, 124154, 1315006317, 124368, 1517976186, 124411, 1332633967, 124415, 1517976186, 124416, 1332633967, 124608, 1517976186, 124639, 1415674223, + 124640, 1517976186, 124662, 1415674223, 124670, 1517976186, 124672, 1415674223, 124896, 1517976186, 124903, 1165256809, 124904, 1517976186, 124908, 1165256809, 124909, 1517976186, 124911, 1165256809, 124912, 1517976186, 124927, 1165256809, + 124928, 1517976186, 125125, 1298493028, 125127, 1517976186, 125143, 1298493028, 125184, 1517976186, 125260, 1097100397, 125264, 1517976186, 125274, 1097100397, 125278, 1517976186, 125280, 1097100397, 126065, 1517976186, 126133, 1517910393, + 126209, 1517976186, 126270, 1517910393, 126464, 1517976186, 126468, 1098015074, 126469, 1517976186, 126496, 1098015074, 126497, 1517976186, 126499, 1098015074, 126500, 1517976186, 126501, 1098015074, 126503, 1517976186, 126504, 1098015074, + 126505, 1517976186, 126515, 1098015074, 126516, 1517976186, 126520, 1098015074, 126521, 1517976186, 126522, 1098015074, 126523, 1517976186, 126524, 1098015074, 126530, 1517976186, 126531, 1098015074, 126535, 1517976186, 126536, 1098015074, + 126537, 1517976186, 126538, 1098015074, 126539, 1517976186, 126540, 1098015074, 126541, 1517976186, 126544, 1098015074, 126545, 1517976186, 126547, 1098015074, 126548, 1517976186, 126549, 1098015074, 126551, 1517976186, 126552, 1098015074, + 126553, 1517976186, 126554, 1098015074, 126555, 1517976186, 126556, 1098015074, 126557, 1517976186, 126558, 1098015074, 126559, 1517976186, 126560, 1098015074, 126561, 1517976186, 126563, 1098015074, 126564, 1517976186, 126565, 1098015074, + 126567, 1517976186, 126571, 1098015074, 126572, 1517976186, 126579, 1098015074, 126580, 1517976186, 126584, 1098015074, 126585, 1517976186, 126589, 1098015074, 126590, 1517976186, 126591, 1098015074, 126592, 1517976186, 126602, 1098015074, + 126603, 1517976186, 126620, 1098015074, 126625, 1517976186, 126628, 1098015074, 126629, 1517976186, 126634, 1098015074, 126635, 1517976186, 126652, 1098015074, 126704, 1517976186, 126706, 1098015074, 126976, 1517976186, 127020, 1517910393, + 127024, 1517976186, 127124, 1517910393, 127136, 1517976186, 127151, 1517910393, 127153, 1517976186, 127168, 1517910393, 127169, 1517976186, 127184, 1517910393, 127185, 1517976186, 127222, 1517910393, 127232, 1517976186, 127406, 1517910393, + 127462, 1517976186, 127488, 1517910393, 127489, 1214870113, 127491, 1517910393, 127504, 1517976186, 127548, 1517910393, 127552, 1517976186, 127561, 1517910393, 127568, 1517976186, 127570, 1517910393, 127584, 1517976186, 127590, 1517910393, + 127744, 1517976186, 128729, 1517910393, 128732, 1517976186, 128749, 1517910393, 128752, 1517976186, 128765, 1517910393, 128768, 1517976186, 128986, 1517910393, 128992, 1517976186, 129004, 1517910393, 129008, 1517976186, 129009, 1517910393, + 129024, 1517976186, 129036, 1517910393, 129040, 1517976186, 129096, 1517910393, 129104, 1517976186, 129114, 1517910393, 129120, 1517976186, 129160, 1517910393, 129168, 1517976186, 129198, 1517910393, 129200, 1517976186, 129212, 1517910393, + 129216, 1517976186, 129218, 1517910393, 129232, 1517976186, 129241, 1517910393, 129280, 1517976186, 129624, 1517910393, 129632, 1517976186, 129646, 1517910393, 129648, 1517976186, 129661, 1517910393, 129664, 1517976186, 129675, 1517910393, + 129678, 1517976186, 129735, 1517910393, 129736, 1517976186, 129737, 1517910393, 129741, 1517976186, 129757, 1517910393, 129759, 1517976186, 129771, 1517910393, 129775, 1517976186, 129785, 1517910393, 129792, 1517976186, 129939, 1517910393, + 129940, 1517976186, 130043, 1517910393, 131072, 1517976186, 173792, 1214344809, 173824, 1517976186, 178206, 1214344809, 178208, 1517976186, 183982, 1214344809, 183984, 1517976186, 191457, 1214344809, 191472, 1517976186, 192094, 1214344809, + 194560, 1517976186, 195102, 1214344809, 196608, 1517976186, 201547, 1214344809, 201552, 1517976186, 210042, 1214344809, 917505, 1517976186, 917506, 1517910393, 917536, 1517976186, 917632, 1517910393, 917760, 1517976186, 918000, 1516858984, + 1114112, 1517976186 +]; +pub static SCRIPT_EXTENSION_END_VALUES: &[u32] = &[ + 65, 0, 91, 1, 97, 0, 123, 1, 170, 0, 171, 1, 183, 0, 184, 2, 186, 0, 187, 1, 192, 0, 215, 1, + 216, 0, 247, 1, 248, 0, 697, 1, 700, 0, 701, 3, 711, 0, 712, 4, 713, 0, 716, 4, 717, 0, 718, 5, + 727, 0, 728, 6, 729, 0, 730, 4, 736, 0, 741, 1, 746, 0, 748, 7, 768, 0, 769, 8, 770, 9, 771, 10, + 772, 11, 773, 12, 774, 13, 775, 14, 776, 15, 777, 16, 778, 17, 779, 18, 780, 19, 781, 20, 782, 21, 783, 22, + 784, 23, 785, 21, 786, 24, 787, 23, 788, 25, 803, 23, 804, 26, 805, 27, 806, 28, 813, 23, 814, 29, 815, 28, + 816, 23, 817, 30, 818, 31, 834, 23, 835, 32, 837, 23, 838, 32, 856, 23, 857, 33, 862, 23, 863, 34, 867, 23, + 880, 1, 884, 32, 886, 35, 888, 32, 890, 36, 894, 32, 895, 0, 896, 32, 900, 36, 901, 32, 902, 0, 903, 32, + 904, 0, 907, 32, 908, 36, 909, 32, 910, 36, 930, 32, 931, 36, 994, 32, 1008, 37, 1024, 32, 1155, 38, 1156, 39, + 1157, 40, 1159, 41, 1160, 40, 1328, 38, 1329, 36, 1367, 42, 1369, 36, 1417, 42, 1418, 43, 1419, 42, 1421, 36, 1424, 42, + 1425, 36, 1480, 44, 1488, 36, 1515, 44, 1519, 36, 1525, 44, 1536, 36, 1541, 45, 1542, 0, 1548, 45, 1549, 46, 1563, 45, + 1564, 46, 1565, 47, 1567, 45, 1568, 48, 1600, 45, 1601, 49, 1611, 45, 1622, 50, 1632, 45, 1642, 51, 1648, 45, 1649, 50, + 1748, 45, 1749, 52, 1757, 45, 1758, 0, 1792, 45, 1806, 53, 1807, 36, 1867, 53, 1869, 36, 1872, 53, 1920, 45, 1970, 54, + 1984, 36, 2043, 55, 2045, 36, 2048, 55, 2094, 56, 2096, 36, 2111, 56, 2112, 36, 2140, 57, 2142, 36, 2143, 57, 2144, 36, + 2155, 53, 2160, 36, 2194, 45, 2199, 36, 2274, 45, 2275, 0, 2304, 45, 2385, 58, 2386, 59, 2387, 60, 2389, 23, 2404, 58, + 2405, 61, 2406, 62, 2416, 63, 2432, 58, 2436, 64, 2437, 36, 2445, 64, 2447, 36, 2449, 64, 2451, 36, 2473, 64, 2474, 36, + 2481, 64, 2482, 36, 2483, 64, 2486, 36, 2490, 64, 2492, 36, 2501, 64, 2503, 36, 2505, 64, 2507, 36, 2511, 64, 2519, 36, + 2520, 64, 2524, 36, 2526, 64, 2527, 36, 2532, 64, 2534, 36, 2544, 65, 2559, 64, 2561, 36, 2564, 66, 2565, 36, 2571, 66, + 2575, 36, 2577, 66, 2579, 36, 2601, 66, 2602, 36, 2609, 66, 2610, 36, 2612, 66, 2613, 36, 2615, 66, 2616, 36, 2618, 66, + 2620, 36, 2621, 66, 2622, 36, 2627, 66, 2631, 36, 2633, 66, 2635, 36, 2638, 66, 2641, 36, 2642, 66, 2649, 36, 2653, 66, + 2654, 36, 2655, 66, 2662, 36, 2672, 67, 2679, 66, 2689, 36, 2692, 68, 2693, 36, 2702, 68, 2703, 36, 2706, 68, 2707, 36, + 2729, 68, 2730, 36, 2737, 68, 2738, 36, 2740, 68, 2741, 36, 2746, 68, 2748, 36, 2758, 68, 2759, 36, 2762, 68, 2763, 36, + 2766, 68, 2768, 36, 2769, 68, 2784, 36, 2788, 68, 2790, 36, 2800, 69, 2802, 68, 2809, 36, 2816, 68, 2817, 36, 2820, 70, + 2821, 36, 2829, 70, 2831, 36, 2833, 70, 2835, 36, 2857, 70, 2858, 36, 2865, 70, 2866, 36, 2868, 70, 2869, 36, 2874, 70, + 2876, 36, 2885, 70, 2887, 36, 2889, 70, 2891, 36, 2894, 70, 2901, 36, 2904, 70, 2908, 36, 2910, 70, 2911, 36, 2916, 70, + 2918, 36, 2936, 70, 2946, 36, 2948, 71, 2949, 36, 2955, 71, 2958, 36, 2961, 71, 2962, 36, 2966, 71, 2969, 36, 2971, 71, + 2972, 36, 2973, 71, 2974, 36, 2976, 71, 2979, 36, 2981, 71, 2984, 36, 2987, 71, 2990, 36, 3002, 71, 3006, 36, 3011, 71, + 3014, 36, 3017, 71, 3018, 36, 3022, 71, 3024, 36, 3025, 71, 3031, 36, 3032, 71, 3046, 36, 3060, 72, 3067, 71, 3072, 36, + 3085, 73, 3086, 36, 3089, 73, 3090, 36, 3113, 73, 3114, 36, 3130, 73, 3132, 36, 3141, 73, 3142, 36, 3145, 73, 3146, 36, + 3150, 73, 3157, 36, 3159, 73, 3160, 36, 3163, 73, 3164, 36, 3166, 73, 3168, 36, 3172, 73, 3174, 36, 3184, 73, 3191, 36, + 3200, 73, 3213, 74, 3214, 36, 3217, 74, 3218, 36, 3241, 74, 3242, 36, 3252, 74, 3253, 36, 3258, 74, 3260, 36, 3269, 74, + 3270, 36, 3273, 74, 3274, 36, 3278, 74, 3285, 36, 3287, 74, 3292, 36, 3295, 74, 3296, 36, 3300, 74, 3302, 36, 3312, 75, + 3313, 36, 3316, 74, 3328, 36, 3341, 76, 3342, 36, 3345, 76, 3346, 36, 3397, 76, 3398, 36, 3401, 76, 3402, 36, 3408, 76, + 3412, 36, 3428, 76, 3430, 36, 3456, 76, 3457, 36, 3460, 77, 3461, 36, 3479, 77, 3482, 36, 3506, 77, 3507, 36, 3516, 77, + 3517, 36, 3518, 77, 3520, 36, 3527, 77, 3530, 36, 3531, 77, 3535, 36, 3541, 77, 3542, 36, 3543, 77, 3544, 36, 3552, 77, + 3558, 36, 3568, 77, 3570, 36, 3573, 77, 3585, 36, 3643, 78, 3647, 36, 3648, 0, 3676, 78, 3713, 36, 3715, 79, 3716, 36, + 3717, 79, 3718, 36, 3723, 79, 3724, 36, 3748, 79, 3749, 36, 3750, 79, 3751, 36, 3774, 79, 3776, 36, 3781, 79, 3782, 36, + 3783, 79, 3784, 36, 3791, 79, 3792, 36, 3802, 79, 3804, 36, 3808, 79, 3840, 36, 3912, 80, 3913, 36, 3949, 80, 3953, 36, + 3992, 80, 3993, 36, 4029, 80, 4030, 36, 4045, 80, 4046, 36, 4053, 80, 4057, 0, 4059, 80, 4096, 36, 4160, 81, 4170, 82, + 4256, 81, 4294, 83, 4295, 36, 4296, 83, 4301, 36, 4302, 83, 4304, 36, 4347, 83, 4348, 84, 4352, 83, 4608, 85, 4681, 86, + 4682, 36, 4686, 86, 4688, 36, 4695, 86, 4696, 36, 4697, 86, 4698, 36, 4702, 86, 4704, 36, 4745, 86, 4746, 36, 4750, 86, + 4752, 36, 4785, 86, 4786, 36, 4790, 86, 4792, 36, 4799, 86, 4800, 36, 4801, 86, 4802, 36, 4806, 86, 4808, 36, 4823, 86, + 4824, 36, 4881, 86, 4882, 36, 4886, 86, 4888, 36, 4955, 86, 4957, 36, 4989, 86, 4992, 36, 5018, 86, 5024, 36, 5110, 87, + 5112, 36, 5118, 87, 5120, 36, 5760, 88, 5789, 89, 5792, 36, 5881, 90, 5888, 36, 5910, 91, 5919, 36, 5920, 91, 5941, 92, + 5943, 93, 5952, 36, 5972, 94, 5984, 36, 5997, 95, 5998, 36, 6001, 95, 6002, 36, 6004, 95, 6016, 36, 6110, 96, 6112, 36, + 6122, 96, 6128, 36, 6138, 96, 6144, 36, 6146, 97, 6148, 98, 6149, 97, 6150, 98, 6170, 97, 6176, 36, 6265, 97, 6272, 36, + 6315, 97, 6320, 36, 6390, 88, 6400, 36, 6431, 99, 6432, 36, 6444, 99, 6448, 36, 6460, 99, 6464, 36, 6465, 99, 6468, 36, + 6480, 99, 6510, 100, 6512, 36, 6517, 100, 6528, 36, 6572, 101, 6576, 36, 6602, 101, 6608, 36, 6619, 101, 6622, 36, 6624, 101, + 6656, 96, 6684, 102, 6686, 36, 6688, 102, 6751, 103, 6752, 36, 6781, 103, 6783, 36, 6794, 103, 6800, 36, 6810, 103, 6816, 36, + 6830, 103, 6832, 36, 6878, 23, 6880, 36, 6892, 23, 6912, 36, 6989, 104, 6990, 36, 7040, 104, 7104, 105, 7156, 106, 7164, 36, + 7168, 106, 7224, 107, 7227, 36, 7242, 107, 7245, 36, 7248, 107, 7296, 108, 7307, 38, 7312, 36, 7355, 83, 7357, 36, 7360, 83, + 7368, 105, 7376, 36, 7377, 109, 7378, 58, 7379, 109, 7380, 110, 7381, 58, 7382, 111, 7383, 112, 7384, 113, 7385, 114, 7386, 115, + 7387, 116, 7388, 58, 7390, 115, 7392, 58, 7393, 115, 7394, 117, 7395, 118, 7401, 58, 7402, 119, 7403, 120, 7404, 121, 7405, 58, + 7406, 122, 7410, 58, 7411, 123, 7412, 124, 7413, 125, 7415, 117, 7416, 64, 7418, 124, 7419, 126, 7424, 36, 7462, 1, 7467, 32, + 7468, 38, 7517, 1, 7522, 32, 7526, 1, 7531, 32, 7544, 1, 7545, 38, 7615, 1, 7618, 32, 7672, 23, 7673, 127, 7674, 23, + 7675, 53, 7680, 23, 7936, 1, 7958, 32, 7960, 36, 7966, 32, 7968, 36, 8006, 32, 8008, 36, 8014, 32, 8016, 36, 8024, 32, + 8025, 36, 8026, 32, 8027, 36, 8028, 32, 8029, 36, 8030, 32, 8031, 36, 8062, 32, 8064, 36, 8117, 32, 8118, 36, 8133, 32, + 8134, 36, 8148, 32, 8150, 36, 8156, 32, 8157, 36, 8176, 32, 8178, 36, 8181, 32, 8182, 36, 8191, 32, 8192, 36, 8204, 0, + 8206, 23, 8239, 0, 8240, 128, 8271, 0, 8272, 129, 8282, 0, 8283, 130, 8285, 0, 8286, 131, 8293, 0, 8294, 36, 8305, 0, + 8306, 1, 8308, 36, 8319, 0, 8320, 1, 8335, 0, 8336, 36, 8349, 1, 8352, 36, 8386, 0, 8400, 36, 8432, 23, 8433, 132, + 8448, 36, 8486, 0, 8487, 32, 8490, 0, 8492, 1, 8498, 0, 8499, 1, 8526, 0, 8527, 1, 8544, 0, 8585, 1, 8588, 0, + 8592, 36, 9258, 0, 9280, 36, 9291, 0, 9312, 36, 10240, 0, 10496, 133, 11124, 0, 11126, 36, 11264, 0, 11360, 134, 11392, 1, + 11508, 37, 11513, 36, 11520, 37, 11558, 83, 11559, 36, 11560, 83, 11565, 36, 11566, 83, 11568, 36, 11624, 135, 11631, 36, 11633, 135, + 11647, 36, 11648, 135, 11671, 86, 11680, 36, 11687, 86, 11688, 36, 11695, 86, 11696, 36, 11703, 86, 11704, 36, 11711, 86, 11712, 36, + 11719, 86, 11720, 36, 11727, 86, 11728, 36, 11735, 86, 11736, 36, 11743, 86, 11744, 36, 11776, 38, 11799, 0, 11800, 136, 11824, 0, + 11825, 137, 11826, 138, 11836, 0, 11837, 139, 11841, 0, 11842, 140, 11843, 0, 11844, 40, 11870, 0, 11904, 36, 11930, 141, 11931, 36, + 12020, 141, 12032, 36, 12246, 141, 12272, 36, 12288, 142, 12289, 0, 12290, 143, 12291, 144, 12292, 145, 12293, 0, 12296, 141, 12298, 146, + 12300, 147, 12306, 148, 12307, 0, 12308, 145, 12316, 148, 12320, 145, 12321, 0, 12330, 141, 12334, 149, 12336, 85, 12337, 145, 12342, 150, + 12343, 0, 12344, 145, 12348, 141, 12350, 151, 12352, 141, 12353, 36, 12439, 152, 12441, 36, 12445, 150, 12448, 152, 12449, 150, 12539, 153, + 12540, 148, 12541, 150, 12544, 153, 12549, 36, 12592, 7, 12593, 36, 12687, 85, 12688, 36, 12704, 141, 12736, 7, 12774, 141, 12783, 36, + 12784, 142, 12800, 153, 12831, 85, 12832, 36, 12872, 141, 12896, 0, 12927, 85, 12928, 0, 12977, 141, 12992, 0, 13004, 141, 13008, 0, + 13055, 153, 13056, 141, 13144, 153, 13169, 141, 13179, 0, 13184, 141, 13280, 0, 13311, 141, 13312, 0, 19904, 141, 19968, 0, 40960, 141, + 42125, 154, 42128, 36, 42183, 154, 42192, 36, 42240, 155, 42540, 156, 42560, 36, 42607, 38, 42608, 40, 42656, 38, 42744, 157, 42752, 36, + 42760, 158, 42786, 0, 42888, 1, 42891, 0, 42973, 1, 42993, 36, 43008, 1, 43053, 159, 43056, 36, 43059, 160, 43062, 161, 43064, 162, + 43065, 163, 43066, 162, 43072, 36, 43128, 164, 43136, 36, 43206, 165, 43214, 36, 43226, 165, 43232, 36, 43249, 58, 43250, 166, 43251, 58, + 43252, 167, 43264, 58, 43310, 168, 43311, 169, 43312, 168, 43348, 170, 43359, 36, 43360, 170, 43389, 85, 43392, 36, 43470, 171, 43471, 36, + 43472, 172, 43482, 171, 43486, 36, 43488, 171, 43519, 81, 43520, 36, 43575, 173, 43584, 36, 43598, 173, 43600, 36, 43610, 173, 43612, 36, + 43616, 173, 43648, 81, 43715, 174, 43739, 36, 43744, 174, 43767, 175, 43777, 36, 43783, 86, 43785, 36, 43791, 86, 43793, 36, 43799, 86, + 43808, 36, 43815, 86, 43816, 36, 43823, 86, 43824, 36, 43867, 1, 43868, 0, 43877, 1, 43878, 32, 43882, 1, 43884, 0, 43888, 36, + 43968, 87, 44014, 175, 44016, 36, 44026, 175, 44032, 36, 55204, 85, 55216, 36, 55239, 85, 55243, 36, 55292, 85, 63744, 36, 64110, 141, + 64112, 36, 64218, 141, 64256, 36, 64263, 1, 64275, 36, 64280, 42, 64285, 36, 64311, 44, 64312, 36, 64317, 44, 64318, 36, 64319, 44, + 64320, 36, 64322, 44, 64323, 36, 64325, 44, 64326, 36, 64336, 44, 64830, 45, 64832, 176, 64976, 45, 65008, 36, 65010, 45, 65011, 177, + 65021, 45, 65022, 177, 65024, 45, 65040, 23, 65050, 0, 65056, 36, 65070, 23, 65072, 38, 65093, 0, 65095, 145, 65107, 0, 65108, 36, + 65127, 0, 65128, 36, 65132, 0, 65136, 36, 65141, 45, 65142, 36, 65277, 45, 65279, 36, 65280, 0, 65281, 36, 65313, 0, 65339, 1, + 65345, 0, 65371, 1, 65377, 0, 65382, 148, 65392, 153, 65393, 150, 65438, 153, 65440, 150, 65471, 85, 65474, 36, 65480, 85, 65482, 36, + 65488, 85, 65490, 36, 65496, 85, 65498, 36, 65501, 85, 65504, 36, 65511, 0, 65512, 36, 65519, 0, 65529, 36, 65534, 0, 65536, 36, + 65548, 178, 65549, 36, 65575, 178, 65576, 36, 65595, 178, 65596, 36, 65598, 178, 65599, 36, 65614, 178, 65616, 36, 65630, 178, 65664, 36, + 65787, 178, 65792, 36, 65794, 179, 65795, 180, 65799, 36, 65844, 181, 65847, 36, 65856, 180, 65935, 32, 65936, 36, 65949, 0, 65952, 36, + 65953, 32, 66000, 36, 66045, 0, 66046, 23, 66176, 36, 66205, 182, 66208, 36, 66257, 183, 66272, 36, 66300, 184, 66304, 36, 66340, 185, + 66349, 36, 66352, 185, 66379, 186, 66384, 36, 66427, 187, 66432, 36, 66462, 188, 66463, 36, 66464, 188, 66500, 189, 66504, 36, 66518, 189, + 66560, 36, 66640, 190, 66688, 191, 66718, 192, 66720, 36, 66730, 192, 66736, 36, 66772, 193, 66776, 36, 66812, 193, 66816, 36, 66856, 194, + 66864, 36, 66916, 195, 66927, 36, 66928, 195, 66939, 196, 66940, 36, 66955, 196, 66956, 36, 66963, 196, 66964, 36, 66966, 196, 66967, 36, + 66978, 196, 66979, 36, 66994, 196, 66995, 36, 67002, 196, 67003, 36, 67005, 196, 67008, 36, 67060, 197, 67072, 36, 67383, 198, 67392, 36, + 67414, 198, 67424, 36, 67432, 198, 67456, 36, 67462, 1, 67463, 36, 67505, 1, 67506, 36, 67515, 1, 67584, 36, 67590, 199, 67592, 36, + 67593, 199, 67594, 36, 67638, 199, 67639, 36, 67641, 199, 67644, 36, 67645, 199, 67647, 36, 67648, 199, 67670, 200, 67671, 36, 67680, 200, + 67712, 201, 67743, 202, 67751, 36, 67760, 202, 67808, 36, 67827, 203, 67828, 36, 67830, 203, 67835, 36, 67840, 203, 67868, 204, 67871, 36, + 67872, 204, 67898, 205, 67903, 36, 67904, 205, 67930, 206, 67968, 36, 68000, 207, 68024, 208, 68028, 36, 68048, 208, 68050, 36, 68096, 208, + 68100, 209, 68101, 36, 68103, 209, 68108, 36, 68116, 209, 68117, 36, 68120, 209, 68121, 36, 68150, 209, 68152, 36, 68155, 209, 68159, 36, + 68169, 209, 68176, 36, 68185, 209, 68192, 36, 68224, 210, 68256, 211, 68288, 36, 68327, 212, 68331, 36, 68338, 212, 68339, 213, 68343, 212, + 68352, 36, 68406, 214, 68409, 36, 68416, 214, 68438, 215, 68440, 36, 68448, 215, 68467, 216, 68472, 36, 68480, 216, 68498, 217, 68505, 36, + 68509, 217, 68521, 36, 68528, 217, 68608, 36, 68681, 218, 68736, 36, 68787, 219, 68800, 36, 68851, 219, 68858, 36, 68864, 219, 68904, 220, + 68912, 36, 68922, 220, 68928, 36, 68966, 221, 68969, 36, 68998, 221, 69006, 36, 69008, 221, 69216, 36, 69247, 45, 69248, 36, 69290, 222, + 69291, 36, 69294, 222, 69296, 36, 69298, 222, 69314, 36, 69320, 45, 69328, 36, 69337, 45, 69370, 36, 69376, 45, 69416, 223, 69424, 36, + 69466, 224, 69488, 36, 69514, 225, 69552, 36, 69580, 226, 69600, 36, 69623, 227, 69632, 36, 69710, 228, 69714, 36, 69750, 228, 69759, 36, + 69760, 228, 69827, 229, 69837, 36, 69838, 229, 69840, 36, 69865, 230, 69872, 36, 69882, 230, 69888, 36, 69941, 231, 69942, 36, 69960, 231, + 69968, 36, 70007, 232, 70016, 36, 70112, 233, 70113, 36, 70133, 77, 70144, 36, 70162, 234, 70163, 36, 70210, 234, 70272, 36, 70279, 235, + 70280, 36, 70281, 235, 70282, 36, 70286, 235, 70287, 36, 70302, 235, 70303, 36, 70314, 235, 70320, 36, 70379, 236, 70384, 36, 70394, 236, + 70400, 36, 70401, 237, 70402, 72, 70403, 237, 70404, 72, 70405, 36, 70413, 237, 70415, 36, 70417, 237, 70419, 36, 70441, 237, 70442, 36, + 70449, 237, 70450, 36, 70452, 237, 70453, 36, 70458, 237, 70459, 36, 70461, 72, 70469, 237, 70471, 36, 70473, 237, 70475, 36, 70478, 237, + 70480, 36, 70481, 237, 70487, 36, 70488, 237, 70493, 36, 70500, 237, 70502, 36, 70509, 237, 70512, 36, 70517, 237, 70528, 36, 70538, 238, + 70539, 36, 70540, 238, 70542, 36, 70543, 238, 70544, 36, 70582, 238, 70583, 36, 70593, 238, 70594, 36, 70595, 238, 70597, 36, 70598, 238, + 70599, 36, 70603, 238, 70604, 36, 70614, 238, 70615, 36, 70617, 238, 70625, 36, 70627, 238, 70656, 36, 70748, 239, 70749, 36, 70754, 239, + 70784, 36, 70856, 240, 70864, 36, 70874, 240, 71040, 36, 71094, 241, 71096, 36, 71134, 241, 71168, 36, 71237, 242, 71248, 36, 71258, 242, + 71264, 36, 71277, 97, 71296, 36, 71354, 243, 71360, 36, 71370, 243, 71376, 36, 71396, 81, 71424, 36, 71451, 244, 71453, 36, 71468, 244, + 71472, 36, 71495, 244, 71680, 36, 71740, 245, 71840, 36, 71923, 246, 71935, 36, 71936, 246, 71943, 247, 71945, 36, 71946, 247, 71948, 36, + 71956, 247, 71957, 36, 71959, 247, 71960, 36, 71990, 247, 71991, 36, 71993, 247, 71995, 36, 72007, 247, 72016, 36, 72026, 247, 72096, 36, + 72104, 126, 72106, 36, 72152, 126, 72154, 36, 72165, 126, 72192, 36, 72264, 248, 72272, 36, 72355, 249, 72368, 36, 72384, 88, 72441, 250, + 72448, 36, 72458, 58, 72544, 36, 72552, 233, 72640, 36, 72674, 251, 72688, 36, 72698, 251, 72704, 36, 72713, 252, 72714, 36, 72759, 252, + 72760, 36, 72774, 252, 72784, 36, 72813, 252, 72816, 36, 72848, 253, 72850, 36, 72872, 253, 72873, 36, 72887, 253, 72960, 36, 72967, 254, + 72968, 36, 72970, 254, 72971, 36, 73015, 254, 73018, 36, 73019, 254, 73020, 36, 73022, 254, 73023, 36, 73032, 254, 73040, 36, 73050, 254, + 73056, 36, 73062, 255, 73063, 36, 73065, 255, 73066, 36, 73103, 255, 73104, 36, 73106, 255, 73107, 36, 73113, 255, 73120, 36, 73130, 255, + 73136, 36, 73180, 256, 73184, 36, 73194, 256, 73440, 36, 73465, 257, 73472, 36, 73489, 258, 73490, 36, 73531, 258, 73534, 36, 73563, 258, + 73648, 36, 73649, 155, 73664, 36, 73680, 71, 73682, 72, 73683, 71, 73684, 72, 73714, 71, 73727, 36, 73728, 71, 74650, 259, 74752, 36, + 74863, 259, 74864, 36, 74869, 259, 74880, 36, 75076, 259, 77712, 36, 77811, 260, 77824, 36, 78934, 261, 78944, 36, 82939, 261, 82944, 36, + 83527, 262, 90368, 36, 90426, 263, 92160, 36, 92729, 157, 92736, 36, 92767, 264, 92768, 36, 92778, 264, 92782, 36, 92784, 264, 92863, 265, + 92864, 36, 92874, 265, 92880, 36, 92910, 266, 92912, 36, 92918, 266, 92928, 36, 92998, 267, 93008, 36, 93018, 267, 93019, 36, 93026, 267, + 93027, 36, 93048, 267, 93053, 36, 93072, 267, 93504, 36, 93562, 268, 93760, 36, 93851, 269, 93856, 36, 93881, 270, 93883, 36, 93908, 270, + 93952, 36, 94027, 271, 94031, 36, 94088, 271, 94095, 36, 94112, 271, 94176, 36, 94177, 272, 94178, 273, 94180, 141, 94181, 274, 94192, 36, + 94199, 141, 94208, 36, 101120, 272, 101590, 274, 101631, 36, 101632, 274, 101663, 272, 101760, 36, 101875, 272, 110576, 36, 110580, 153, 110581, 36, + 110588, 153, 110589, 36, 110591, 153, 110592, 36, 110593, 153, 110880, 152, 110883, 153, 110898, 36, 110899, 152, 110928, 36, 110931, 152, 110933, 36, + 110934, 153, 110948, 36, 110952, 153, 110960, 36, 111356, 273, 113664, 36, 113771, 139, 113776, 36, 113789, 139, 113792, 36, 113801, 139, 113808, 36, + 113818, 139, 113820, 36, 113828, 139, 117760, 36, 118013, 0, 118016, 36, 118452, 0, 118458, 36, 118481, 0, 118496, 36, 118513, 0, 118528, 36, + 118574, 23, 118576, 36, 118599, 23, 118608, 36, 118724, 0, 118784, 36, 119030, 0, 119040, 36, 119079, 0, 119081, 36, 119143, 0, 119146, 23, + 119163, 0, 119171, 23, 119173, 0, 119180, 23, 119210, 0, 119214, 23, 119275, 0, 119296, 36, 119366, 32, 119488, 36, 119508, 0, 119520, 36, + 119540, 0, 119552, 36, 119639, 0, 119648, 36, 119666, 141, 119673, 0, 119808, 36, 119893, 0, 119894, 36, 119965, 0, 119966, 36, 119968, 0, + 119970, 36, 119971, 0, 119973, 36, 119975, 0, 119977, 36, 119981, 0, 119982, 36, 119994, 0, 119995, 36, 119996, 0, 119997, 36, 120004, 0, + 120005, 36, 120070, 0, 120071, 36, 120075, 0, 120077, 36, 120085, 0, 120086, 36, 120093, 0, 120094, 36, 120122, 0, 120123, 36, 120127, 0, + 120128, 36, 120133, 0, 120134, 36, 120135, 0, 120138, 36, 120145, 0, 120146, 36, 120486, 0, 120488, 36, 120780, 0, 120782, 36, 120832, 0, + 121484, 275, 121499, 36, 121504, 275, 121505, 36, 121520, 275, 122624, 36, 122655, 1, 122661, 36, 122667, 1, 122880, 36, 122887, 134, 122888, 36, + 122905, 134, 122907, 36, 122914, 134, 122915, 36, 122917, 134, 122918, 36, 122923, 134, 122928, 36, 122990, 38, 123023, 36, 123024, 38, 123136, 36, + 123181, 276, 123184, 36, 123198, 276, 123200, 36, 123210, 276, 123214, 36, 123216, 276, 123536, 36, 123567, 277, 123584, 36, 123642, 278, 123647, 36, + 123648, 278, 124112, 36, 124154, 279, 124368, 36, 124411, 280, 124415, 36, 124416, 280, 124608, 36, 124639, 281, 124640, 36, 124662, 281, 124670, 36, + 124672, 281, 124896, 36, 124903, 86, 124904, 36, 124908, 86, 124909, 36, 124911, 86, 124912, 36, 124927, 86, 124928, 36, 125125, 282, 125127, 36, + 125143, 282, 125184, 36, 125260, 283, 125264, 36, 125274, 283, 125278, 36, 125280, 283, 126065, 36, 126133, 0, 126209, 36, 126270, 0, 126464, 36, + 126468, 45, 126469, 36, 126496, 45, 126497, 36, 126499, 45, 126500, 36, 126501, 45, 126503, 36, 126504, 45, 126505, 36, 126515, 45, 126516, 36, + 126520, 45, 126521, 36, 126522, 45, 126523, 36, 126524, 45, 126530, 36, 126531, 45, 126535, 36, 126536, 45, 126537, 36, 126538, 45, 126539, 36, + 126540, 45, 126541, 36, 126544, 45, 126545, 36, 126547, 45, 126548, 36, 126549, 45, 126551, 36, 126552, 45, 126553, 36, 126554, 45, 126555, 36, + 126556, 45, 126557, 36, 126558, 45, 126559, 36, 126560, 45, 126561, 36, 126563, 45, 126564, 36, 126565, 45, 126567, 36, 126571, 45, 126572, 36, + 126579, 45, 126580, 36, 126584, 45, 126585, 36, 126589, 45, 126590, 36, 126591, 45, 126592, 36, 126602, 45, 126603, 36, 126620, 45, 126625, 36, + 126628, 45, 126629, 36, 126634, 45, 126635, 36, 126652, 45, 126704, 36, 126706, 45, 126976, 36, 127020, 0, 127024, 36, 127124, 0, 127136, 36, + 127151, 0, 127153, 36, 127168, 0, 127169, 36, 127184, 0, 127185, 36, 127222, 0, 127232, 36, 127406, 0, 127462, 36, 127488, 0, 127489, 152, + 127491, 0, 127504, 36, 127548, 0, 127552, 36, 127561, 0, 127568, 36, 127570, 141, 127584, 36, 127590, 0, 127744, 36, 128729, 0, 128732, 36, + 128749, 0, 128752, 36, 128765, 0, 128768, 36, 128986, 0, 128992, 36, 129004, 0, 129008, 36, 129009, 0, 129024, 36, 129036, 0, 129040, 36, + 129096, 0, 129104, 36, 129114, 0, 129120, 36, 129160, 0, 129168, 36, 129198, 0, 129200, 36, 129212, 0, 129216, 36, 129218, 0, 129232, 36, + 129241, 0, 129280, 36, 129624, 0, 129632, 36, 129646, 0, 129648, 36, 129661, 0, 129664, 36, 129675, 0, 129678, 36, 129735, 0, 129736, 36, + 129737, 0, 129741, 36, 129757, 0, 129759, 36, 129771, 0, 129775, 36, 129785, 0, 129792, 36, 129939, 0, 129940, 36, 130043, 0, 131072, 36, + 173792, 141, 173824, 36, 178206, 141, 178208, 36, 183982, 141, 183984, 36, 191457, 141, 191472, 36, 192094, 141, 194560, 36, 195102, 141, 196608, 36, + 201547, 141, 201552, 36, 210042, 141, 917505, 36, 917506, 0, 917536, 36, 917632, 0, 917760, 36, 918000, 23, 1114112, 36 +]; +pub static SCRIPT_EXTENSION_OFFSETS: &[u32] = &[ + 0, 1, 2, 18, 25, 27, 29, 31, 32, 40, 48, 52, 57, 68, 74, 79, 88, 99, 101, 104, 108, 111, 113, 115, + 116, 119, 123, 129, 133, 135, 138, 141, 148, 149, 151, 154, 156, 157, 158, 159, 161, 163, 165, 166, 169, 170, 171, 178, + 181, 189, 198, 200, 203, 205, 206, 207, 208, 209, 210, 211, 226, 239, 260, 283, 287, 288, 291, 292, 294, 295, 297, 298, + 299, 301, 302, 303, 306, 307, 308, 309, 310, 311, 312, 315, 316, 319, 320, 321, 322, 323, 324, 325, 326, 327, 331, 332, + 333, 334, 335, 337, 338, 339, 340, 341, 342, 343, 344, 345, 346, 347, 351, 354, 359, 362, 365, 369, 371, 377, 379, 382, + 385, 388, 390, 394, 405, 407, 411, 412, 415, 418, 420, 426, 430, 433, 434, 435, 436, 438, 440, 447, 448, 451, 452, 454, + 461, 469, 474, 482, 491, 497, 499, 501, 504, 505, 506, 507, 508, 509, 510, 512, 513, 529, 544, 555, 567, 568, 569, 572, + 574, 575, 578, 579, 580, 582, 583, 584, 585, 587, 589, 590, 593, 595, 598, 599, 600, 602, 603, 604, 605, 606, 607, 608, + 609, 610, 611, 612, 613, 614, 615, 616, 617, 618, 619, 620, 621, 622, 623, 624, 625, 626, 627, 628, 629, 630, 632, 633, + 634, 635, 636, 637, 638, 639, 640, 641, 642, 643, 644, 645, 646, 647, 648, 649, 650, 651, 652, 653, 654, 655, 656, 657, + 658, 659, 660, 661, 662, 663, 664, 665, 666, 667, 668, 669, 670, 671, 672, 673, 674, 675, 676, 677, 678, 679, 680, 681, + 682, 683, 684, 685, 686, 687, 688, 689, 690, 691, 692, 693, 694, 695, 696, 697, 698, 699, 700, 701, 702 +]; +pub static SCRIPT_EXTENSION_TAGS: &[u32] = &[ + 1517910393, 1281455214, 1098281844, 1130459753, 1131376756, 1148547180, 1164730977, 1197830002, 1198285159, 1198485095, 1198486632, 1198679403, 1214344809, 1281455214, 1283023977, 1298229354, 1348825709, 1399349623, 1113943655, 1132032620, 1147500129, 1281455214, 1281979253, 1416126825, + 1416590447, 1114599535, 1281455214, 1281455214, 1281979253, 1281455214, 1416126825, 1114599535, 1130915186, 1131376756, 1132032620, 1198679403, 1281455214, 1348825709, 1400204917, 1415670885, 1130915186, 1132032620, 1198679403, 1281455214, 1332963173, 1400204917, 1415670885, 1416586354, + 1130915186, 1132032620, 1281455214, 1415999079, 1198285159, 1281455214, 1400204917, 1400468067, 1416126825, 1097295970, 1130915186, 1131376756, 1132032620, 1198486632, 1198679403, 1281455214, 1332963173, 1400468067, 1415999079, 1416586354, 1131376756, 1164730977, 1198285159, 1198486632, + 1264676449, 1281455214, 1132032620, 1198679403, 1281455214, 1348825709, 1415999079, 1131376756, 1148547180, 1214603890, 1281455214, 1348825709, 1400468067, 1415670885, 1415999079, 1416586354, 1098018158, 1132032620, 1148547180, 1198486632, 1198679403, 1214603890, 1281455214, 1348825709, + 1400468067, 1415670885, 1415999079, 1281455214, 1415999079, 1148547180, 1281455214, 1400468067, 1130915186, 1132032620, 1281455214, 1332963173, 1130915186, 1281455214, 1415670885, 1281455214, 1400204917, 1165256809, 1281455214, 1516858984, 1132032620, 1281455214, 1416586354, 1198679403, + 1281455214, 1348825709, 1416586354, 1130915186, 1148547180, 1264676449, 1281455214, 1400468067, 1415999079, 1130915186, 1148547180, 1281455214, 1400468067, 1281455214, 1400468067, 1281455214, 1400204917, 1400468067, 1130915186, 1281455214, 1400468067, 1097295970, 1130915186, 1198486632, + 1281455214, 1400204917, 1400468067, 1416126825, 1198679403, 1281455214, 1332963173, 1097295970, 1281455214, 1416586354, 1131376756, 1198679403, 1517976186, 1131376756, 1132032620, 1132032620, 1348825709, 1132032620, 1198285159, 1132032620, 1281455214, 1098018158, 1098018158, 1197830002, + 1198285159, 1214603890, 1098015074, 1098015074, 1197568609, 1315663727, 1383032935, 1400468067, 1416126817, 1499822697, 1098015074, 1400468067, 1416126817, 1097100397, 1098015074, 1197568609, 1315663727, 1383032935, 1400468067, 1416126817, 1499822697, 1097100397, 1098015074, 1298230884, + 1298230889, 1333094258, 1349020784, 1383032935, 1399809892, 1400468067, 1098015074, 1400468067, 1098015074, 1416126817, 1499822697, 1098015074, 1383032935, 1400468067, 1416126817, 1315663727, 1398893938, 1298230884, 1147500129, 1113943655, 1147500129, 1198678382, 1198877298, 1198879349, + 1265525857, 1281455214, 1298954605, 1315008100, 1315272545, 1332902241, 1399353956, 1415671148, 1415933045, 1416196712, 1113943655, 1147500129, 1198678382, 1198877298, 1198879349, 1265525857, 1281455214, 1298954605, 1315272545, 1332902241, 1415671148, 1415933045, 1416196712, 1113943655, + 1147500129, 1148151666, 1198485095, 1198485101, 1198678382, 1198877298, 1198879349, 1265525857, 1298229354, 1298954605, 1315008100, 1332633967, 1332902241, 1399418468, 1399418472, 1400466543, 1415670642, 1415671148, 1415933045, 1416196712, 1113943655, 1147500129, 1148151666, 1198485095, + 1198485101, 1198678382, 1198877298, 1198877544, 1198879349, 1265525857, 1281977698, 1298229354, 1298954605, 1315008100, 1332633967, 1332902241, 1399418468, 1399418472, 1400466543, 1415670642, 1415671148, 1415933045, 1416196712, 1147500129, 1148151666, 1265920105, 1298229354, 1113943655, + 1113943655, 1130457965, 1400466543, 1198879349, 1198879349, 1299541108, 1198877298, 1198877298, 1265135466, 1332902241, 1415671148, 1198678382, 1415671148, 1415933045, 1265525857, 1265525857, 1315008100, 1416983655, 1298954605, 1399418472, 1416126825, 1281453935, 1416192628, 1299803506, + 1130457965, 1299803506, 1415670885, 1197830002, 1197830002, 1198285159, 1281455214, 1214344807, 1165256809, 1130915186, 1130458739, 1332175213, 1383427698, 1416064103, 1214344815, 1114990692, 1214344815, 1415669602, 1416064103, 1114990692, 1415669602, 1265134962, 1299148391, 1299148391, + 1349017959, 1281977698, 1415670885, 1415670901, 1114990441, 1281453665, 1113681001, 1400204900, 1113683051, 1281716323, 1332503403, 1113943655, 1147500129, 1198678382, 1265525857, 1147500129, 1198678382, 1265525857, 1113943655, 1147500129, 1315272545, 1415933045, 1416196712, 1113943655, + 1147500129, 1415933045, 1147500129, 1315272545, 1399353956, 1113943655, 1147500129, 1315272545, 1415933045, 1147500129, 1399353956, 1147500129, 1265525857, 1298954605, 1332902241, 1415671148, 1415933045, 1113943655, 1147500129, 1147500129, 1315272545, 1416196712, 1147500129, 1315008100, + 1315272545, 1113943655, 1147500129, 1399353956, 1147500129, 1315272545, 1113943655, 1147500129, 1315272545, 1399353956, 1113943655, 1147500129, 1198678382, 1265525857, 1298954605, 1315008100, 1332902241, 1399418472, 1415933045, 1416196712, 1416983655, 1147500129, 1198678382, 1147500129, + 1198678382, 1265525857, 1416983655, 1315008100, 1132032620, 1281455214, 1400468067, 1281455214, 1299148391, 1349017959, 1097100397, 1098015074, 1130459753, 1197830002, 1198285159, 1215655527, 1283023721, 1332898664, 1130459753, 1198679403, 1215655527, 1298494063, 1147500129, 1198678382, + 1281455214, 1114792297, 1198285159, 1415999079, 1131376756, 1281455214, 1098281844, 1332898664, 1098281844, 1130459753, 1197830002, 1215655527, 1265920105, 1283023977, 1398893938, 1148547180, 1097100397, 1098015074, 1215655527, 1214344809, 1214344809, 1415671399, 1114599535, 1214344807, + 1214344809, 1214870113, 1264676449, 1299148391, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1299148391, 1349017959, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1299148391, + 1416192628, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1281979253, 1299148391, 1416192628, 1500080489, 1114599535, 1214344807, 1214344809, 1214870113, 1264676449, 1500080489, 1114599535, 1214344809, 1214870113, 1264676449, 1214344809, 1214870113, 1264676449, + 1214870113, 1264676449, 1500080489, 1281979253, 1449224553, 1113681269, 1214344809, 1281455214, 1400466543, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265525857, 1265920105, 1298229354, 1298954605, 1299145833, 1315008100, 1399353956, 1399418468, 1415670642, 1416196712, + 1416983655, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265525857, 1265920105, 1298229354, 1299145833, 1315008100, 1399353956, 1399418468, 1415670642, 1416196712, 1416983655, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265920105, 1298229354, 1299145833, + 1399418468, 1415670642, 1416196712, 1147500129, 1148151666, 1198877298, 1198879349, 1265135466, 1265920105, 1298229354, 1299145833, 1399353956, 1399418468, 1415670642, 1416196712, 1349017959, 1398895986, 1113943655, 1147500129, 1416983655, 1147500129, 1415671148, 1264675945, 1264675945, + 1281455214, 1299803506, 1382706791, 1247901281, 1114990441, 1247901281, 1130914157, 1415673460, 1299473769, 1098015074, 1315663727, 1098015074, 1416126817, 1281977954, 1131441518, 1131442804, 1281977954, 1131442804, 1281977954, 1131442804, 1281977953, 1281977954, 1283023721, 1130459753, + 1098015074, 1131376756, 1232363884, 1198486632, 1348825709, 1432838514, 1483761007, 1148416628, 1399349623, 1332964705, 1332963173, 1164730977, 1097295970, 1449751656, 1416586354, 1281977953, 1131442804, 1098018153, 1348562029, 1315070324, 1214346354, 1349021304, 1283023977, 1399415924, + 1298494063, 1298494051, 1265131890, 1398895202, 1315009122, 1298230889, 1298230889, 1333094258, 1098281844, 1349678185, 1349020777, 1349020784, 1332898664, 1215655527, 1383032935, 1197568609, 1499822697, 1399809903, 1399809892, 1333094258, 1130918515, 1164736877, 1114792296, 1265920105, + 1399812705, 1130457965, 1298229354, 1399353956, 1265135466, 1299541108, 1399418468, 1198678382, 1416983655, 1315272545, 1416196712, 1399415908, 1299145833, 1415670642, 1097363309, 1148151666, 1466004065, 1147756907, 1516334690, 1399814511, 1348564323, 1400204917, 1114139507, 1298231907, + 1198485101, 1198485095, 1416588403, 1298230113, 1264678761, 1483961720, 1131441518, 1164409200, 1215067511, 1198877544, 1299345263, 1416524641, 1113682803, 1215131239, 1265787241, 1298490470, 1113944678, 1349284452, 1415671399, 1316186229, 1265202291, 1399287415, 1215131248, 1416590447, + 1466132591, 1315006317, 1332633967, 1415674223, 1298493028, 1097100397 +]; diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index e061f2f2..68e5b60a 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; mod abi_contract; pub mod bidi; pub mod engine; +pub mod unicode; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] mod wire; diff --git a/packages/text/rust/shaper/src/unicode.rs b/packages/text/rust/shaper/src/unicode.rs new file mode 100644 index 00000000..f45d4133 --- /dev/null +++ b/packages/text/rust/shaper/src/unicode.rs @@ -0,0 +1,357 @@ +use alloc::{string::String, vec::Vec}; +use unicode_segmentation::UnicodeSegmentation; + +mod generated { + include!("generated/script_data.rs"); +} + +pub use generated::UNICODE_VERSION; + +pub const COMMON_SCRIPT: u32 = generated::COMMON_SCRIPT; +pub const INHERITED_SCRIPT: u32 = generated::INHERITED_SCRIPT; +pub const UNKNOWN_SCRIPT: u32 = generated::UNKNOWN_SCRIPT; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UnicodeError { + InvalidUtf16, + ResultTooLarge, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScriptItem { + pub text_start: u32, + pub text_end: u32, + pub script: u32, +} + +#[derive(Default)] +pub struct UnicodeAnalysis { + utf8: String, + grapheme_boundaries: Vec, + grapheme_scripts: Vec, + candidate_offsets: Vec, + candidate_tags: Vec, + candidate_scratch: Vec, + script_items: Vec, +} + +impl UnicodeAnalysis { + pub fn reserve(&mut self, utf16_capacity: usize) -> Result<(), UnicodeError> { + reserve( + &mut self.grapheme_boundaries, + utf16_capacity.saturating_add(1), + )?; + reserve(&mut self.grapheme_scripts, utf16_capacity)?; + reserve( + &mut self.candidate_offsets, + utf16_capacity.saturating_add(1), + )?; + reserve(&mut self.candidate_tags, utf16_capacity)?; + reserve(&mut self.script_items, utf16_capacity)?; + self.utf8 + .try_reserve(utf16_capacity.saturating_mul(3)) + .map_err(|_| UnicodeError::ResultTooLarge) + } + + pub fn analyze(&mut self, text: &[u16]) -> Result<(), UnicodeError> { + self.clear(); + self.reserve(text.len())?; + encode_utf16(text, &mut self.utf8)?; + self.segment_graphemes()?; + self.resolve_grapheme_scripts(text)?; + self.resolve_neutral_scripts(); + self.itemize_scripts()?; + Ok(()) + } + + pub fn grapheme_boundaries(&self) -> &[u32] { + &self.grapheme_boundaries + } + + pub fn script_items(&self) -> &[ScriptItem] { + &self.script_items + } + + fn clear(&mut self) { + self.utf8.clear(); + self.grapheme_boundaries.clear(); + self.grapheme_scripts.clear(); + self.candidate_offsets.clear(); + self.candidate_tags.clear(); + self.candidate_scratch.clear(); + self.script_items.clear(); + } + + fn segment_graphemes(&mut self) -> Result<(), UnicodeError> { + self.grapheme_boundaries.push(0); + let mut utf16_end = 0usize; + for grapheme in self.utf8.graphemes(true) { + utf16_end = utf16_end + .checked_add(grapheme.chars().map(char::len_utf16).sum::()) + .ok_or(UnicodeError::ResultTooLarge)?; + self.grapheme_boundaries + .push(u32::try_from(utf16_end).map_err(|_| UnicodeError::ResultTooLarge)?); + } + Ok(()) + } + + fn resolve_grapheme_scripts(&mut self, text: &[u16]) -> Result<(), UnicodeError> { + self.candidate_offsets.push(0); + for boundary in self.grapheme_boundaries.windows(2) { + let start = usize::try_from(boundary[0]).map_err(|_| UnicodeError::ResultTooLarge)?; + let end = usize::try_from(boundary[1]).map_err(|_| UnicodeError::ResultTooLarge)?; + let mut preferred = COMMON_SCRIPT; + let mut intersected = false; + self.candidate_scratch.clear(); + let mut index = start; + while index < end { + let (character, consumed) = decode_scalar(text, index)?; + index += consumed; + let primary = script(character as u32).ok_or(UnicodeError::InvalidUtf16)?; + if !is_neutral_script(primary) && preferred == COMMON_SCRIPT { + preferred = primary; + } + let extensions = + script_extensions(character as u32).ok_or(UnicodeError::InvalidUtf16)?; + if !extensions + .iter() + .copied() + .any(|tag| !is_neutral_script(tag)) + { + continue; + } + if !intersected { + intersected = true; + self.candidate_scratch.extend( + extensions + .iter() + .copied() + .filter(|tag| !is_neutral_script(*tag)), + ); + } else { + self.candidate_scratch + .retain(|tag| extensions.contains(tag)); + } + } + self.grapheme_scripts.push(preferred); + if intersected + && !self.candidate_scratch.is_empty() + && !self.candidate_scratch.contains(&preferred) + { + self.candidate_tags + .extend_from_slice(&self.candidate_scratch); + } + self.candidate_offsets.push( + u32::try_from(self.candidate_tags.len()) + .map_err(|_| UnicodeError::ResultTooLarge)?, + ); + } + Ok(()) + } + + fn resolve_neutral_scripts(&mut self) { + let mut previous = COMMON_SCRIPT; + for index in 0..self.grapheme_scripts.len() { + let current = self.grapheme_scripts[index]; + if is_neutral_script(current) { + if self.accepts_context(index, previous) { + self.grapheme_scripts[index] = previous; + } + } else { + previous = current; + } + } + let mut next = COMMON_SCRIPT; + for index in (0..self.grapheme_scripts.len()).rev() { + let current = self.grapheme_scripts[index]; + if is_neutral_script(current) { + if self.accepts_context(index, next) { + self.grapheme_scripts[index] = next; + } + } else { + next = current; + } + } + } + + fn accepts_context(&self, index: usize, context: u32) -> bool { + if is_neutral_script(context) { + return false; + } + let start = self.candidate_offsets[index] as usize; + let end = self.candidate_offsets[index + 1] as usize; + start == end || self.candidate_tags[start..end].contains(&context) + } + + fn itemize_scripts(&mut self) -> Result<(), UnicodeError> { + for (index, boundaries) in self.grapheme_boundaries.windows(2).enumerate() { + let item = ScriptItem { + text_start: boundaries[0], + text_end: boundaries[1], + script: self.grapheme_scripts[index], + }; + if let Some(previous) = self.script_items.last_mut() + && previous.text_end == item.text_start + && previous.script == item.script + { + previous.text_end = item.text_end; + } else { + self.script_items.push(item); + } + } + if self.grapheme_scripts.len() + 1 != self.grapheme_boundaries.len() { + return Err(UnicodeError::InvalidUtf16); + } + Ok(()) + } +} + +pub fn script(code_point: u32) -> Option { + lookup_partition(generated::SCRIPT_END_VALUES, code_point) +} + +pub fn script_extensions(code_point: u32) -> Option<&'static [u32]> { + let set = usize::try_from(lookup_partition( + generated::SCRIPT_EXTENSION_END_VALUES, + code_point, + )?) + .ok()?; + let start = usize::try_from(*generated::SCRIPT_EXTENSION_OFFSETS.get(set)?).ok()?; + let end = usize::try_from(*generated::SCRIPT_EXTENSION_OFFSETS.get(set + 1)?).ok()?; + generated::SCRIPT_EXTENSION_TAGS.get(start..end) +} + +fn lookup_partition(end_values: &[u32], code_point: u32) -> Option { + if code_point > 0x10_ffff { + return None; + } + let mut low = 0; + let mut high = end_values.len() / 2; + while low < high { + let middle = low + (high - low) / 2; + let offset = middle * 2; + let end = end_values[offset]; + if code_point >= end { + low = middle + 1; + } else { + high = middle; + } + } + end_values.get(low.checked_mul(2)?.checked_add(1)?).copied() +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), UnicodeError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| UnicodeError::ResultTooLarge)?; + } + Ok(()) +} + +fn encode_utf16(text: &[u16], output: &mut String) -> Result<(), UnicodeError> { + let mut index = 0; + while index < text.len() { + let (character, consumed) = decode_scalar(text, index)?; + output.push(character); + index += consumed; + } + Ok(()) +} + +fn decode_scalar(text: &[u16], index: usize) -> Result<(char, usize), UnicodeError> { + let first = text[index]; + if (0xd800..=0xdbff).contains(&first) { + let second = text + .get(index + 1) + .copied() + .filter(|unit| (0xdc00..=0xdfff).contains(unit)) + .ok_or(UnicodeError::InvalidUtf16)?; + let scalar = + 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)); + return char::from_u32(scalar) + .map(|character| (character, 2)) + .ok_or(UnicodeError::InvalidUtf16); + } + if (0xdc00..=0xdfff).contains(&first) { + return Err(UnicodeError::InvalidUtf16); + } + char::from_u32(u32::from(first)) + .map(|character| (character, 1)) + .ok_or(UnicodeError::InvalidUtf16) +} + +fn is_neutral_script(script: u32) -> bool { + matches!(script, COMMON_SCRIPT | INHERITED_SCRIPT | UNKNOWN_SCRIPT) +} + +#[cfg(test)] +mod tests { + use super::*; + + const fn tag(value: &[u8; 4]) -> u32 { + u32::from_be_bytes(*value) + } + + #[test] + fn generated_tables_are_unicode_17_scalar_partitions() { + assert_eq!(UNICODE_VERSION, "17.0.0"); + assert_eq!(script('A' as u32), Some(tag(b"Latn"))); + assert_eq!(script('\u{4e00}' as u32), Some(tag(b"Hani"))); + assert_eq!(script(0x10_ffff), Some(UNKNOWN_SCRIPT)); + assert_eq!(script(0x11_0000), None); + } + + #[test] + fn script_extensions_preserve_shared_marks() { + let prolonged_sound_mark = script_extensions('\u{30fc}' as u32).expect("extensions"); + assert!(prolonged_sound_mark.contains(&tag(b"Hira"))); + assert!(prolonged_sound_mark.contains(&tag(b"Kana"))); + } + + #[test] + fn analysis_segments_emoji_and_resolves_neutral_scripts() { + let text: Vec = "Latin, हिन्दी 👩‍🚀 カー".encode_utf16().collect(); + let mut analysis = UnicodeAnalysis::default(); + analysis.reserve(64).unwrap(); + analysis.analyze(&text).unwrap(); + assert_eq!(analysis.grapheme_boundaries().first(), Some(&0)); + assert_eq!( + analysis.grapheme_boundaries().last(), + Some(&(text.len() as u32)) + ); + assert!( + analysis + .grapheme_boundaries() + .windows(2) + .any(|range| range[1] - range[0] == 5) + ); + assert!( + analysis + .script_items() + .iter() + .any(|item| item.script == tag(b"Deva")) + ); + assert!( + analysis + .script_items() + .iter() + .any(|item| item.script == tag(b"Kana")) + ); + } + + #[test] + fn analysis_reuses_reserved_storage_and_rejects_unpaired_surrogates() { + let mut analysis = UnicodeAnalysis::default(); + analysis.reserve(64).unwrap(); + analysis + .analyze(&"abc".encode_utf16().collect::>()) + .unwrap(); + let boundary_capacity = analysis.grapheme_boundaries.capacity(); + analysis + .analyze(&"def".encode_utf16().collect::>()) + .unwrap(); + assert_eq!(analysis.grapheme_boundaries.capacity(), boundary_capacity); + assert_eq!(analysis.analyze(&[0xd800]), Err(UnicodeError::InvalidUtf16)); + } +} diff --git a/packages/text/scripts/generate-unicode-script-data.mjs b/packages/text/scripts/generate-unicode-script-data.mjs index 7b4e6f9f..a0737f63 100644 --- a/packages/text/scripts/generate-unicode-script-data.mjs +++ b/packages/text/scripts/generate-unicode-script-data.mjs @@ -4,7 +4,16 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; const require = createRequire(import.meta.url); const unicode = require('@unicode/unicode-17.0.0'); const propertyAliases = require('unicode-property-value-aliases'); -const output = new URL('../src/generated/unicode-script-data.ts', import.meta.url); +const outputs = [ + { + url: new URL('../src/generated/unicode-script-data.ts', import.meta.url), + source: () => typescriptSource(), + }, + { + url: new URL('../rust/shaper/src/generated/script_data.rs', import.meta.url), + source: () => rustSource(), + }, +]; const check = process.argv.includes('--check'); const scriptAliases = propertyAliases.get('Script'); @@ -72,7 +81,8 @@ for (const values of extensionSets) { extensionOffsets.push(extensionTags.length); } -const source = `// Generated by scripts/generate-unicode-script-data.mjs from +function typescriptSource() { + return `// Generated by scripts/generate-unicode-script-data.mjs from // @unicode/unicode-17.0.0@1.6.17 and unicode-property-value-aliases@3.9.0. // Do not edit by hand. @@ -86,21 +96,43 @@ export const scriptExtensionRanges: Uint32Array = new Uint32Array(${numbers(exte export const scriptExtensionOffsets: Uint32Array = new Uint32Array(${numbers(extensionOffsets)}); export const scriptExtensionTags: Uint32Array = new Uint32Array(${numbers(extensionTags)}); `; +} + +function rustSource() { + return `// Generated by scripts/generate-unicode-script-data.mjs from +// @unicode/unicode-17.0.0@1.6.17 and unicode-property-value-aliases@3.9.0. +// Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const COMMON_SCRIPT: u32 = ${tagToUint32('Zyyy')}; +pub const INHERITED_SCRIPT: u32 = ${tagToUint32('Zinh')}; +pub const UNKNOWN_SCRIPT: u32 = ${tagToUint32('Zzzz')}; + +pub static SCRIPT_END_VALUES: &[u32] = &${rustNumbers(compactPartition(primaryRanges))}; +pub static SCRIPT_EXTENSION_END_VALUES: &[u32] = &${rustNumbers(compactPartition(extensionRanges))}; +pub static SCRIPT_EXTENSION_OFFSETS: &[u32] = &${rustNumbers(extensionOffsets)}; +pub static SCRIPT_EXTENSION_TAGS: &[u32] = &${rustNumbers(extensionTags)}; +`; +} if (check) { - let existing; - try { - existing = await readFile(output, 'utf8'); - } catch { - existing = undefined; - } - if (existing !== source) { - console.error('generated Unicode script data is stale; run pnpm run unicode generate-data'); - process.exitCode = 1; + for (const output of outputs) { + let existing; + try { + existing = await readFile(output.url, 'utf8'); + } catch { + existing = undefined; + } + if (existing !== output.source()) { + console.error('generated Unicode script data is stale; run pnpm run unicode generate-data'); + process.exitCode = 1; + } } } else { - await mkdir(new URL('../src/generated/', import.meta.url), { recursive: true }); - await writeFile(output, source); + for (const output of outputs) { + await mkdir(new URL('.', output.url), { recursive: true }); + await writeFile(output.url, output.source()); + } } function event(events, position) { @@ -153,3 +185,15 @@ function numbers(values) { } return `[\n${rows.join(',\n')}\n]`; } + +function rustNumbers(values) { + const rows = []; + for (let index = 0; index < values.length; index += 24) { + rows.push(` ${values.slice(index, index + 24).join(', ')}`); + } + return `[\n${rows.join(',\n')}\n]`; +} + +function compactPartition(ranges) { + return ranges.flatMap(([, end, value]) => [end, value]); +} From 5e9c80bd804a0ac22bf54b92854c688a48d2dd0e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 14:23:11 -0400 Subject: [PATCH 030/128] feat(text): retain rust shaping runs --- docs/log.md | 7 + docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 8 + packages/text/rust/shaper/src/bidi.rs | 138 ++++++++-- packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/shaping_state.rs | 251 ++++++++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 183 ++++++++++++- .../rust/shaper/src/engine/style_state.rs | 4 +- 9 files changed, 568 insertions(+), 29 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/shaping_state.rs diff --git a/docs/log.md b/docs/log.md index e7494f5f..ae91190b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Retained bidi and shaping-run itemization moved inside `text_update`** — UAX #9 output now fills reusable + active/pending level, class, paragraph, and equal-level-run arrays. Root direction changes paragraph base level; + nested direction carries a distinct override bit and forces parity during one style×script×level interval sweep. + The sweep skips mandatory hard-break controls and commits/aborts with the session. Rust tests and host/SIMD Clippy + pass. Optimized Wasm is 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes (+4,067 / +1,899 / -2,304). Fallback + shaping, layout, nonempty plan output, and complete-path timing remain open. + - **Retained Unicode 17 analysis moved inside `text_update`** — The shared Unicode generator now emits compact Rust Script/Script_Extensions partitions beside the TypeScript tables. A no-std Unicode 17 grapheme iterator validates UTF-16, preserves UTF-16 boundaries, resolves contextual scripts, and reuses pre-reserved active/pending session diff --git a/docs/packages/text.md b/docs/packages/text.md index 5a39a349..8b129430 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:be6faa9b8d19faf5a430393a5a0732729388f324a502562309e8197c644f1df7' +source_digest: 'sha256:791c19ab170f263d40125c9ebb3fa08ff8b984f9d5891d6d63d7b423086b206b' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -693,7 +693,7 @@ The canonical composed Inter fixture proves GLB → registry → public `Text` The five-line, 120-glyph text above is the bounded conformance specimen. The separate live benchmark ipsum exceeds 1,000 characters and renders 1,150 glyphs through the same one-draw public `Text` path. -The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. The optimized shaper is 964,019 raw, 360,765 gzip, and 288,742 Brotli bytes at this checkpoint. Bidi/run intersection, fallback shaping, layout, and nonempty plan output remain open, so this size evidence carries no new frame latency claim. +The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. The optimized shaper is 968,086 raw, 362,664 gzip, and 286,438 Brotli bytes at this checkpoint. Fallback shaping, layout, and nonempty plan output remain open, so this size evidence carries no new frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index e18d5d6d..7dd94110 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -248,6 +248,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-181 | Nonempty style mutations are admitted only with their Rust consumer. Decoding borrows canonical fixed records and monotonically packed offset payloads without allocation. Each session pre-reserves two flat 64-style/512-language-byte/128-feature arenas and reusable mutation/order/nesting scratch. Mutations collapse by stable ID and merge with committed ID-sorted state into a compact inactive arena; no per-style vector or stale replaced payload survives. Rust validates stated versus absent bytes, numeric domains, language/tags, UTF-16 feature/range boundaries, binary-searched font-stack reachability, one complete root, unambiguous equal-range cascade order, proper nesting, and all request-section aliasing before plan preparation. Payload admission is linear and retained validation is O(n log n), with one reusable-scratch sort. Commit swaps arenas and abort preserves committed state. A compiled-Wasm real-font transaction commits text plus root style and rejects root removal without revision advance or post-creation memory growth. Optimized Wasm changes from 856,831 / 319,003 / 252,236 to 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes. Layout consumption and latency remain open. | Accepted | | D-182 | Rust resolves retained stated styles into a derived A/B segment arena before shaping. One containment sweep stores the fully resolved parent in pre-reserved scope scratch, applies each stated field once, restores parent values on close, and coalesces adjacent semantically equal segments. Stable identity is irrelevant to precedence; containment and explicit authored order govern equal ranges. Language and feature results reference retained compact payloads rather than copying per segment. Root font stack, logical size, and target density are required; line height may remain absent to select natural font metrics. A nested/equal-range proof emits five exact maximal segments and verifies inherited shaping, spacing, paint, material, language, and features. Optimized Wasm changes from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 raw/gzip/Brotli bytes. Unicode/run intersection, shaping, layout, and nonempty plan output remain open. | Accepted | | D-183 | Unicode analysis is derived session state inside the Rust frame transaction. The existing pinned Unicode 17 generator emits TypeScript tables and compact Rust Script/Script_Extensions partitions from one source; the Rust partition omits derivable starts. `unicode-segmentation` 1.13.3 runs under `no_std`, validates retained UTF-16 through a reusable UTF-8 scratch string, returns extended-grapheme boundaries to the public UTF-16 coordinate space, and feeds allocation-reusing contextual script itemization. Active and pending analysis arenas reserve with session text capacity, swap only on commit, abort with a failed frame, and are untouched when text is unchanged. Rust unit tests cover Emoji ZWJ, Indic/Kana scripts, shared marks, malformed surrogates, rollback, and capacity reuse; host/SIMD Clippy and compiled-Wasm lifecycle tests pass. Optimized Wasm changes from 895,593 / 335,396 / 264,355 to 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-184 | Bidi analysis and shaping-run itemization are derived A/B session state. `unicode-bidi` remains the UAX #9 algorithm and fills reusable level, class, paragraph, and equal-level-run arrays; text and root base-direction changes re-run it, while unchanged text/style skips it. Root direction selects the paragraph base level. A non-root stated LTR/RTL direction is retained as a distinct derived override, inherited through its scope, and forces only the intersected run's level parity. One forward interval sweep intersects maximal resolved styles, contextual script items, and equal-level bidi runs, excludes mandatory hard-break controls, and coalesces equal adjacent shaping records. Session text capacity pre-reserves active/pending bidi and run arrays. Unit tests cover output reuse, mixed Latin/Hebrew levels, root-only direction updates, nested override parity, hard-break exclusion, and transaction rollback; host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 964,019 / 360,765 / 288,742 to 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes. HarfRust fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 040bb70c..69a17312 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -857,6 +857,14 @@ derivable starts. Optimized Wasm measures 964,019 / 360,765 / 288,742 raw/gzip/B 895,593 / 335,396 / 264,355 checkpoint. This is shared runtime data and code, not per-font shaping payload. The number does not claim layout or shaping latency because neither has consumed these products yet. +Retained bidi and run-itemization now consume those products inside the same transaction. UAX #9 output is copied into +reusable active/pending level, class, paragraph, and equal-level-run arrays. Text or root base-direction changes re-run +bidi; unchanged text and style do not. Root direction selects paragraph base level, while a nested stated LTR/RTL value +is preserved separately as a run override and forces level parity only during one style×script×bidi interval sweep. +That sweep excludes mandatory hard-break controls and emits allocation-reusing shaping-run records. Optimized Wasm is +968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes (+4,067 / +1,899 / -2,304 from retained Unicode). HarfRust fallback +shaping has not consumed the runs yet, so plan output and complete-path timing remain open. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/bidi.rs b/packages/text/rust/shaper/src/bidi.rs index eacc0120..7cd28d16 100644 --- a/packages/text/rust/shaper/src/bidi.rs +++ b/packages/text/rust/shaper/src/bidi.rs @@ -20,15 +20,34 @@ pub enum BidiError { ResultTooLarge, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BidiRun { + pub text_start: u32, + pub text_end: u32, + pub level: u8, +} + +#[derive(Default)] pub struct BidiAnalysis { pub levels: Vec, pub classes: Vec, pub paragraph_starts: Vec, pub paragraph_ends: Vec, pub paragraph_levels: Vec, + pub runs: Vec, } pub fn analyze(text: &[u16], direction: u8) -> Result { + let mut output = BidiAnalysis::default(); + analyze_into(text, direction, &mut output)?; + Ok(output) +} + +pub fn analyze_into( + text: &[u16], + direction: u8, + output: &mut BidiAnalysis, +) -> Result<(), BidiError> { let default_level = match direction { DIRECTION_AUTO => None, DIRECTION_LTR => Some(LTR_LEVEL), @@ -36,34 +55,83 @@ pub fn analyze(text: &[u16], direction: u8) -> Result { _ => return Err(BidiError::InvalidDirection), }; let info = BidiInfo::new_with_data_source(&Unicode17BidiData, text, default_level); - let mut paragraph_starts = Vec::with_capacity(info.paragraphs.len().max(1)); - let mut paragraph_ends = Vec::with_capacity(info.paragraphs.len().max(1)); - let mut paragraph_levels = Vec::with_capacity(info.paragraphs.len().max(1)); + output.clear(); + output.reserve_for_analysis(text.len(), info.paragraphs.len().max(1))?; if info.paragraphs.is_empty() { - paragraph_starts.push(0); - paragraph_ends.push(0); - paragraph_levels.push(default_level.unwrap_or(LTR_LEVEL).number()); + output.paragraph_starts.push(0); + output.paragraph_ends.push(0); + output + .paragraph_levels + .push(default_level.unwrap_or(LTR_LEVEL).number()); } else { for paragraph in &info.paragraphs { - paragraph_starts + output + .paragraph_starts .push(u32::try_from(paragraph.range.start).map_err(|_| BidiError::ResultTooLarge)?); - paragraph_ends + output + .paragraph_ends .push(u32::try_from(paragraph.range.end).map_err(|_| BidiError::ResultTooLarge)?); - paragraph_levels.push(paragraph.level.number()); + output.paragraph_levels.push(paragraph.level.number()); } } - Ok(BidiAnalysis { - levels: info.levels.iter().map(|level| level.number()).collect(), - classes: info - .original_classes - .iter() - .copied() - .map(class_code) - .collect(), - paragraph_starts, - paragraph_ends, - paragraph_levels, - }) + output + .levels + .extend(info.levels.iter().map(|level| level.number())); + output + .classes + .extend(info.original_classes.iter().copied().map(class_code)); + let mut start = 0usize; + while start < output.levels.len() { + let level = output.levels[start]; + let mut end = start + 1; + while end < output.levels.len() && output.levels[end] == level { + end += 1; + } + output.runs.push(BidiRun { + text_start: u32::try_from(start).map_err(|_| BidiError::ResultTooLarge)?, + text_end: u32::try_from(end).map_err(|_| BidiError::ResultTooLarge)?, + level, + }); + start = end; + } + Ok(()) +} + +impl BidiAnalysis { + pub fn reserve(&mut self, text_capacity: usize) -> Result<(), BidiError> { + self.reserve_for_analysis(text_capacity, 1) + } + + fn reserve_for_analysis( + &mut self, + text_capacity: usize, + paragraph_capacity: usize, + ) -> Result<(), BidiError> { + reserve(&mut self.levels, text_capacity)?; + reserve(&mut self.classes, text_capacity)?; + reserve(&mut self.runs, text_capacity)?; + reserve(&mut self.paragraph_starts, paragraph_capacity)?; + reserve(&mut self.paragraph_ends, paragraph_capacity)?; + reserve(&mut self.paragraph_levels, paragraph_capacity) + } + + fn clear(&mut self) { + self.levels.clear(); + self.classes.clear(); + self.paragraph_starts.clear(); + self.paragraph_ends.clear(); + self.paragraph_levels.clear(); + self.runs.clear(); + } +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), BidiError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| BidiError::ResultTooLarge)?; + } + Ok(()) } pub struct Unicode17BidiData; @@ -160,4 +228,32 @@ mod tests { assert_eq!(result.levels[1], result.levels[2]); assert_eq!(result.classes[1], result.classes[2]); } + + #[test] + fn analysis_reuses_output_and_builds_equal_level_runs() { + let text: Vec = "abc אבג".encode_utf16().collect(); + let mut result = BidiAnalysis::default(); + result.reserve(32).unwrap(); + analyze_into(&text, DIRECTION_AUTO, &mut result).unwrap(); + let capacities = [ + result.levels.capacity(), + result.classes.capacity(), + result.runs.capacity(), + ]; + assert_eq!(result.runs.first().map(|run| run.text_start), Some(0)); + assert_eq!( + result.runs.last().map(|run| run.text_end), + Some(text.len() as u32) + ); + assert!(result.runs.iter().any(|run| run.level & 1 == 1)); + analyze_into(&text, DIRECTION_AUTO, &mut result).unwrap(); + assert_eq!( + [ + result.levels.capacity(), + result.classes.capacity(), + result.runs.capacity(), + ], + capacities + ); + } } diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index a0c92fa9..4889d826 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -27,6 +27,7 @@ pub mod render_plan; pub mod render_plan_compiler; pub(crate) mod render_plan_wire; mod semantic_wire; +mod shaping_state; #[cfg_attr(not(test), allow(dead_code))] mod stable_order; pub mod stable_plan; diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs new file mode 100644 index 00000000..626e9fdd --- /dev/null +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -0,0 +1,251 @@ +use alloc::vec::Vec; + +use crate::{ + bidi::BidiAnalysis, + unicode::{COMMON_SCRIPT, UnicodeAnalysis}, +}; + +use super::{ + EngineError, + style_state::{ResolvedStyle, StyleSegment}, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ShapingRun { + pub text_start: u32, + pub text_end: u32, + pub script: u32, + pub direction: u8, + pub bidi_level: u8, + pub style: ResolvedStyle, +} + +#[derive(Default)] +pub(crate) struct ShapingRunArena { + runs: Vec, +} + +impl ShapingRunArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + if self.runs.capacity() < capacity { + self.runs + .try_reserve_exact(capacity.saturating_sub(self.runs.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) + } + + pub(crate) fn build( + &mut self, + text: &[u16], + styles: &[StyleSegment], + unicode: &UnicodeAnalysis, + bidi: &BidiAnalysis, + ) -> Result<(), EngineError> { + self.runs.clear(); + if styles.is_empty() { + return Ok(()); + } + if text.is_empty() { + if let Some(segment) = styles.first() { + let level = bidi + .levels + .first() + .or_else(|| bidi.paragraph_levels.first()) + .copied() + .unwrap_or(0); + self.push(ShapingRun { + text_start: segment.text_start, + text_end: segment.text_start, + script: COMMON_SCRIPT, + direction: direction(segment.style, level), + bidi_level: forced_level(segment.style, level), + style: segment.style, + })?; + } + return Ok(()); + } + let scripts = unicode.script_items(); + let mut style_index = 0usize; + let mut script_index = 0usize; + let mut bidi_index = 0usize; + while style_index < styles.len() + && script_index < scripts.len() + && bidi_index < bidi.runs.len() + { + let style = styles[style_index]; + let script = scripts[script_index]; + let bidi_run = bidi.runs[bidi_index]; + let start = style + .text_start + .max(script.text_start) + .max(bidi_run.text_start); + let end = style.text_end.min(script.text_end).min(bidi_run.text_end); + if start < end { + self.push_drawable_fragments( + text, + start, + end, + ShapingRun { + text_start: start, + text_end: end, + script: script.script, + direction: direction(style.style, bidi_run.level), + bidi_level: forced_level(style.style, bidi_run.level), + style: style.style, + }, + )?; + } + let boundary = style.text_end.min(script.text_end).min(bidi_run.text_end); + if boundary <= start { + return Err(EngineError::InvalidRequest); + } + if style.text_end == boundary { + style_index += 1; + } + if script.text_end == boundary { + script_index += 1; + } + if bidi_run.text_end == boundary { + bidi_index += 1; + } + } + if style_index != styles.len() + || script_index != scripts.len() + || bidi_index != bidi.runs.len() + { + return Err(EngineError::InvalidRequest); + } + Ok(()) + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn runs(&self) -> &[ShapingRun] { + &self.runs + } + + pub(crate) fn clear(&mut self) { + self.runs.clear(); + } + + fn push_drawable_fragments( + &mut self, + text: &[u16], + start: u32, + end: u32, + template: ShapingRun, + ) -> Result<(), EngineError> { + let mut fragment_start = usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?; + let mut offset = fragment_start; + let end = usize::try_from(end).map_err(|_| EngineError::InvalidRequest)?; + while offset < end { + let unit = *text.get(offset).ok_or(EngineError::InvalidRequest)?; + let hard_break = matches!(unit, 0x0a | 0x0b | 0x0c | 0x0d | 0x85 | 0x2028 | 0x2029); + if hard_break { + if fragment_start < offset { + self.push(ShapingRun { + text_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + text_end: u32::try_from(offset).map_err(|_| EngineError::ResultTooLarge)?, + ..template + })?; + } + offset += 1; + fragment_start = offset; + } else { + offset += if (0xd800..=0xdbff).contains(&unit) { + 2 + } else { + 1 + }; + } + } + if fragment_start < end { + self.push(ShapingRun { + text_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + text_end: u32::try_from(end).map_err(|_| EngineError::ResultTooLarge)?, + ..template + })?; + } + Ok(()) + } + + fn push(&mut self, run: ShapingRun) -> Result<(), EngineError> { + if let Some(previous) = self.runs.last_mut() + && previous.text_end == run.text_start + && previous.script == run.script + && previous.direction == run.direction + && previous.bidi_level == run.bidi_level + && previous.style == run.style + { + previous.text_end = run.text_end; + return Ok(()); + } + self.runs + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + self.runs.push(run); + Ok(()) + } +} + +fn direction(style: ResolvedStyle, level: u8) -> u8 { + if style.bidi_override { + u8::from(style.direction == 2) + } else { + level & 1 + } +} + +fn forced_level(style: ResolvedStyle, level: u8) -> u8 { + let direction = direction(style, level); + if level & 1 == direction { + level + } else { + level.saturating_add(1) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + bidi::{DIRECTION_LTR, analyze}, + unicode::UnicodeAnalysis, + }; + + #[test] + fn intersects_style_script_and_bidi_and_skips_hard_breaks() { + let text: Vec = "abc\nאבג".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let bidi = analyze(&text, DIRECTION_LTR).unwrap(); + let base = ResolvedStyle::default(); + let mut override_style = base; + override_style.direction = 1; + override_style.bidi_override = true; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 4, + style: base, + }, + StyleSegment { + text_start: 4, + text_end: text.len() as u32, + style: override_style, + }, + ]; + let mut runs = ShapingRunArena::default(); + runs.reserve(16).unwrap(); + runs.build(&text, &styles, &unicode, &bidi).unwrap(); + assert_eq!( + runs.runs() + .iter() + .map(|run| (run.text_start, run.text_end, run.direction, run.bidi_level)) + .collect::>(), + vec![(0, 3, 0, 0), (4, 7, 0, 2)] + ); + } +} diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index c2bd3286..4f54737c 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1,6 +1,9 @@ use alloc::{collections::BTreeMap, vec::Vec}; -use crate::unicode::{UnicodeAnalysis, UnicodeError}; +use crate::{ + bidi::{BidiAnalysis, BidiError, DIRECTION_AUTO, analyze_into as analyze_bidi_into}, + unicode::{UnicodeAnalysis, UnicodeError}, +}; use super::{ font_binding::FontRenderBinding, @@ -11,6 +14,7 @@ use super::{ }, render_plan::RenderPlanView, render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, + shaping_state::ShapingRunArena, style_state::{ DEFAULT_STYLE_CAPACITY, MutationKey, ResolutionScope, ResolvedStyleArena, StyleArena, }, @@ -64,12 +68,18 @@ struct EngineSession { pending_resolved_styles: ResolvedStyleArena, unicode: UnicodeAnalysis, pending_unicode: UnicodeAnalysis, + bidi: BidiAnalysis, + pending_bidi: BidiAnalysis, + shaping_runs: ShapingRunArena, + pending_shaping_runs: ShapingRunArena, style_mutation_scratch: Vec, style_order_scratch: Vec, style_nesting_scratch: Vec, style_resolution_scratch: Vec, styles_prepared: bool, unicode_prepared: bool, + bidi_prepared: bool, + shaping_runs_prepared: bool, geometry_fingerprint: u64, pending_geometry_fingerprint: u64, geometry_prepared: bool, @@ -296,6 +306,10 @@ impl TextEngine { .pending_unicode .reserve(capacity) .map_err(unicode_error)?; + session.bidi.reserve(capacity).map_err(bidi_error)?; + session.pending_bidi.reserve(capacity).map_err(bidi_error)?; + session.shaping_runs.reserve(capacity)?; + session.pending_shaping_runs.reserve(capacity)?; Ok(()) } @@ -330,6 +344,14 @@ impl TextEngine { .ok_or(EngineError::SessionMissing) } + #[cfg(test)] + pub(crate) fn session_shaping_run_count(&self, handle: u32) -> Result { + self.sessions + .get(&handle) + .map(|session| session.shaping_runs.runs().len()) + .ok_or(EngineError::SessionMissing) + } + pub fn session_count(&self) -> u32 { self.sessions.len().try_into().unwrap_or(u32::MAX) } @@ -405,10 +427,25 @@ impl TextEngine { session.abort_styles(); return Err(error); } + if let Err(error) = session.prepare_bidi() { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + return Err(error); + } + if let Err(error) = session.prepare_shaping_runs() { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + return Err(error); + } if let Err(error) = session.prepare_geometry(request.geometry) { session.abort_text(); session.abort_styles(); session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); return Err(error); } if let Err(error) = gather.gather( @@ -429,6 +466,8 @@ impl TextEngine { session.abort_text(); session.abort_styles(); session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); session.abort_geometry(); return Err(gather_error(error)); } @@ -444,6 +483,8 @@ impl TextEngine { session.abort_text(); session.abort_styles(); session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); session.abort_geometry(); return Err(plan_error(error)); } @@ -492,6 +533,8 @@ impl TextEngine { session.abort_text(); session.abort_styles(); session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); session.abort_geometry(); Ok(()) } @@ -511,6 +554,8 @@ impl TextEngine { session.commit_text(); session.commit_styles(); session.commit_unicode(); + session.commit_bidi(); + session.commit_shaping_runs(); session.commit_geometry(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, @@ -661,6 +706,84 @@ impl EngineSession { self.abort_unicode(); } + fn prepare_bidi(&mut self) -> Result<(), EngineError> { + self.abort_bidi(); + if !self.text_prepared && !self.styles_prepared { + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let styles = if self.styles_prepared { + &self.pending_resolved_styles + } else { + &self.resolved_styles + }; + let direction = styles + .segments() + .first() + .map_or(DIRECTION_AUTO, |segment| segment.style.direction); + analyze_bidi_into(text, direction, &mut self.pending_bidi).map_err(bidi_error)?; + self.bidi_prepared = true; + Ok(()) + } + + fn abort_bidi(&mut self) { + self.bidi_prepared = false; + } + + fn commit_bidi(&mut self) { + if self.bidi_prepared { + core::mem::swap(&mut self.bidi, &mut self.pending_bidi); + } + self.abort_bidi(); + } + + fn prepare_shaping_runs(&mut self) -> Result<(), EngineError> { + self.abort_shaping_runs(); + if !self.text_prepared && !self.styles_prepared { + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let unicode = if self.unicode_prepared { + &self.pending_unicode + } else { + &self.unicode + }; + let bidi = if self.bidi_prepared { + &self.pending_bidi + } else { + &self.bidi + }; + self.pending_shaping_runs + .build(text, styles, unicode, bidi)?; + self.shaping_runs_prepared = true; + Ok(()) + } + + fn abort_shaping_runs(&mut self) { + self.pending_shaping_runs.clear(); + self.shaping_runs_prepared = false; + } + + fn commit_shaping_runs(&mut self) { + if self.shaping_runs_prepared { + core::mem::swap(&mut self.shaping_runs, &mut self.pending_shaping_runs); + } + self.abort_shaping_runs(); + } + fn prepare_geometry( &mut self, geometry: super::semantic_wire::GeometryBatch<'_>, @@ -747,6 +870,13 @@ fn unicode_error(error: UnicodeError) -> EngineError { } } +fn bidi_error(error: BidiError) -> EngineError { + match error { + BidiError::InvalidDirection => EngineError::InvalidRequest, + BidiError::ResultTooLarge => EngineError::ResultTooLarge, + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TextMutationError { Invalid, @@ -782,14 +912,16 @@ mod tests { ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, + bidi::DIRECTION_RTL, engine::{ font_binding::{ FieldTable, FontRenderBinding, FontResource, FontStrike, MISSING_RESOURCE_INDEX, }, frame::{ - STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_LINE_HEIGHT, - STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, - STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, + STYLE_FIELD_DIRECTION, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, + STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FLAG_ROOT, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + TEXT_MUTATION_REPLACE_UTF16, }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, @@ -1078,6 +1210,34 @@ mod tests { let session = engine.sessions.get(&4).unwrap(); assert!(session.text.is_empty()); assert!(session.unicode.grapheme_boundaries().is_empty()); + assert!(session.bidi.levels.is_empty()); + } + + #[test] + fn root_direction_reanalyzes_bidi_without_a_text_mutation() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.register_font_stack(7, &[42]).unwrap(); + engine.create_session(4).unwrap(); + + let text_bytes = text_mutation_bytes(&[(0, 0, &[0x61, 0x62, 0x63, 0x64])]); + let mut text = update(0, 0, 0); + text.text_mutations = + parse_text_mutations(&text_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(text, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + assert_eq!(engine.sessions.get(&4).unwrap().bidi.paragraph_levels, &[0]); + + let root_bytes = root_style_bytes_with_direction(7, DIRECTION_RTL); + let mut root = update(1, 1, 1); + root.style_mutations = + parse_style_mutations(&root_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(root, 2).unwrap(); + assert_eq!(engine.sessions.get(&4).unwrap().bidi.paragraph_levels, &[0]); + engine.commit_update(prepared).unwrap(); + assert_eq!(engine.sessions.get(&4).unwrap().bidi.paragraph_levels, &[1]); } #[test] @@ -1106,6 +1266,7 @@ mod tests { engine.commit_update(prepared).unwrap(); assert_eq!(engine.session_style_count(4), Ok(1)); assert_eq!(engine.session_style_segment_count(4), Ok(1)); + assert_eq!(engine.session_shaping_run_count(4), Ok(1)); let remove_bytes = remove_style_bytes(1); let mut remove = update(2, 2, 2); @@ -1293,6 +1454,14 @@ mod tests { } fn root_style_bytes(font_stack_handle: u32) -> Vec { + root_style_bytes_inner(font_stack_handle, None) + } + + fn root_style_bytes_with_direction(font_stack_handle: u32, direction: u8) -> Vec { + root_style_bytes_inner(font_stack_handle, Some(direction)) + } + + fn root_style_bytes_inner(font_stack_handle: u32, direction: Option) -> Vec { let record = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; let mut bytes = vec![0; record + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize]; bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_UPSERT; @@ -1304,7 +1473,8 @@ mod tests { STYLE_FIELD_FONT_STACK | STYLE_FIELD_FONT_SIZE | STYLE_FIELD_LINE_HEIGHT - | STYLE_FIELD_RASTER_PIXEL_RATIO, + | STYLE_FIELD_RASTER_PIXEL_RATIO + | direction.map_or(0, |_| STYLE_FIELD_DIRECTION), ); write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_TEXT_END, 4); write_u32( @@ -1327,6 +1497,9 @@ mod tests { record + abi::ENGINE_STYLE_MUTATION_RASTER_PIXEL_RATIO, 1.0, ); + if let Some(direction) = direction { + bytes[record + abi::ENGINE_STYLE_MUTATION_DIRECTION] = direction; + } bytes } diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs index 01df4779..7d315c46 100644 --- a/packages/text/rust/shaper/src/engine/style_state.rs +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -79,6 +79,7 @@ pub(crate) struct ResolvedStyle { pub baseline_shift: f32, pub raster_pixel_ratio: f32, pub direction: u8, + pub bidi_override: bool, pub foreground_rgba: u32, pub decoration_rgba: u32, pub decoration_flags: u32, @@ -120,6 +121,7 @@ impl Default for ResolvedStyle { baseline_shift: 0.0, raster_pixel_ratio: 1.0, direction: 0, + bidi_override: false, foreground_rgba: u32::MAX, decoration_rgba: 0, decoration_flags: 0, @@ -144,7 +146,6 @@ impl ResolvedStyleArena { self.segments.clear(); } - #[cfg(test)] pub(crate) fn segments(&self) -> &[StyleSegment] { &self.segments } @@ -535,6 +536,7 @@ fn apply_style(mut resolved: ResolvedStyle, style: RetainedStyle, source: usize) } if fields & STYLE_FIELD_DIRECTION != 0 { resolved.direction = style.direction; + resolved.bidi_override = !style.root && style.direction != 0; } if fields & STYLE_FIELD_FOREGROUND != 0 { resolved.foreground_rgba = style.foreground_rgba; From 59b980b752ca1b03228f5e46bb1beefda5cc1688 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 14:33:33 -0400 Subject: [PATCH 031/128] feat(text): shape retained rust runs --- docs/log.md | 7 + docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 9 ++ .../rust/shaper/src/engine/shaping_state.rs | 87 +++++++++++ packages/text/rust/shaper/src/engine/state.rs | 113 +++++++++++++- packages/text/rust/shaper/src/lib.rs | 141 ++++++++++++++---- packages/text/rust/shaper/src/wasm.rs | 6 +- .../integration/shaper-registration.test.mjs | 3 + 9 files changed, 337 insertions(+), 34 deletions(-) diff --git a/docs/log.md b/docs/log.md index ae91190b..57ca9ea6 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Primary HarfRust shaping now runs inside `text_update`** — A borrowed run view lets legacy batching and the retained + engine share the prewarmed UnicodeBuffer, UTF-16 context, and reusable feature scratch. Retained style payloads feed + HarfRust without an owned request, and glyph SoA appends directly into a pre-reserved A/B session arena. A real-Inter + compiled-Wasm proof observes shape-plan count 0→1 after the frame and no increase after abort. Rust tests and + host/SIMD Clippy pass. Optimized Wasm is 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes (+5,281 / +1,853 / + +1,504). Ordered fallback, layout, nonempty plan output, and complete timing remain open. + - **Retained bidi and shaping-run itemization moved inside `text_update`** — UAX #9 output now fills reusable active/pending level, class, paragraph, and equal-level-run arrays. Root direction changes paragraph base level; nested direction carries a distinct override bit and forces parity during one style×script×level interval sweep. diff --git a/docs/packages/text.md b/docs/packages/text.md index 8b129430..329155e0 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:791c19ab170f263d40125c9ebb3fa08ff8b984f9d5891d6d63d7b423086b206b' +source_digest: 'sha256:71d2cd291a88f5c8b1c2edc2076902d4f80c7ded422e85a4c0a5beda632bd8a5' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -693,7 +693,7 @@ The canonical composed Inter fixture proves GLB → registry → public `Text` The five-line, 120-glyph text above is the bounded conformance specimen. The separate live benchmark ipsum exceeds 1,000 characters and renders 1,150 glyphs through the same one-draw public `Text` path. -The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. The optimized shaper is 968,086 raw, 362,664 gzip, and 286,438 Brotli bytes at this checkpoint. Fallback shaping, layout, and nonempty plan output remain open, so this size evidence carries no new frame latency claim. +The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 7dd94110..70301362 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -249,6 +249,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-182 | Rust resolves retained stated styles into a derived A/B segment arena before shaping. One containment sweep stores the fully resolved parent in pre-reserved scope scratch, applies each stated field once, restores parent values on close, and coalesces adjacent semantically equal segments. Stable identity is irrelevant to precedence; containment and explicit authored order govern equal ranges. Language and feature results reference retained compact payloads rather than copying per segment. Root font stack, logical size, and target density are required; line height may remain absent to select natural font metrics. A nested/equal-range proof emits five exact maximal segments and verifies inherited shaping, spacing, paint, material, language, and features. Optimized Wasm changes from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 raw/gzip/Brotli bytes. Unicode/run intersection, shaping, layout, and nonempty plan output remain open. | Accepted | | D-183 | Unicode analysis is derived session state inside the Rust frame transaction. The existing pinned Unicode 17 generator emits TypeScript tables and compact Rust Script/Script_Extensions partitions from one source; the Rust partition omits derivable starts. `unicode-segmentation` 1.13.3 runs under `no_std`, validates retained UTF-16 through a reusable UTF-8 scratch string, returns extended-grapheme boundaries to the public UTF-16 coordinate space, and feeds allocation-reusing contextual script itemization. Active and pending analysis arenas reserve with session text capacity, swap only on commit, abort with a failed frame, and are untouched when text is unchanged. Rust unit tests cover Emoji ZWJ, Indic/Kana scripts, shared marks, malformed surrogates, rollback, and capacity reuse; host/SIMD Clippy and compiled-Wasm lifecycle tests pass. Optimized Wasm changes from 895,593 / 335,396 / 264,355 to 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | | D-184 | Bidi analysis and shaping-run itemization are derived A/B session state. `unicode-bidi` remains the UAX #9 algorithm and fills reusable level, class, paragraph, and equal-level-run arrays; text and root base-direction changes re-run it, while unchanged text/style skips it. Root direction selects the paragraph base level. A non-root stated LTR/RTL direction is retained as a distinct derived override, inherited through its scope, and forces only the intersected run's level parity. One forward interval sweep intersects maximal resolved styles, contextual script items, and equal-level bidi runs, excludes mandatory hard-break controls, and coalesces equal adjacent shaping records. Session text capacity pre-reserves active/pending bidi and run arrays. Unit tests cover output reuse, mixed Latin/Hebrew levels, root-only direction updates, nested override parity, hard-break exclusion, and transaction rollback; host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 964,019 / 360,765 / 288,742 to 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes. HarfRust fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-185 | Frame shaping consumes retained runs through a borrowed HarfRust input rather than constructing the legacy owned batch request. Legacy exports and `text_update` share the module-global prewarmed UnicodeBuffer, UTF-16 context scratch, and reusable 128-feature vector. Frame language/features borrow the active retained style arena. Each shaped run appends its font/source identity and glyph ID, UTF-16 cluster, advances, offsets, and flags directly into a pre-reserved A/B session SoA arena that swaps only on commit. The production Wasm update borrows the one existing `ShaperRegistry`; it does not duplicate font bytes or serialize shaped output through the old ABI. A compiled real-Inter test proves shape-plan cache count changes 0→1 only after `text_update` and remains unchanged after an aborted frame. Rust unit tests, host/SIMD Clippy, and focused compiled-Wasm tests pass. Optimized Wasm changes from 968,086 / 362,664 / 286,438 to 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes. This checkpoint shapes only each stack's primary font; ordered fallback, layout, nonempty plan output, and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 69a17312..a2030eb4 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -865,6 +865,15 @@ That sweep excludes mandatory hard-break controls and emits allocation-reusing s 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes (+4,067 / +1,899 / -2,304 from retained Unicode). HarfRust fallback shaping has not consumed the runs yet, so plan output and complete-path timing remain open. +Primary-font HarfRust shaping now consumes retained runs during Wasm `text_update`. The legacy batch export and frame +engine share one borrowed run view, actual prewarmed `UnicodeBuffer`, UTF-16 context scratch, and reusable 128-feature +scratch vector. Frame language/features borrow the retained style arena; glyph IDs, clusters, advances, offsets, flags, +and source-run/font records append directly into a pre-reserved A/B shape arena without constructing or serializing a +`ShapeBatchRequest`. A compiled real-Inter test observes plan-cache count 0→1 only after `text_update` and no increase +after an aborted update. Optimized Wasm is 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes (+5,281 / +1,853 / ++1,504). Ordered fallback is not yet applied, and layout/gather still receive no glyphs, so nonempty plan output and +complete-path timing remain open. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs index 626e9fdd..9690282f 100644 --- a/packages/text/rust/shaper/src/engine/shaping_state.rs +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -25,6 +25,26 @@ pub(crate) struct ShapingRunArena { runs: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ShapedRun { + pub source_run: u32, + pub font_handle: u32, + pub glyph_start: u32, + pub glyph_count: u32, +} + +#[derive(Default)] +pub(crate) struct ShapeArena { + pub runs: Vec, + pub glyph_ids: Vec, + pub clusters: Vec, + pub x_advances: Vec, + pub y_advances: Vec, + pub x_offsets: Vec, + pub y_offsets: Vec, + pub glyph_flags: Vec, +} + impl ShapingRunArena { pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { if self.runs.capacity() < capacity { @@ -190,6 +210,73 @@ impl ShapingRunArena { } } +impl ShapeArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve_vec(&mut self.runs, capacity)?; + reserve_vec(&mut self.glyph_ids, capacity)?; + reserve_vec(&mut self.clusters, capacity)?; + reserve_vec(&mut self.x_advances, capacity)?; + reserve_vec(&mut self.y_advances, capacity)?; + reserve_vec(&mut self.x_offsets, capacity)?; + reserve_vec(&mut self.y_offsets, capacity)?; + reserve_vec(&mut self.glyph_flags, capacity) + } + + pub(crate) fn clear(&mut self) { + self.runs.clear(); + self.glyph_ids.clear(); + self.clusters.clear(); + self.x_advances.clear(); + self.y_advances.clear(); + self.x_offsets.clear(); + self.y_offsets.clear(); + self.glyph_flags.clear(); + } + + pub(crate) fn append( + &mut self, + source_run: usize, + font_handle: u32, + shaped: &harfrust::GlyphBuffer, + ) -> Result<(), u32> { + let glyph_start = + u32::try_from(self.glyph_ids.len()).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?; + let glyph_count = + u32::try_from(shaped.len()).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?; + self.reserve(self.glyph_ids.len().saturating_add(shaped.len())) + .map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?; + self.runs.push(ShapedRun { + source_run: u32::try_from(source_run).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?, + font_handle, + glyph_start, + glyph_count, + }); + for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + self.glyph_ids + .push(u16::try_from(info.glyph_id).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?); + self.clusters.push(info.cluster); + self.x_advances.push(position.x_advance); + self.y_advances.push(position.y_advance); + self.x_offsets.push(position.x_offset); + self.y_offsets.push(position.y_offset); + self.glyph_flags.push( + u16::try_from(info.flags().to_bits()) + .map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?, + ); + } + Ok(()) + } +} + +fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + fn direction(style: ResolvedStyle, level: u8) -> u8 { if style.bidi_override { u8::from(style.direction == 2) diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 4f54737c..19711572 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1,6 +1,7 @@ use alloc::{collections::BTreeMap, vec::Vec}; use crate::{ + STATUS_RESULT_TOO_LARGE, ShapeRunRef, ShaperRegistry, bidi::{BidiAnalysis, BidiError, DIRECTION_AUTO, analyze_into as analyze_bidi_into}, unicode::{UnicodeAnalysis, UnicodeError}, }; @@ -14,7 +15,7 @@ use super::{ }, render_plan::RenderPlanView, render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, - shaping_state::ShapingRunArena, + shaping_state::{ShapeArena, ShapingRunArena}, style_state::{ DEFAULT_STYLE_CAPACITY, MutationKey, ResolutionScope, ResolvedStyleArena, StyleArena, }, @@ -72,6 +73,8 @@ struct EngineSession { pending_bidi: BidiAnalysis, shaping_runs: ShapingRunArena, pending_shaping_runs: ShapingRunArena, + shape: ShapeArena, + pending_shape: ShapeArena, style_mutation_scratch: Vec, style_order_scratch: Vec, style_nesting_scratch: Vec, @@ -80,6 +83,7 @@ struct EngineSession { unicode_prepared: bool, bidi_prepared: bool, shaping_runs_prepared: bool, + shape_prepared: bool, geometry_fingerprint: u64, pending_geometry_fingerprint: u64, geometry_prepared: bool, @@ -310,6 +314,9 @@ impl TextEngine { session.pending_bidi.reserve(capacity).map_err(bidi_error)?; session.shaping_runs.reserve(capacity)?; session.pending_shaping_runs.reserve(capacity)?; + let glyph_capacity = capacity.saturating_mul(2); + session.shape.reserve(glyph_capacity)?; + session.pending_shape.reserve(glyph_capacity)?; Ok(()) } @@ -356,10 +363,29 @@ impl TextEngine { self.sessions.len().try_into().unwrap_or(u32::MAX) } + #[cfg(test)] pub(crate) fn prepare_update( &mut self, request: UpdateRequest<'_>, publication_generation: u32, + ) -> Result { + self.prepare_update_inner(None, request, publication_generation) + } + + pub(crate) fn prepare_update_with_shaper( + &mut self, + shaper: &mut ShaperRegistry, + request: UpdateRequest<'_>, + publication_generation: u32, + ) -> Result { + self.prepare_update_inner(Some(shaper), request, publication_generation) + } + + fn prepare_update_inner( + &mut self, + shaper: Option<&mut ShaperRegistry>, + request: UpdateRequest<'_>, + publication_generation: u32, ) -> Result { if !request.limits.all_nonzero() { return Err(EngineError::InvalidRequest); @@ -440,12 +466,23 @@ impl TextEngine { session.abort_bidi(); return Err(error); } + if let Some(shaper) = shaper + && let Err(error) = session.prepare_shape(shaper, font_stacks) + { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); + return Err(error); + } if let Err(error) = session.prepare_geometry(request.geometry) { session.abort_text(); session.abort_styles(); session.abort_unicode(); session.abort_bidi(); session.abort_shaping_runs(); + session.abort_shape(); return Err(error); } if let Err(error) = gather.gather( @@ -468,6 +505,7 @@ impl TextEngine { session.abort_unicode(); session.abort_bidi(); session.abort_shaping_runs(); + session.abort_shape(); session.abort_geometry(); return Err(gather_error(error)); } @@ -485,6 +523,7 @@ impl TextEngine { session.abort_unicode(); session.abort_bidi(); session.abort_shaping_runs(); + session.abort_shape(); session.abort_geometry(); return Err(plan_error(error)); } @@ -535,6 +574,7 @@ impl TextEngine { session.abort_unicode(); session.abort_bidi(); session.abort_shaping_runs(); + session.abort_shape(); session.abort_geometry(); Ok(()) } @@ -556,6 +596,7 @@ impl TextEngine { session.commit_unicode(); session.commit_bidi(); session.commit_shaping_runs(); + session.commit_shape(); session.commit_geometry(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, @@ -784,6 +825,68 @@ impl EngineSession { self.abort_shaping_runs(); } + fn prepare_shape( + &mut self, + shaper: &mut ShaperRegistry, + font_stacks: &[RegisteredFontStack], + ) -> Result<(), EngineError> { + self.abort_shape(); + if !self.shaping_runs_prepared { + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let styles = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; + let runs = self.pending_shaping_runs.runs(); + let output = &mut self.pending_shape; + for (index, run) in runs.iter().copied().enumerate() { + let stack = font_stacks + .binary_search_by_key(&run.style.font_stack_handle, |stack| stack.handle) + .ok() + .and_then(|stack| font_stacks.get(stack)) + .ok_or(EngineError::FontStackMissing)?; + let font_handle = *stack.fonts.first().ok_or(EngineError::FontStackMissing)?; + shaper + .with_shaped_run( + font_handle, + text, + ShapeRunRef { + text_start: run.text_start, + text_end: run.text_end, + script: run.script, + language: styles.resolved_language(run.style), + features: styles.resolved_features(run.style), + direction: run.direction, + cluster_level: 0, + flags: 0x40, + }, + |shaped| output.append(index, font_handle, shaped), + ) + .map_err(shaper_error)?; + } + self.shape_prepared = true; + Ok(()) + } + + fn abort_shape(&mut self) { + self.pending_shape.clear(); + self.shape_prepared = false; + } + + fn commit_shape(&mut self) { + if self.shape_prepared { + core::mem::swap(&mut self.shape, &mut self.pending_shape); + } + self.abort_shape(); + } + fn prepare_geometry( &mut self, geometry: super::semantic_wire::GeometryBatch<'_>, @@ -877,6 +980,14 @@ fn bidi_error(error: BidiError) -> EngineError { } } +fn shaper_error(status: u32) -> EngineError { + if status == STATUS_RESULT_TOO_LARGE { + EngineError::ResultTooLarge + } else { + EngineError::InvalidRequest + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TextMutationError { Invalid, diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 68e5b60a..f8a34734 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -40,12 +40,14 @@ pub const STATUS_FONT_IN_USE: u32 = 14; const BUFFER_FLAGS_MASK: u32 = 0xff; const MAX_CACHED_PLANS_PER_FONT: usize = 64; const DEFAULT_SHAPE_BUFFER_CAPACITY: usize = 32_768; +const DEFAULT_SHAPE_FEATURE_CAPACITY: usize = 128; pub struct ShaperRegistry { fonts: BTreeMap, result: ResultArena, shape_buffer: Option, context_codepoints: Vec, + shape_features: Vec, } impl Default for ShaperRegistry { @@ -55,6 +57,7 @@ impl Default for ShaperRegistry { result: ResultArena::default(), shape_buffer: Some(UnicodeBuffer::new()), context_codepoints: Vec::new(), + shape_features: Vec::new(), } } } @@ -131,6 +134,33 @@ struct SegmentRange { flags: u32, } +#[derive(Clone, Copy)] +pub(crate) struct ShapeRunRef<'a> { + pub text_start: u32, + pub text_end: u32, + pub script: u32, + pub language: Option<&'a [u8]>, + pub features: &'a [FeatureRecord], + pub direction: u8, + pub cluster_level: u8, + pub flags: u32, +} + +impl<'a> From<&'a RunRequest> for ShapeRunRef<'a> { + fn from(run: &'a RunRequest) -> Self { + Self { + text_start: run.text_start, + text_end: run.text_end, + script: run.script, + language: run.language.as_deref(), + features: &run.features, + direction: run.direction, + cluster_level: run.cluster_level, + flags: run.flags, + } + } +} + pub struct ShapeBatchRequest { pub text: Vec, pub runs: Vec, @@ -162,6 +192,9 @@ impl ShaperRegistry { .try_reserve_exact( DEFAULT_SHAPE_BUFFER_CAPACITY.saturating_sub(self.context_codepoints.len()), ) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + self.shape_features + .try_reserve_exact(DEFAULT_SHAPE_FEATURE_CAPACITY) .map_err(|_| STATUS_RESULT_TOO_LARGE) } @@ -271,6 +304,7 @@ impl ShaperRegistry { ) }; let run = &request.runs[run_index]; + let run_ref = ShapeRunRef::from(run); let slot = if let Some(slot) = font_slots.get(&run.font_handle) { *slot } else { @@ -289,10 +323,11 @@ impl ShaperRegistry { let shaped = shape_segment( font, &request.text, - run, + run_ref, range, &mut self.shape_buffer, &mut self.context_codepoints, + &mut self.shape_features, )?; let append_result: Result<(), u32> = (|| { let glyph_count = @@ -322,6 +357,38 @@ impl ShaperRegistry { Ok(output) } + pub(crate) fn with_shaped_run( + &mut self, + font_handle: u32, + text: &[u16], + run: ShapeRunRef<'_>, + consume: impl FnOnce(&harfrust::GlyphBuffer) -> Result, + ) -> Result { + let font = self + .fonts + .get_mut(&font_handle) + .ok_or(STATUS_FONT_MISSING)?; + let range = SegmentRange { + item_start: run.text_start, + item_end: run.text_end, + context_start: run.text_start, + context_end: run.text_end, + flags: run.flags, + }; + let shaped = shape_segment( + font, + text, + run, + range, + &mut self.shape_buffer, + &mut self.context_codepoints, + &mut self.shape_features, + )?; + let result = consume(&shaped); + self.shape_buffer = Some(shaped.clear()); + result + } + pub fn clear_result(&mut self) { self.result.clear(); } @@ -475,20 +542,28 @@ fn validate_request( fn shape_segment( font: &mut RegisteredFont, text: &[u16], - run: &RunRequest, + run: ShapeRunRef<'_>, range: SegmentRange, buffer_slot: &mut Option, context_codepoints: &mut Vec, + features: &mut Vec, ) -> Result { let mut buffer = buffer_slot.take().ok_or(STATUS_INVALID_REQUEST)?; - let features = - match shape_segment_inner(font, text, run, range, &mut buffer, context_codepoints) { - Ok(features) => features, - Err(status) => { - *buffer_slot = Some(buffer); - return Err(status); - } - }; + match shape_segment_inner( + font, + text, + run, + range, + &mut buffer, + context_codepoints, + features, + ) { + Ok(()) => {} + Err(status) => { + *buffer_slot = Some(buffer); + return Err(status); + } + } let font_ref = match FontRef::new(&font.sfnt) { Ok(font_ref) => font_ref, @@ -509,7 +584,7 @@ fn shape_segment( Ok(shaper.shape( buffer, ShapeOptions::new() - .features(&features) + .features(features) .plan(Some(plan)) .font_funcs(Some(&mut extents)), )) @@ -518,11 +593,12 @@ fn shape_segment( fn shape_segment_inner( font: &mut RegisteredFont, text: &[u16], - run: &RunRequest, + run: ShapeRunRef<'_>, range: SegmentRange, buffer: &mut UnicodeBuffer, context_codepoints: &mut Vec, -) -> Result, u32> { + features: &mut Vec, +) -> Result<(), u32> { let direction = match run.direction { 0 => Direction::LeftToRight, 1 => Direction::RightToLeft, @@ -532,10 +608,9 @@ fn shape_segment_inner( .ok_or(STATUS_INVALID_REQUEST)?; let language = run .language - .as_ref() .map(|value| parse_language(value).ok_or(STATUS_INVALID_REQUEST)) .transpose()?; - let features = shape_features(run, range); + shape_features(run, range, features)?; let key = PlanKey { direction: run.direction, script: run.script, @@ -560,7 +635,7 @@ fn shape_segment_inner( direction, Some(script), language.as_ref(), - &features, + features, ); if font.plans.len() == MAX_CACHED_PLANS_PER_FONT { font.plans.remove(0); @@ -598,22 +673,28 @@ fn shape_segment_inner( }); buffer.set_flags(BufferFlags::from_bits(range.flags).ok_or(STATUS_INVALID_REQUEST)?); - Ok(features) + Ok(()) } -fn shape_features(run: &RunRequest, range: SegmentRange) -> Vec { - run.features - .iter() - .map(|feature| { - let global = feature.start <= range.item_start && feature.end >= range.item_end; - Feature { - tag: Tag::from_be_bytes(feature.tag.to_be_bytes()), - value: feature.value, - start: if global { 0 } else { feature.start }, - end: if global { u32::MAX } else { feature.end }, - } - }) - .collect() +fn shape_features( + run: ShapeRunRef<'_>, + range: SegmentRange, + output: &mut Vec, +) -> Result<(), u32> { + output.clear(); + output + .try_reserve(run.features.len()) + .map_err(|_| STATUS_RESULT_TOO_LARGE)?; + output.extend(run.features.iter().map(|feature| { + let global = feature.start <= range.item_start && feature.end >= range.item_end; + Feature { + tag: Tag::from_be_bytes(feature.tag.to_be_bytes()), + value: feature.value, + start: if global { 0 } else { feature.start }, + end: if global { u32::MAX } else { feature.end }, + } + })); + Ok(()) } struct FlatExtents<'a> { diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 8910ce31..fc87a0ec 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -519,7 +519,11 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( return publish_failure(state, session_id, revision, STATUS_RESULT_TOO_LARGE, 0, 0); } }; - let prepared = match state.engine.prepare_update(request, publication_generation) { + let prepared = match state.engine.prepare_update_with_shaper( + &mut state.registry, + request, + publication_generation, + ) { Ok(prepared) => prepared, Err(error) => { return publish_failure(state, session_id, revision, engine_status(error), 0, 0); diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 8e463ed8..07d9fb02 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -155,6 +155,7 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo fontStackHandle: 17, text: [0x61, 0x62, 0x63, 0x64], }); + assert.equal(fn.planCount(), 0); let requestPointer = fn.requestPointer(29); new Uint8Array(memory.buffer, requestPointer, initialUpdate.byteLength).set(initialUpdate); let resultPointer = fn.textUpdate(29, requestPointer, initialUpdate.byteLength); @@ -162,6 +163,7 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo let result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 1); + assert.equal(fn.planCount(), 1, 'text_update must shape retained runs through HarfRust'); const removeRoot = engineStyleUpdateBytes(abi, { sessionId: 29, @@ -179,6 +181,7 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.invalidRequest); assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 1); + assert.equal(fn.planCount(), 1, 'an aborted update must not perform another shape'); assert.equal(fn.disposeSession(29), abi.status.ok); assert.equal(fn.disposePolicy(23), abi.status.ok); From 7b0de7595d9a30da4aab70789bb6673b7349c1e8 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 14:46:19 -0400 Subject: [PATCH 032/128] feat(text): resolve rust font fallback --- docs/packages/text.md | 2 +- docs/planning/decision-register.md | 3 +- docs/planning/rust-layout-engine.md | 45 ++- .../rust/shaper/src/engine/shaping_state.rs | 6 + packages/text/rust/shaper/src/engine/state.rs | 319 ++++++++++++++++-- .../integration/shaper-registration.test.mjs | 73 ++++ 6 files changed, 407 insertions(+), 41 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index 329155e0..0d5ee9cb 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:71d2cd291a88f5c8b1c2edc2076902d4f80c7ded422e85a4c0a5beda632bd8a5' +source_digest: 'sha256:9e95f07ada11bc61a3fd6f889a6412577e36af87be1142e505c09dd2c9d84923' tags: [package, public-api, typescript, contracts] sources: - id: manifest diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 70301362..a58e0841 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -238,7 +238,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | | D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 88-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry stable identity separately from authored cascade order, stated-field presence, shaping, spacing, raster density, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty style sections and carries no shaping/layout timing claim. | Accepted | | D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | -| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms both retained UTF-16 transaction buffers to 1,024 units by default; `createSession` and `reserveSession` accept an explicit text capacity so a known large paragraph can reserve both before its first update. The synchronous Rust analysis/shaping/layout workspace is engine-global and reused across sessions; each production array prewarms once to 32,768 clusters/glyphs as it lands, covering the 25,515 target fixture, with explicit cold growth beyond that envelope. The compiler-published `initialize()` export runs immediately after Wasm instantiation and owns policy-independent workspace reservation; D-178 lands the plan-glyph arena, and the following checkpoint reserves and reuses HarfRust's actual internal buffer plus UTF-16 context scratch. Initialization now settles 57 Wasm pages and is identity-stable when repeated. Policy registration cold-reserves its exact field lanes. Bidi, cluster, line, and geometry workspaces remain open; legacy batch-result vectors are outside the final frame claim. Warm `text_update` may not perform lazy capacity settlement within declared envelopes. This preserves first-use latency without multiplying the full shaping workspace by the number of sessions. | Accepted | +| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms retained active/pending UTF-16, Unicode, bidi, shaping-run, shaped-glyph, and fallback arrays to 1,024 text units by default, with shaped-glyph capacity at 2×; `createSession` and `reserveSession` accept an explicit text capacity and retain the resulting high-water mark. These arrays are session-owned because committed results must survive across multiple live sessions. The compiler-published `initialize()` export runs after Wasm instantiation and prewarms reusable module-global synchronous scratch: the 32,768-entry policy-gather arena, HarfRust's actual 32,768-codepoint buffer, and UTF-16 context scratch, covering the 25,515 target fixture without multiplying that largest reservation per session. Initialization settles 57 Wasm pages and is identity-stable when repeated. Policy registration cold-reserves its exact field lanes. Line and geometry-output workspaces remain open; legacy batch-result vectors are outside the final frame claim. Warm `text_update` may not lazily settle capacity within declared envelopes. | Accepted | | D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | | D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | | D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | @@ -250,6 +250,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-183 | Unicode analysis is derived session state inside the Rust frame transaction. The existing pinned Unicode 17 generator emits TypeScript tables and compact Rust Script/Script_Extensions partitions from one source; the Rust partition omits derivable starts. `unicode-segmentation` 1.13.3 runs under `no_std`, validates retained UTF-16 through a reusable UTF-8 scratch string, returns extended-grapheme boundaries to the public UTF-16 coordinate space, and feeds allocation-reusing contextual script itemization. Active and pending analysis arenas reserve with session text capacity, swap only on commit, abort with a failed frame, and are untouched when text is unchanged. Rust unit tests cover Emoji ZWJ, Indic/Kana scripts, shared marks, malformed surrogates, rollback, and capacity reuse; host/SIMD Clippy and compiled-Wasm lifecycle tests pass. Optimized Wasm changes from 895,593 / 335,396 / 264,355 to 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | | D-184 | Bidi analysis and shaping-run itemization are derived A/B session state. `unicode-bidi` remains the UAX #9 algorithm and fills reusable level, class, paragraph, and equal-level-run arrays; text and root base-direction changes re-run it, while unchanged text/style skips it. Root direction selects the paragraph base level. A non-root stated LTR/RTL direction is retained as a distinct derived override, inherited through its scope, and forces only the intersected run's level parity. One forward interval sweep intersects maximal resolved styles, contextual script items, and equal-level bidi runs, excludes mandatory hard-break controls, and coalesces equal adjacent shaping records. Session text capacity pre-reserves active/pending bidi and run arrays. Unit tests cover output reuse, mixed Latin/Hebrew levels, root-only direction updates, nested override parity, hard-break exclusion, and transaction rollback; host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 964,019 / 360,765 / 288,742 to 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes. HarfRust fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | | D-185 | Frame shaping consumes retained runs through a borrowed HarfRust input rather than constructing the legacy owned batch request. Legacy exports and `text_update` share the module-global prewarmed UnicodeBuffer, UTF-16 context scratch, and reusable 128-feature vector. Frame language/features borrow the active retained style arena. Each shaped run appends its font/source identity and glyph ID, UTF-16 cluster, advances, offsets, and flags directly into a pre-reserved A/B session SoA arena that swaps only on commit. The production Wasm update borrows the one existing `ShaperRegistry`; it does not duplicate font bytes or serialize shaped output through the old ABI. A compiled real-Inter test proves shape-plan cache count changes 0→1 only after `text_update` and remains unchanged after an aborted frame. Rust unit tests, host/SIMD Clippy, and focused compiled-Wasm tests pass. Optimized Wasm changes from 968,086 / 362,664 / 286,438 to 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes. This checkpoint shapes only each stack's primary font; ordered fallback, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-186 | Ordered fallback is resolved inside the same Rust frame transaction from actual HarfRust `.notdef` output, never from `cmap`, raster coverage, or a host callback. Reusable flat spans name source run, UTF-16 range, stack index, and concrete font; reusable cluster records collapse multi-glyph clusters with missing status ORed across glyph zero. Records sort by source run/logical cluster to normalize RTL output, then one linear merge advances only missing ranges. Font index increases monotonically, bounding passes by stack depth. Final spans and shaped SoA commit together and abort together. A compiled Inter→Noto Devanagari `text_update` constructs exactly two HarfRust plans, causally proving primary and fallback shaping. Host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 973,367 / 364,517 / 287,942 to 982,356 / 368,183 / 290,439 raw/gzip/Brotli bytes. Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index a2030eb4..2616e13e 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -370,12 +370,14 @@ excess buffers become unreachable and are collected on the worker. Failure to re not permission to grow an unbounded pool. GPU staging belts and submission fences remain backend responsibilities. [^staging][^worker-transfer] -Cold capacity separates per-session retained state from shared synchronous work. Session creation prewarms both UTF-16 -transaction buffers to 1,024 units unless the caller supplies a larger text capacity; cold reserve can grow both before -the request view is pinned. The analysis/shaping/layout arrays are one engine-global workspace reused by synchronous -updates, not one 25K-glyph allocation per paragraph. Those production arrays prewarm once to 32,768 clusters/glyphs when -they land, covering the 25,515-glyph target fixture, and expose explicit cold growth beyond that envelope. A warm update -inside declared capacities may not lazily settle another allocation. +Cold capacity separates retained per-session state from shared synchronous scratch. Session creation prewarms both +UTF-16 transaction buffers and the active/pending Unicode, bidi, shaping-run, shaped-glyph, and fallback arrays to 1,024 +UTF-16 units unless the caller supplies a larger text capacity; shaped-glyph capacity begins at twice that value. Cold +reserve can grow them before the request view is pinned, and committed high-water capacity is reused. Retained A/B state +cannot be one engine-global workspace because more than one live session must preserve its committed result. HarfRust's +consumed-and-returned shaping buffer and policy gather are module-global scratch reused by synchronous updates; those +scratch arrays prewarm once to 32,768 entries, covering the 25,515-glyph target fixture. A warm update inside declared +capacities may not lazily settle another allocation. Module initialization is explicit rather than an incidental side effect of the first operational export. The generated ABI publishes `initialize()`, and the standard host calls it immediately after `WebAssembly.instantiate`; this eagerly @@ -387,8 +389,9 @@ after every successful segment and every fallible setup path instead of construc Initialization grows the optimized module from 1,245,184 to 4,980,736 linear-memory bytes (57 pages), of which 25 pages are the HarfRust/context addition, and repeated initialization preserves byte length and `memory.buffer` identity. Policy-specific aligned field lanes settle at cold policy registration. The legacy exported batch-result vectors still -settle independently and are not evidence for the new frame path; Unicode bidi, clusters, lines, and geometry arrays -remain unimplemented and therefore are not yet included in the zero-allocation frame claim. +settle independently and are not evidence for the new frame path. Retained Unicode, bidi, shaping-run, shaped-glyph, and +fallback arrays now settle at session creation/reservation; line composition and geometry-output arrays have not landed +and are not included in the zero-allocation frame claim. ## Rust layout pipeline @@ -604,14 +607,17 @@ fallback mechanism. It removes the old raster-homogeneity restriction: every mem but each loaded font carries its own technique and resource binding. Fallback remains a shaping decision about glyph availability, not a renderer eligibility decision. -The Rust engine now cold-registers stack identity as a nonempty, duplicate-free ordered list of already registered +The Rust engine cold-registers stack identity as a nonempty, duplicate-free ordered list of already registered shaping-font handles. Equivalent registration is idempotent; conflicting order fails, and a member font cannot be -disposed while any registered stack retains it. Technique/resource data is deliberately not duplicated in the stack: -the next cold binding layer attaches those tables once to the loaded font. A real-font compiled-Wasm test proves the -registration and disposal lifecycle; fallback shaping itself has not yet moved into the update transaction. Because -stack lifecycle is cold and cardinality is normally small, the selected registry uses a compact vector. A generic tree -map measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes; removing that monomorphization reduced the artifact to -828,401 / 309,252 / 244,402 without changing the ABI or lookup result. +disposed while any registered stack retains it. Technique/resource data is deliberately not duplicated in the stack. +During `text_update`, HarfRust output is collapsed to logical cluster records; only clusters containing an actual glyph +zero advance to the next registered font. Flat source-ordered spans are reshaped at most once per stack depth and then +retained with the final shaped SoA. Sorting by source-run/cluster restores logical order for RTL output before one linear +span merge. A compiled Inter-to-Noto-Devanagari test observes two plan constructions in the same update, proving the +primary `.notdef` pass and fallback pass both ran. Stack lifecycle is cold and cardinality is normally small, so the +selected registry uses a compact vector. A generic tree map measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes; +removing that monomorphization reduced the artifact to 828,401 / 309,252 / 244,402 without changing the ABI or lookup +result. RGBA8 costs four GPU bytes per texel versus one for the grayscale R8 bitmap path; selected coverage and independently resident pages are therefore mandatory, and the payload report keeps color pages separate.[^renderer-capabilities] @@ -874,6 +880,15 @@ after an aborted update. Optimized Wasm is 973,367 / 364,517 / 287,942 raw/gzip/ +1,504). Ordered fallback is not yet applied, and layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. +Ordered fallback now consumes the actual shaped result rather than consulting `cmap` or raster coverage. Reusable flat +span and logical-cluster arrays preserve the source run, UTF-16 range, selected stack index, and concrete font. Each pass +shapes current spans, marks a cluster missing when any constituent glyph is zero, restores logical order across RTL +output, and advances only that range to the next font. The pass count is bounded by stack depth; the final spans and +glyph SoA commit together, while any later frame failure discards both pending values. A compiled Inter-to-Noto +Devanagari update constructs exactly two HarfRust plans. Optimized Wasm is 982,356 / 368,183 / 290,439 raw/gzip/Brotli +bytes (+8,989 / +3,666 / +2,497). Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing +remain open. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs index 9690282f..8fe03271 100644 --- a/packages/text/rust/shaper/src/engine/shaping_state.rs +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -29,6 +29,8 @@ pub(crate) struct ShapingRunArena { pub(crate) struct ShapedRun { pub source_run: u32, pub font_handle: u32, + pub text_start: u32, + pub text_end: u32, pub glyph_start: u32, pub glyph_count: u32, } @@ -237,6 +239,8 @@ impl ShapeArena { &mut self, source_run: usize, font_handle: u32, + text_start: u32, + text_end: u32, shaped: &harfrust::GlyphBuffer, ) -> Result<(), u32> { let glyph_start = @@ -248,6 +252,8 @@ impl ShapeArena { self.runs.push(ShapedRun { source_run: u32::try_from(source_run).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?, font_handle, + text_start, + text_end, glyph_start, glyph_count, }); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 19711572..e95ccf0c 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -54,6 +54,22 @@ struct RegisteredFontStack { fonts: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct FallbackSpan { + source_run: u32, + text_start: u32, + text_end: u32, + font_index: u16, + font_handle: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ClusterRecord { + source_run: u32, + cluster: u32, + missing: bool, +} + #[derive(Default)] struct EngineSession { revision: SessionRevision, @@ -75,6 +91,10 @@ struct EngineSession { pending_shaping_runs: ShapingRunArena, shape: ShapeArena, pending_shape: ShapeArena, + fallback_spans: Vec, + pending_fallback_spans: Vec, + fallback_span_scratch: Vec, + fallback_cluster_scratch: Vec, style_mutation_scratch: Vec, style_order_scratch: Vec, style_nesting_scratch: Vec, @@ -317,6 +337,10 @@ impl TextEngine { let glyph_capacity = capacity.saturating_mul(2); session.shape.reserve(glyph_capacity)?; session.pending_shape.reserve(glyph_capacity)?; + reserve_vec(&mut session.fallback_spans, capacity)?; + reserve_vec(&mut session.pending_fallback_spans, capacity)?; + reserve_vec(&mut session.fallback_span_scratch, capacity)?; + reserve_vec(&mut session.fallback_cluster_scratch, glyph_capacity)?; Ok(()) } @@ -845,44 +869,163 @@ impl EngineSession { &self.styles }; let runs = self.pending_shaping_runs.runs(); - let output = &mut self.pending_shape; + let mut max_stack_depth = 0usize; for (index, run) in runs.iter().copied().enumerate() { - let stack = font_stacks - .binary_search_by_key(&run.style.font_stack_handle, |stack| stack.handle) - .ok() - .and_then(|stack| font_stacks.get(stack)) - .ok_or(EngineError::FontStackMissing)?; + let stack = find_font_stack(font_stacks, run.style.font_stack_handle)?; let font_handle = *stack.fonts.first().ok_or(EngineError::FontStackMissing)?; - shaper - .with_shaped_run( + max_stack_depth = max_stack_depth.max(stack.fonts.len()); + push_fallback_span( + &mut self.pending_fallback_spans, + FallbackSpan { + source_run: u32::try_from(index).map_err(|_| EngineError::ResultTooLarge)?, + text_start: run.text_start, + text_end: run.text_end, + font_index: 0, font_handle, - text, - ShapeRunRef { - text_start: run.text_start, - text_end: run.text_end, - script: run.script, - language: styles.resolved_language(run.style), - features: styles.resolved_features(run.style), - direction: run.direction, - cluster_level: 0, - flags: 0x40, - }, - |shaped| output.append(index, font_handle, shaped), - ) - .map_err(shaper_error)?; + }, + )?; } - self.shape_prepared = true; - Ok(()) + for _ in 0..max_stack_depth.max(1) { + self.pending_shape.clear(); + for span in self.pending_fallback_spans.iter().copied() { + let source_index = + usize::try_from(span.source_run).map_err(|_| EngineError::InvalidRequest)?; + let run = *runs.get(source_index).ok_or(EngineError::InvalidRequest)?; + let output = &mut self.pending_shape; + shaper + .with_shaped_run( + span.font_handle, + text, + ShapeRunRef { + text_start: span.text_start, + text_end: span.text_end, + script: run.script, + language: styles.resolved_language(run.style), + features: styles.resolved_features(run.style), + direction: run.direction, + cluster_level: 0, + flags: 0x40, + }, + |shaped| { + output.append( + source_index, + span.font_handle, + span.text_start, + span.text_end, + shaped, + ) + }, + ) + .map_err(shaper_error)?; + } + collect_cluster_records(&self.pending_shape, &mut self.fallback_cluster_scratch)?; + self.fallback_span_scratch.clear(); + let mut changed = false; + let mut cluster_index = 0usize; + for span in self.pending_fallback_spans.iter().copied() { + while self + .fallback_cluster_scratch + .get(cluster_index) + .is_some_and(|record| { + record.source_run < span.source_run + || (record.source_run == span.source_run + && record.cluster < span.text_start) + }) + { + cluster_index += 1; + } + let stack_handle = runs + .get(span.source_run as usize) + .ok_or(EngineError::InvalidRequest)? + .style + .font_stack_handle; + let stack = find_font_stack(font_stacks, stack_handle)?; + let next_font_index = span.font_index.checked_add(1); + let next_font = + next_font_index.and_then(|index| stack.fonts.get(usize::from(index)).copied()); + let mut cursor = span.text_start; + let mut record_index = cluster_index; + while let Some(record) = self.fallback_cluster_scratch.get(record_index).copied() { + if record.source_run != span.source_run || record.cluster >= span.text_end { + break; + } + if record.missing + && let (Some(font_index), Some(font_handle)) = (next_font_index, next_font) + { + let cluster_start = record.cluster.max(cursor); + let cluster_end = self + .fallback_cluster_scratch + .get(record_index + 1) + .filter(|next| next.source_run == span.source_run) + .map_or_else( + || { + runs.get(span.source_run as usize) + .map_or(span.text_end, |run| run.text_end) + }, + |next| next.cluster, + ) + .min(span.text_end); + if cursor < cluster_start { + push_fallback_span( + &mut self.fallback_span_scratch, + FallbackSpan { + text_start: cursor, + text_end: cluster_start, + ..span + }, + )?; + } + if cluster_start < cluster_end { + push_fallback_span( + &mut self.fallback_span_scratch, + FallbackSpan { + text_start: cluster_start, + text_end: cluster_end, + font_index, + font_handle, + ..span + }, + )?; + cursor = cluster_end; + changed = true; + } + } + record_index += 1; + } + if cursor < span.text_end || span.text_start == span.text_end { + push_fallback_span( + &mut self.fallback_span_scratch, + FallbackSpan { + text_start: cursor, + ..span + }, + )?; + } + } + if !changed { + self.shape_prepared = true; + return Ok(()); + } + core::mem::swap( + &mut self.pending_fallback_spans, + &mut self.fallback_span_scratch, + ); + } + Err(EngineError::InvalidRequest) } fn abort_shape(&mut self) { self.pending_shape.clear(); + self.pending_fallback_spans.clear(); + self.fallback_span_scratch.clear(); + self.fallback_cluster_scratch.clear(); self.shape_prepared = false; } fn commit_shape(&mut self) { if self.shape_prepared { core::mem::swap(&mut self.shape, &mut self.pending_shape); + core::mem::swap(&mut self.fallback_spans, &mut self.pending_fallback_spans); } self.abort_shape(); } @@ -988,6 +1131,91 @@ fn shaper_error(status: u32) -> EngineError { } } +fn find_font_stack( + font_stacks: &[RegisteredFontStack], + handle: u32, +) -> Result<&RegisteredFontStack, EngineError> { + font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks.get(index)) + .ok_or(EngineError::FontStackMissing) +} + +fn push_fallback_span( + spans: &mut Vec, + span: FallbackSpan, +) -> Result<(), EngineError> { + if let Some(previous) = spans.last_mut() + && previous.source_run == span.source_run + && previous.text_end == span.text_start + && previous.font_index == span.font_index + && previous.font_handle == span.font_handle + { + previous.text_end = span.text_end; + return Ok(()); + } + spans + .try_reserve(1) + .map_err(|_| EngineError::ResultTooLarge)?; + spans.push(span); + Ok(()) +} + +fn collect_cluster_records( + shape: &ShapeArena, + records: &mut Vec, +) -> Result<(), EngineError> { + records.clear(); + reserve_vec(records, shape.glyph_ids.len())?; + for run in &shape.runs { + let start = usize::try_from(run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::try_from(run.glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let clusters = shape + .clusters + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + let glyph_ids = shape + .glyph_ids + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + for (&cluster, &glyph_id) in clusters.iter().zip(glyph_ids) { + records.push(ClusterRecord { + source_run: run.source_run, + cluster, + missing: glyph_id == 0, + }); + } + } + records.sort_unstable_by_key(|record| (record.source_run, record.cluster)); + let mut write_index = 0usize; + for read_index in 0..records.len() { + let record = records[read_index]; + if write_index > 0 + && records[write_index - 1].source_run == record.source_run + && records[write_index - 1].cluster == record.cluster + { + records[write_index - 1].missing |= record.missing; + } else { + records[write_index] = record; + write_index += 1; + } + } + records.truncate(write_index); + Ok(()) +} + +fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TextMutationError { Invalid, @@ -1095,6 +1323,49 @@ mod tests { ); } + #[test] + fn fallback_clusters_restore_logical_order_and_merge_missing_glyphs() { + let shape = ShapeArena { + runs: vec![crate::engine::shaping_state::ShapedRun { + source_run: 7, + font_handle: 11, + text_start: 0, + text_end: 6, + glyph_start: 0, + glyph_count: 4, + }], + glyph_ids: vec![3, 0, 2, 0], + clusters: vec![4, 4, 2, 0], + x_advances: vec![], + y_advances: vec![], + x_offsets: vec![], + y_offsets: vec![], + glyph_flags: vec![], + }; + let mut records = Vec::new(); + collect_cluster_records(&shape, &mut records).unwrap(); + assert_eq!( + records, + vec![ + ClusterRecord { + source_run: 7, + cluster: 0, + missing: true, + }, + ClusterRecord { + source_run: 7, + cluster: 2, + missing: false, + }, + ClusterRecord { + source_run: 7, + cluster: 4, + missing: true, + }, + ] + ); + } + #[test] fn font_bindings_are_owned_once_per_font_and_match_shaping_coverage() { let mut engine = TextEngine::default(); diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 07d9fb02..9b5378bb 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -192,6 +192,58 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo assert.equal(fn.fontBindingCount(), 0); }); +test('text_update advances missing clusters through an ordered font stack', async () => { + const [interArtifact, devanagariArtifact, shaperWasm, abi] = await Promise.all([ + readFile(new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url)), + readFile( + new URL( + '../../../../apps/benchmarks/fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb', + import.meta.url, + ), + ), + readFile(shaperWasmUrl), + readFile(shaperAbiUrl, 'utf8').then(JSON.parse), + ]); + const [inter, devanagari] = await Promise.all([ + validateFontArtifact(interArtifact), + validateFontArtifact(devanagariArtifact), + ]); + const instance = await WebAssembly.instantiate(await WebAssembly.compile(shaperWasm), {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), + ); + assert.equal(fn.initialize(), abi.status.ok); + registerValidatedFont({ abi, fn, memory }, 101, inter); + registerValidatedFont({ abi, fn, memory }, 202, devanagari); + + const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(101, 0, 0, 0, 202, 0, 0, 0)); + assert.equal(fn.registerFontStack(17, stack.pointer, 2), abi.status.ok); + fn.deallocate(stack.pointer, stack.length); + const policyBytes = renderPolicyBytes(abi); + const policy = copyToWasm(memory, fn.allocate, policyBytes); + assert.equal(fn.registerPolicy(23, policy.pointer, policy.length), abi.status.ok); + fn.deallocate(policy.pointer, policy.length); + assert.equal(fn.createSession(29, 512, abi.layouts.engineResult.size, 0), abi.status.ok); + + const update = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + text: [0x0915], + }); + const requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, update.byteLength).set(update); + const resultPointer = fn.textUpdate(29, requestPointer, update.byteLength); + const result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + assert.equal( + fn.planCount(), + 2, + 'Inter must shape .notdef before the Devanagari cluster advances to the fallback font', + ); +}); + test('shaper ownership stays scoped to its FontRegistry', async () => { const { artifact, shaperWasm } = await fixture(); const firstRegistry = new FontRegistry(); @@ -212,6 +264,27 @@ function copyToWasm(memory, allocate, source) { return { pointer, length: bytes.byteLength }; } +function registerValidatedFont({ abi, fn, memory }, handle, validated) { + const allocations = [ + copyToWasm(memory, fn.allocate, validated.shapingSfnt), + copyToWasm(memory, fn.allocate, validated.glyphExtents), + copyToWasm(memory, fn.allocate, validated.glyphExtentsAvailability), + ]; + assert.equal( + fn.registerFont( + handle, + allocations[0].pointer, + allocations[0].length, + allocations[1].pointer, + allocations[1].length, + allocations[2].pointer, + allocations[2].length, + ), + abi.status.ok, + ); + for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); +} + function engineStyleUpdateBytes( abi, { From b76f93910f9ba4d589fccf73908133a7c4318927 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 14:58:53 -0400 Subject: [PATCH 033/128] feat(text): retain rust line breaks --- docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 16 +- .../shaper/LICENSES/cto-af-linebreak-MIT.txt | 21 + .../shaper/src/generated/line_break_data.rs | 297 +++++++++ packages/text/rust/shaper/src/lib.rs | 1 + packages/text/rust/shaper/src/line_break.rs | 571 ++++++++++++++++++ packages/text/rust/shaper/src/unicode.rs | 9 + .../tests/unicode_line_break_conformance.rs | 60 ++ .../generate-unicode-line-break-data.mjs | 83 +++ packages/text/scripts/unicode.mts | 2 + 11 files changed, 1061 insertions(+), 4 deletions(-) create mode 100644 packages/text/rust/shaper/LICENSES/cto-af-linebreak-MIT.txt create mode 100644 packages/text/rust/shaper/src/generated/line_break_data.rs create mode 100644 packages/text/rust/shaper/src/line_break.rs create mode 100644 packages/text/rust/shaper/tests/unicode_line_break_conformance.rs create mode 100644 packages/text/scripts/generate-unicode-line-break-data.mjs diff --git a/docs/packages/text.md b/docs/packages/text.md index 0d5ee9cb..320db29a 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:9e95f07ada11bc61a3fd6f889a6412577e36af87be1142e505c09dd2c9d84923' +source_digest: 'sha256:6a323c3e295101f2919510e10cd1e6b3da9d5970a5846a9c41e66a2b0faa9fa8' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -669,6 +669,8 @@ One `shapeBatch` or `reshapeRanges` call packs validated UTF-16, run, feature, l Roadmap item 5.1 adds synchronous paragraph preparation and measurement. Unicode 17 Script/Script_Extensions tables are generated deterministically from the pinned UCD package; `unicode-segmenter` supplies extended grapheme boundaries and `@cto.af/linebreak` supplies line-break opportunities. The ordinary suite executes all 766 official grapheme vectors and all 19,338 official line-break vectors from hash-pinned gzip fixtures. Prepared text is split only at grapheme-safe style/script boundaries, shaped once through the existing GLB-retained HarfRust path, copied immediately out of its borrowed result arena, and measured into legal break clusters with explicit baselines. Equivalent width constraints reuse frozen measurement objects and width-only reflow performs zero Wasm calls. +The target Rust frame path now derives the same line opportunities internally. Its generator resolves the pinned `@cto.af/linebreak` property trie into a compact Rust scalar partition, and a specialized allocation-reusing `no_std` evaluator ports the ordered UAX #14 rules while retaining the upstream MIT notice. The Rust lane independently passes all 19,338 unchanged official Unicode 17 line-break vectors at canonical UTF-16 offsets; its results are retained with the session's transactional Unicode analysis rather than serialized through the legacy shaping ABI. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index a58e0841..820afa6d 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -251,6 +251,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-184 | Bidi analysis and shaping-run itemization are derived A/B session state. `unicode-bidi` remains the UAX #9 algorithm and fills reusable level, class, paragraph, and equal-level-run arrays; text and root base-direction changes re-run it, while unchanged text/style skips it. Root direction selects the paragraph base level. A non-root stated LTR/RTL direction is retained as a distinct derived override, inherited through its scope, and forces only the intersected run's level parity. One forward interval sweep intersects maximal resolved styles, contextual script items, and equal-level bidi runs, excludes mandatory hard-break controls, and coalesces equal adjacent shaping records. Session text capacity pre-reserves active/pending bidi and run arrays. Unit tests cover output reuse, mixed Latin/Hebrew levels, root-only direction updates, nested override parity, hard-break exclusion, and transaction rollback; host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 964,019 / 360,765 / 288,742 to 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes. HarfRust fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | | D-185 | Frame shaping consumes retained runs through a borrowed HarfRust input rather than constructing the legacy owned batch request. Legacy exports and `text_update` share the module-global prewarmed UnicodeBuffer, UTF-16 context scratch, and reusable 128-feature vector. Frame language/features borrow the active retained style arena. Each shaped run appends its font/source identity and glyph ID, UTF-16 cluster, advances, offsets, and flags directly into a pre-reserved A/B session SoA arena that swaps only on commit. The production Wasm update borrows the one existing `ShaperRegistry`; it does not duplicate font bytes or serialize shaped output through the old ABI. A compiled real-Inter test proves shape-plan cache count changes 0→1 only after `text_update` and remains unchanged after an aborted frame. Rust unit tests, host/SIMD Clippy, and focused compiled-Wasm tests pass. Optimized Wasm changes from 968,086 / 362,664 / 286,438 to 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes. This checkpoint shapes only each stack's primary font; ordered fallback, layout, nonempty plan output, and complete-path timing remain open. | Accepted | | D-186 | Ordered fallback is resolved inside the same Rust frame transaction from actual HarfRust `.notdef` output, never from `cmap`, raster coverage, or a host callback. Reusable flat spans name source run, UTF-16 range, stack index, and concrete font; reusable cluster records collapse multi-glyph clusters with missing status ORed across glyph zero. Records sort by source run/logical cluster to normalize RTL output, then one linear merge advances only missing ranges. Font index increases monotonically, bounding passes by stack depth. Final spans and shaped SoA commit together and abort together. A compiled Inter→Noto Devanagari `text_update` constructs exactly two HarfRust plans, causally proving primary and fallback shaping. Host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 973,367 / 364,517 / 287,942 to 982,356 / 368,183 / 290,439 raw/gzip/Brotli bytes. Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. | Accepted | +| D-187 | Unicode 17 line-break opportunities are retained Rust Unicode state, not host input. The pinned `@cto.af/linebreak` 4.0.3 property trie is generated into a compact scalar partition containing resolved line-break class and only the punctuation, East Asian, and unassigned-extended-pictographic flags its ordered UAX #14 rules consume. A specialized `no_std` Rust evaluator reuses flat scalar/break arrays, returns UTF-16 offsets, retains the upstream MIT notice, and commits/aborts with grapheme/script analysis. All 19,338 unchanged official `LineBreakTest` cases pass, plus focused required-break tests; host/SIMD Clippy passes. Optimized Wasm changes from 982,356 / 368,183 / 290,439 to 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli bytes. Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 2616e13e..3624f9d6 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -417,9 +417,11 @@ and passes the repository's unmodified `LineBreakTest` gate. Published Rust segm Unicode-version guarantee; for example, the current ICU4X line segmenter documents Unicode 15.1 data while its other segmenters have advanced.[^icu4x] -The implementation stage should therefore port the current generated Unicode 17 tables and rule evaluation into Rust, -preserving attribution and licensing, and prove it against the same official vector file. Host-supplied break -opportunities may exist only as a short-lived differential-oracle mechanism before cutover, not as the architecture. +The frame engine now owns that exact Unicode 17 answer. A generator resolves the pinned `@cto.af/linebreak` 4.0.3 +property trie and the punctuation, East Asian, and future-emoji properties used by its rules into one compact Rust scalar +partition. The `no_std` evaluator ports the same UAX #14 rule order into reusable scalar/break arrays, preserves the +upstream MIT notice, reports canonical UTF-16 offsets, and passes all 19,338 cases in the repository's unchanged official +`LineBreakTest` fixture. TypeScript opportunities are no longer an input to Rust layout. ### Per-line composition and editorial flow @@ -889,6 +891,14 @@ Devanagari update constructs exactly two HarfRust plans. Optimized Wasm is 982,3 bytes (+8,989 / +3,666 / +2,497). Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. +Unicode 17 line breaking now runs with grapheme/script analysis inside the pending Unicode transaction. The generated +table stores resolved line-break class plus only the punctuation, East Asian, and unassigned-extended-pictographic flags +read by the rule program; it does not embed a generic Unicode property runtime. Scalar records and break results reserve +with session text capacity and retain their high-water marks. All 19,338 official `LineBreakTest` cases match at UTF-16 +offsets, and required CRLF/end breaks have focused tests. Optimized Wasm is 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli +bytes (+27,104 / +8,870 / +5,436). Cluster measurement, composition, nonempty plan output, and complete-path timing remain +open. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/LICENSES/cto-af-linebreak-MIT.txt b/packages/text/rust/shaper/LICENSES/cto-af-linebreak-MIT.txt new file mode 100644 index 00000000..74bf620b --- /dev/null +++ b/packages/text/rust/shaper/LICENSES/cto-af-linebreak-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-present Joe Hildebrand + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/text/rust/shaper/src/generated/line_break_data.rs b/packages/text/rust/shaper/src/generated/line_break_data.rs new file mode 100644 index 00000000..3b1ead22 --- /dev/null +++ b/packages/text/rust/shaper/src/generated/line_break_data.rs @@ -0,0 +1,297 @@ +// Generated by scripts/generate-unicode-line-break-data.mjs from +// @cto.af/linebreak@4.0.3 and @unicode/unicode-17.0.0@1.6.17. Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const INITIAL_PUNCTUATION: u32 = 1 << 8; +pub const FINAL_PUNCTUATION: u32 = 1 << 9; +pub const EAST_ASIAN: u32 = 1 << 10; +pub const UNASSIGNED_EXTENDED_PICTOGRAPHIC: u32 = 1 << 11; + +pub const XX: u8 = 0; +pub const ER: u8 = 1; +pub const CM: u8 = 2; +pub const BA: u8 = 3; +pub const LF: u8 = 4; +pub const BK: u8 = 5; +pub const CR: u8 = 6; +pub const SP: u8 = 7; +pub const EX: u8 = 8; +pub const QU: u8 = 9; +pub const AL: u8 = 10; +pub const PR: u8 = 11; +pub const PO: u8 = 12; +pub const OP: u8 = 13; +pub const CP: u8 = 14; +pub const IS: u8 = 15; +pub const HY: u8 = 16; +pub const SY: u8 = 17; +pub const NU: u8 = 18; +pub const CL: u8 = 19; +pub const NL: u8 = 20; +pub const GL: u8 = 21; +pub const AI: u8 = 22; +pub const BB: u8 = 23; +pub const HH: u8 = 24; +pub const HL: u8 = 25; +pub const SA: u8 = 26; +pub const JL: u8 = 27; +pub const JV: u8 = 28; +pub const JT: u8 = 29; +pub const NS: u8 = 30; +pub const AK: u8 = 31; +pub const VI: u8 = 32; +pub const AS: u8 = 33; +pub const ID: u8 = 34; +pub const VF: u8 = 35; +pub const ZW: u8 = 36; +pub const ZWJ: u8 = 37; +pub const B2: u8 = 38; +pub const IN: u8 = 39; +pub const WJ: u8 = 40; +pub const EB: u8 = 41; +pub const CJ: u8 = 42; +pub const H2: u8 = 43; +pub const H3: u8 = 44; +pub const SG: u8 = 45; +pub const CB: u8 = 46; +pub const AP: u8 = 47; +pub const RI: u8 = 48; +pub const EM: u8 = 49; + +pub const LINE_BREAK_END_VALUES: &[u32] = &[ + 9, 2, 10, 3, 11, 4, 13, 5, 14, 6, 32, 2, 33, 7, 34, 8, 35, 9, 36, 10, 37, 11, 38, 12, + 39, 10, 40, 9, 41, 13, 42, 14, 43, 10, 44, 11, 45, 15, 46, 16, 47, 15, 48, 17, 58, 18, 60, 15, + 63, 10, 64, 8, 91, 10, 92, 13, 93, 11, 94, 14, 123, 10, 124, 13, 125, 3, 126, 19, 127, 10, 133, 2, + 134, 20, 160, 2, 161, 21, 162, 13, 163, 12, 166, 11, 171, 10, 172, 265, 173, 10, 174, 3, 176, 10, 177, 12, + 178, 11, 180, 10, 181, 23, 187, 10, 188, 521, 191, 10, 192, 13, 712, 10, 713, 23, 716, 10, 717, 23, 735, 10, + 736, 23, 768, 10, 860, 2, 867, 21, 880, 2, 894, 10, 895, 15, 1155, 10, 1162, 2, 1417, 10, 1418, 15, 1419, 24, + 1423, 10, 1424, 11, 1425, 10, 1470, 2, 1471, 24, 1472, 2, 1473, 10, 1475, 2, 1476, 10, 1478, 2, 1479, 8, 1480, 2, + 1488, 10, 1515, 25, 1519, 10, 1523, 25, 1536, 10, 1542, 18, 1545, 10, 1548, 12, 1550, 15, 1552, 10, 1563, 2, 1564, 8, + 1565, 2, 1568, 8, 1611, 10, 1632, 2, 1642, 18, 1643, 12, 1645, 18, 1648, 10, 1649, 2, 1748, 10, 1749, 8, 1750, 10, + 1757, 2, 1758, 18, 1759, 10, 1765, 2, 1767, 10, 1769, 2, 1770, 10, 1774, 2, 1776, 10, 1786, 18, 1809, 10, 1810, 2, + 1840, 10, 1867, 2, 1958, 10, 1969, 2, 1984, 10, 1994, 18, 2027, 10, 2036, 2, 2040, 10, 2041, 15, 2042, 8, 2045, 10, + 2046, 2, 2048, 11, 2070, 10, 2074, 2, 2075, 10, 2084, 2, 2085, 10, 2088, 2, 2089, 10, 2094, 2, 2137, 10, 2140, 2, + 2192, 10, 2194, 18, 2199, 10, 2208, 2, 2250, 10, 2274, 2, 2275, 18, 2308, 2, 2362, 10, 2365, 2, 2366, 10, 2384, 2, + 2385, 10, 2392, 2, 2402, 10, 2404, 2, 2406, 3, 2416, 18, 2433, 10, 2436, 2, 2492, 10, 2493, 2, 2494, 10, 2501, 2, + 2503, 10, 2505, 2, 2507, 10, 2510, 2, 2519, 10, 2520, 2, 2530, 10, 2532, 2, 2534, 10, 2544, 18, 2546, 10, 2548, 12, + 2553, 10, 2554, 12, 2555, 10, 2556, 11, 2558, 10, 2559, 2, 2561, 10, 2564, 2, 2620, 10, 2621, 2, 2622, 10, 2627, 2, + 2631, 10, 2633, 2, 2635, 10, 2638, 2, 2641, 10, 2642, 2, 2662, 10, 2672, 18, 2674, 2, 2677, 10, 2678, 2, 2689, 10, + 2692, 2, 2748, 10, 2749, 2, 2750, 10, 2758, 2, 2759, 10, 2762, 2, 2763, 10, 2766, 2, 2786, 10, 2788, 2, 2790, 10, + 2800, 18, 2801, 10, 2802, 11, 2810, 10, 2816, 2, 2817, 10, 2820, 2, 2876, 10, 2877, 2, 2878, 10, 2885, 2, 2887, 10, + 2889, 2, 2891, 10, 2894, 2, 2901, 10, 2904, 2, 2914, 10, 2916, 2, 2918, 10, 2928, 18, 2946, 10, 2947, 2, 3006, 10, + 3011, 2, 3014, 10, 3017, 2, 3018, 10, 3022, 2, 3031, 10, 3032, 2, 3046, 10, 3056, 18, 3065, 10, 3066, 11, 3072, 10, + 3077, 2, 3132, 10, 3133, 2, 3134, 10, 3141, 2, 3142, 10, 3145, 2, 3146, 10, 3150, 2, 3157, 10, 3159, 2, 3170, 10, + 3172, 2, 3174, 10, 3184, 18, 3191, 10, 3192, 23, 3201, 10, 3204, 2, 3205, 23, 3260, 10, 3261, 2, 3262, 10, 3269, 2, + 3270, 10, 3273, 2, 3274, 10, 3278, 2, 3285, 10, 3287, 2, 3298, 10, 3300, 2, 3302, 10, 3312, 18, 3315, 10, 3316, 2, + 3328, 10, 3332, 2, 3387, 10, 3389, 2, 3390, 10, 3397, 2, 3398, 10, 3401, 2, 3402, 10, 3406, 2, 3415, 10, 3416, 2, + 3426, 10, 3428, 2, 3430, 10, 3440, 18, 3449, 10, 3450, 12, 3457, 10, 3460, 2, 3530, 10, 3531, 2, 3535, 10, 3541, 2, + 3542, 10, 3543, 2, 3544, 10, 3552, 2, 3558, 10, 3568, 18, 3570, 10, 3572, 2, 3633, 10, 3634, 2, 3636, 10, 3643, 2, + 3647, 10, 3648, 11, 3655, 10, 3663, 2, 3664, 10, 3674, 18, 3676, 3, 3761, 10, 3762, 2, 3764, 10, 3773, 2, 3784, 10, + 3791, 2, 3792, 10, 3802, 18, 3841, 10, 3845, 23, 3846, 10, 3848, 23, 3849, 21, 3851, 23, 3852, 3, 3853, 21, 3858, 8, + 3859, 21, 3860, 10, 3861, 8, 3864, 10, 3866, 2, 3872, 10, 3882, 18, 3892, 10, 3893, 3, 3894, 2, 3895, 10, 3896, 2, + 3897, 10, 3898, 2, 3899, 13, 3900, 19, 3901, 13, 3902, 19, 3904, 2, 3953, 10, 3967, 2, 3968, 3, 3973, 2, 3974, 3, + 3976, 2, 3981, 10, 3992, 2, 3993, 10, 4029, 2, 4030, 10, 4032, 3, 4038, 10, 4039, 2, 4048, 10, 4050, 23, 4051, 3, + 4052, 23, 4057, 10, 4059, 21, 4139, 10, 4159, 2, 4160, 10, 4170, 18, 4172, 3, 4182, 10, 4186, 2, 4190, 10, 4193, 2, + 4194, 10, 4197, 2, 4199, 10, 4206, 2, 4209, 10, 4213, 2, 4226, 10, 4238, 2, 4239, 10, 4240, 2, 4250, 18, 4254, 2, + 4352, 10, 4448, 1051, 4520, 28, 4608, 29, 4957, 10, 4960, 2, 4961, 10, 4962, 3, 5120, 10, 5121, 24, 5760, 10, 5761, 3, + 5787, 10, 5788, 13, 5789, 19, 5867, 10, 5870, 3, 5906, 10, 5910, 2, 5938, 10, 5941, 2, 5943, 3, 5970, 10, 5972, 2, + 6002, 10, 6004, 2, 6068, 10, 6100, 2, 6102, 3, 6103, 30, 6104, 10, 6105, 3, 6106, 10, 6107, 3, 6108, 11, 6109, 10, + 6110, 2, 6112, 10, 6122, 18, 6146, 10, 6148, 8, 6150, 3, 6151, 23, 6152, 10, 6154, 8, 6155, 10, 6158, 2, 6159, 21, + 6160, 2, 6170, 18, 6277, 10, 6279, 2, 6313, 10, 6314, 2, 6432, 10, 6444, 2, 6448, 10, 6460, 2, 6468, 10, 6470, 8, + 6480, 18, 6608, 10, 6619, 18, 6679, 10, 6684, 2, 6741, 10, 6751, 2, 6752, 10, 6781, 2, 6783, 10, 6784, 2, 6794, 18, + 6800, 10, 6810, 18, 6832, 10, 6878, 2, 6880, 10, 6891, 2, 6892, 21, 6912, 10, 6917, 2, 6964, 31, 6980, 2, 6981, 32, + 6989, 31, 6990, 10, 6992, 3, 7002, 33, 7004, 3, 7005, 34, 7009, 3, 7019, 34, 7028, 2, 7037, 34, 7040, 3, 7043, 2, + 7073, 10, 7086, 2, 7088, 10, 7098, 18, 7104, 10, 7142, 33, 7154, 2, 7156, 35, 7204, 10, 7224, 2, 7227, 10, 7232, 3, + 7242, 18, 7248, 10, 7258, 18, 7294, 10, 7296, 3, 7376, 10, 7379, 2, 7380, 10, 7401, 2, 7405, 10, 7406, 2, 7412, 10, + 7413, 2, 7415, 10, 7418, 2, 7616, 10, 7629, 2, 7630, 21, 7676, 2, 7677, 21, 7680, 2, 8189, 10, 8190, 23, 8192, 10, + 8199, 3, 8200, 21, 8203, 3, 8204, 36, 8205, 2, 8206, 37, 8208, 2, 8209, 24, 8210, 21, 8212, 24, 8213, 38, 8216, 10, + 8217, 265, 8218, 521, 8219, 13, 8221, 265, 8222, 521, 8223, 13, 8224, 265, 8228, 10, 8231, 39, 8232, 3, 8234, 5, 8239, 2, + 8240, 21, 8248, 12, 8249, 10, 8250, 265, 8251, 521, 8252, 10, 8254, 30, 8260, 10, 8261, 15, 8262, 13, 8263, 19, 8266, 30, + 8278, 10, 8279, 3, 8280, 12, 8284, 3, 8285, 10, 8288, 3, 8289, 40, 8294, 10, 8304, 2, 8317, 10, 8318, 13, 8319, 19, + 8333, 10, 8334, 13, 8335, 19, 8352, 10, 8359, 11, 8360, 12, 8361, 11, 8362, 1035, 8374, 11, 8375, 12, 8379, 11, 8380, 12, + 8382, 11, 8383, 12, 8384, 11, 8385, 12, 8400, 11, 8433, 2, 8451, 10, 8452, 12, 8457, 10, 8458, 12, 8470, 10, 8471, 11, + 8722, 10, 8724, 11, 8943, 10, 8944, 39, 8968, 10, 8969, 13, 8970, 19, 8971, 13, 8972, 19, 8986, 10, 8988, 1058, 9001, 10, + 9002, 1037, 9003, 1043, 9193, 10, 9197, 1034, 9200, 10, 9201, 1058, 9203, 34, 9204, 1058, 9725, 10, 9727, 1034, 9728, 10, 9732, 34, + 9748, 10, 9750, 1058, 9752, 10, 9753, 34, 9754, 10, 9757, 34, 9758, 41, 9760, 34, 9776, 10, 9784, 1034, 9785, 10, 9788, 34, + 9800, 10, 9812, 1034, 9832, 10, 9833, 34, 9855, 10, 9856, 1058, 9866, 10, 9872, 1034, 9875, 10, 9876, 1034, 9889, 10, 9890, 1034, + 9898, 10, 9900, 1034, 9917, 10, 9919, 1058, 9924, 34, 9926, 1058, 9929, 34, 9933, 10, 9934, 34, 9935, 1034, 9938, 34, 9939, 10, + 9940, 34, 9941, 1058, 9944, 10, 9946, 34, 9948, 10, 9949, 34, 9951, 10, 9954, 34, 9962, 10, 9963, 1058, 9969, 10, 9970, 34, + 9972, 1058, 9973, 34, 9974, 1058, 9975, 10, 9977, 34, 9978, 41, 9979, 1058, 9981, 10, 9982, 1058, 9989, 34, 9990, 1034, 9992, 10, + 9994, 34, 9996, 1065, 9998, 41, 10024, 10, 10025, 1034, 10060, 10, 10061, 1034, 10062, 10, 10063, 1034, 10067, 10, 10070, 1034, 10071, 10, + 10072, 1034, 10075, 10, 10081, 9, 10082, 10, 10084, 8, 10085, 34, 10088, 10, 10089, 13, 10090, 19, 10091, 13, 10092, 19, 10093, 13, + 10094, 19, 10095, 13, 10096, 19, 10097, 13, 10098, 19, 10099, 13, 10100, 19, 10101, 13, 10102, 19, 10133, 10, 10136, 1034, 10160, 10, + 10161, 1034, 10175, 10, 10176, 1034, 10181, 10, 10182, 13, 10183, 19, 10214, 10, 10215, 13, 10216, 19, 10217, 13, 10218, 19, 10219, 13, + 10220, 19, 10221, 13, 10222, 19, 10223, 13, 10224, 19, 10240, 10, 10241, 3, 10627, 10, 10628, 13, 10629, 19, 10630, 13, 10631, 19, + 10632, 13, 10633, 19, 10634, 13, 10635, 19, 10636, 13, 10637, 19, 10638, 13, 10639, 19, 10640, 13, 10641, 19, 10642, 13, 10643, 19, + 10644, 13, 10645, 19, 10646, 13, 10647, 19, 10648, 13, 10649, 19, 10712, 10, 10713, 13, 10714, 19, 10715, 13, 10716, 19, 10748, 10, + 10749, 13, 10750, 19, 11035, 10, 11037, 1034, 11088, 10, 11089, 1034, 11093, 10, 11094, 1034, 11503, 10, 11506, 2, 11513, 10, 11514, 8, + 11517, 3, 11518, 10, 11519, 8, 11520, 3, 11632, 10, 11633, 3, 11647, 10, 11648, 2, 11744, 10, 11776, 2, 11778, 9, 11779, 265, + 11780, 521, 11781, 265, 11782, 521, 11785, 9, 11786, 265, 11787, 521, 11788, 9, 11789, 265, 11790, 521, 11798, 3, 11799, 10, 11800, 24, + 11801, 13, 11802, 3, 11804, 10, 11805, 265, 11806, 521, 11808, 10, 11809, 265, 11810, 521, 11811, 13, 11812, 19, 11813, 13, 11814, 19, + 11815, 13, 11816, 19, 11817, 13, 11818, 19, 11822, 3, 11823, 8, 11824, 10, 11826, 3, 11827, 10, 11829, 3, 11834, 10, 11836, 38, + 11839, 3, 11840, 10, 11841, 24, 11842, 3, 11843, 13, 11851, 3, 11852, 10, 11853, 3, 11854, 10, 11856, 3, 11859, 10, 11861, 8, + 11862, 13, 11863, 14, 11864, 13, 11865, 14, 11866, 13, 11867, 14, 11868, 13, 11869, 14, 11870, 24, 11904, 10, 11930, 1058, 11931, 10, + 12020, 1058, 12032, 10, 12246, 1058, 12272, 10, 12288, 1058, 12289, 1027, 12291, 1043, 12293, 1058, 12294, 1054, 12296, 1058, 12297, 1037, 12298, 1043, + 12299, 1037, 12300, 1043, 12301, 1037, 12302, 1043, 12303, 1037, 12304, 1043, 12305, 1037, 12306, 1043, 12308, 1058, 12309, 1037, 12310, 1043, 12311, 1037, + 12312, 1043, 12313, 1037, 12314, 1043, 12315, 1037, 12316, 1043, 12317, 1054, 12318, 1037, 12320, 1043, 12330, 1058, 12336, 1026, 12341, 1058, 12342, 1026, + 12347, 1058, 12349, 1054, 12351, 1058, 12352, 34, 12353, 10, 12354, 1054, 12355, 1058, 12356, 1054, 12357, 1058, 12358, 1054, 12359, 1058, 12360, 1054, + 12361, 1058, 12362, 1054, 12387, 1058, 12388, 1054, 12419, 1058, 12420, 1054, 12421, 1058, 12422, 1054, 12423, 1058, 12424, 1054, 12430, 1058, 12431, 1054, + 12437, 1058, 12439, 1054, 12441, 10, 12443, 1026, 12447, 1054, 12448, 1058, 12450, 1054, 12451, 1058, 12452, 1054, 12453, 1058, 12454, 1054, 12455, 1058, + 12456, 1054, 12457, 1058, 12458, 1054, 12483, 1058, 12484, 1054, 12515, 1058, 12516, 1054, 12517, 1058, 12518, 1054, 12519, 1058, 12520, 1054, 12526, 1058, + 12527, 1054, 12533, 1058, 12535, 1054, 12539, 1058, 12543, 1054, 12544, 1058, 12549, 10, 12592, 1058, 12593, 10, 12687, 1058, 12688, 10, 12774, 1058, + 12783, 10, 12784, 1058, 12800, 1054, 12831, 1058, 12832, 10, 12872, 1058, 12880, 10, 19904, 1058, 19968, 1034, 40981, 1058, 40982, 1054, 42125, 1058, + 42128, 10, 42183, 1058, 42238, 10, 42240, 3, 42509, 10, 42510, 3, 42511, 8, 42512, 3, 42528, 10, 42538, 18, 42607, 10, 42611, 2, + 42612, 10, 42622, 2, 42654, 10, 42656, 2, 42736, 10, 42738, 2, 42739, 10, 42744, 3, 43010, 10, 43011, 2, 43014, 10, 43015, 2, + 43019, 10, 43020, 2, 43043, 10, 43048, 2, 43052, 10, 43053, 2, 43064, 10, 43065, 12, 43124, 10, 43126, 23, 43128, 8, 43136, 10, + 43138, 2, 43188, 10, 43206, 2, 43214, 10, 43216, 3, 43226, 18, 43232, 10, 43250, 2, 43260, 10, 43261, 23, 43263, 10, 43264, 2, + 43274, 18, 43302, 10, 43310, 2, 43312, 3, 43335, 10, 43348, 2, 43360, 10, 43389, 1051, 43392, 10, 43396, 2, 43443, 31, 43456, 2, + 43457, 32, 43463, 34, 43466, 3, 43470, 34, 43471, 10, 43472, 3, 43482, 33, 43486, 10, 43488, 34, 43493, 10, 43494, 2, 43504, 10, + 43514, 18, 43520, 10, 43561, 33, 43575, 2, 43584, 10, 43587, 3, 43588, 2, 43596, 3, 43598, 2, 43600, 10, 43610, 33, 43612, 10, + 43613, 34, 43616, 3, 43643, 10, 43646, 2, 43696, 10, 43697, 2, 43698, 10, 43701, 2, 43703, 10, 43705, 2, 43710, 10, 43712, 2, + 43713, 10, 43714, 2, 43755, 10, 43760, 2, 43762, 3, 43765, 10, 43767, 2, 44003, 10, 44011, 2, 44012, 3, 44014, 2, 44016, 10, + 44026, 18, 44032, 10, 44033, 1067, 44060, 1068, 44061, 1067, 44088, 1068, 44089, 1067, 44116, 1068, 44117, 1067, 44144, 1068, 44145, 1067, 44172, 1068, + 44173, 1067, 44200, 1068, 44201, 1067, 44228, 1068, 44229, 1067, 44256, 1068, 44257, 1067, 44284, 1068, 44285, 1067, 44312, 1068, 44313, 1067, 44340, 1068, + 44341, 1067, 44368, 1068, 44369, 1067, 44396, 1068, 44397, 1067, 44424, 1068, 44425, 1067, 44452, 1068, 44453, 1067, 44480, 1068, 44481, 1067, 44508, 1068, + 44509, 1067, 44536, 1068, 44537, 1067, 44564, 1068, 44565, 1067, 44592, 1068, 44593, 1067, 44620, 1068, 44621, 1067, 44648, 1068, 44649, 1067, 44676, 1068, + 44677, 1067, 44704, 1068, 44705, 1067, 44732, 1068, 44733, 1067, 44760, 1068, 44761, 1067, 44788, 1068, 44789, 1067, 44816, 1068, 44817, 1067, 44844, 1068, + 44845, 1067, 44872, 1068, 44873, 1067, 44900, 1068, 44901, 1067, 44928, 1068, 44929, 1067, 44956, 1068, 44957, 1067, 44984, 1068, 44985, 1067, 45012, 1068, + 45013, 1067, 45040, 1068, 45041, 1067, 45068, 1068, 45069, 1067, 45096, 1068, 45097, 1067, 45124, 1068, 45125, 1067, 45152, 1068, 45153, 1067, 45180, 1068, + 45181, 1067, 45208, 1068, 45209, 1067, 45236, 1068, 45237, 1067, 45264, 1068, 45265, 1067, 45292, 1068, 45293, 1067, 45320, 1068, 45321, 1067, 45348, 1068, + 45349, 1067, 45376, 1068, 45377, 1067, 45404, 1068, 45405, 1067, 45432, 1068, 45433, 1067, 45460, 1068, 45461, 1067, 45488, 1068, 45489, 1067, 45516, 1068, + 45517, 1067, 45544, 1068, 45545, 1067, 45572, 1068, 45573, 1067, 45600, 1068, 45601, 1067, 45628, 1068, 45629, 1067, 45656, 1068, 45657, 1067, 45684, 1068, + 45685, 1067, 45712, 1068, 45713, 1067, 45740, 1068, 45741, 1067, 45768, 1068, 45769, 1067, 45796, 1068, 45797, 1067, 45824, 1068, 45825, 1067, 45852, 1068, + 45853, 1067, 45880, 1068, 45881, 1067, 45908, 1068, 45909, 1067, 45936, 1068, 45937, 1067, 45964, 1068, 45965, 1067, 45992, 1068, 45993, 1067, 46020, 1068, + 46021, 1067, 46048, 1068, 46049, 1067, 46076, 1068, 46077, 1067, 46104, 1068, 46105, 1067, 46132, 1068, 46133, 1067, 46160, 1068, 46161, 1067, 46188, 1068, + 46189, 1067, 46216, 1068, 46217, 1067, 46244, 1068, 46245, 1067, 46272, 1068, 46273, 1067, 46300, 1068, 46301, 1067, 46328, 1068, 46329, 1067, 46356, 1068, + 46357, 1067, 46384, 1068, 46385, 1067, 46412, 1068, 46413, 1067, 46440, 1068, 46441, 1067, 46468, 1068, 46469, 1067, 46496, 1068, 46497, 1067, 46524, 1068, + 46525, 1067, 46552, 1068, 46553, 1067, 46580, 1068, 46581, 1067, 46608, 1068, 46609, 1067, 46636, 1068, 46637, 1067, 46664, 1068, 46665, 1067, 46692, 1068, + 46693, 1067, 46720, 1068, 46721, 1067, 46748, 1068, 46749, 1067, 46776, 1068, 46777, 1067, 46804, 1068, 46805, 1067, 46832, 1068, 46833, 1067, 46860, 1068, + 46861, 1067, 46888, 1068, 46889, 1067, 46916, 1068, 46917, 1067, 46944, 1068, 46945, 1067, 46972, 1068, 46973, 1067, 47000, 1068, 47001, 1067, 47028, 1068, + 47029, 1067, 47056, 1068, 47057, 1067, 47084, 1068, 47085, 1067, 47112, 1068, 47113, 1067, 47140, 1068, 47141, 1067, 47168, 1068, 47169, 1067, 47196, 1068, + 47197, 1067, 47224, 1068, 47225, 1067, 47252, 1068, 47253, 1067, 47280, 1068, 47281, 1067, 47308, 1068, 47309, 1067, 47336, 1068, 47337, 1067, 47364, 1068, + 47365, 1067, 47392, 1068, 47393, 1067, 47420, 1068, 47421, 1067, 47448, 1068, 47449, 1067, 47476, 1068, 47477, 1067, 47504, 1068, 47505, 1067, 47532, 1068, + 47533, 1067, 47560, 1068, 47561, 1067, 47588, 1068, 47589, 1067, 47616, 1068, 47617, 1067, 47644, 1068, 47645, 1067, 47672, 1068, 47673, 1067, 47700, 1068, + 47701, 1067, 47728, 1068, 47729, 1067, 47756, 1068, 47757, 1067, 47784, 1068, 47785, 1067, 47812, 1068, 47813, 1067, 47840, 1068, 47841, 1067, 47868, 1068, + 47869, 1067, 47896, 1068, 47897, 1067, 47924, 1068, 47925, 1067, 47952, 1068, 47953, 1067, 47980, 1068, 47981, 1067, 48008, 1068, 48009, 1067, 48036, 1068, + 48037, 1067, 48064, 1068, 48065, 1067, 48092, 1068, 48093, 1067, 48120, 1068, 48121, 1067, 48148, 1068, 48149, 1067, 48176, 1068, 48177, 1067, 48204, 1068, + 48205, 1067, 48232, 1068, 48233, 1067, 48260, 1068, 48261, 1067, 48288, 1068, 48289, 1067, 48316, 1068, 48317, 1067, 48344, 1068, 48345, 1067, 48372, 1068, + 48373, 1067, 48400, 1068, 48401, 1067, 48428, 1068, 48429, 1067, 48456, 1068, 48457, 1067, 48484, 1068, 48485, 1067, 48512, 1068, 48513, 1067, 48540, 1068, + 48541, 1067, 48568, 1068, 48569, 1067, 48596, 1068, 48597, 1067, 48624, 1068, 48625, 1067, 48652, 1068, 48653, 1067, 48680, 1068, 48681, 1067, 48708, 1068, + 48709, 1067, 48736, 1068, 48737, 1067, 48764, 1068, 48765, 1067, 48792, 1068, 48793, 1067, 48820, 1068, 48821, 1067, 48848, 1068, 48849, 1067, 48876, 1068, + 48877, 1067, 48904, 1068, 48905, 1067, 48932, 1068, 48933, 1067, 48960, 1068, 48961, 1067, 48988, 1068, 48989, 1067, 49016, 1068, 49017, 1067, 49044, 1068, + 49045, 1067, 49072, 1068, 49073, 1067, 49100, 1068, 49101, 1067, 49128, 1068, 49129, 1067, 49156, 1068, 49157, 1067, 49184, 1068, 49185, 1067, 49212, 1068, + 49213, 1067, 49240, 1068, 49241, 1067, 49268, 1068, 49269, 1067, 49296, 1068, 49297, 1067, 49324, 1068, 49325, 1067, 49352, 1068, 49353, 1067, 49380, 1068, + 49381, 1067, 49408, 1068, 49409, 1067, 49436, 1068, 49437, 1067, 49464, 1068, 49465, 1067, 49492, 1068, 49493, 1067, 49520, 1068, 49521, 1067, 49548, 1068, + 49549, 1067, 49576, 1068, 49577, 1067, 49604, 1068, 49605, 1067, 49632, 1068, 49633, 1067, 49660, 1068, 49661, 1067, 49688, 1068, 49689, 1067, 49716, 1068, + 49717, 1067, 49744, 1068, 49745, 1067, 49772, 1068, 49773, 1067, 49800, 1068, 49801, 1067, 49828, 1068, 49829, 1067, 49856, 1068, 49857, 1067, 49884, 1068, + 49885, 1067, 49912, 1068, 49913, 1067, 49940, 1068, 49941, 1067, 49968, 1068, 49969, 1067, 49996, 1068, 49997, 1067, 50024, 1068, 50025, 1067, 50052, 1068, + 50053, 1067, 50080, 1068, 50081, 1067, 50108, 1068, 50109, 1067, 50136, 1068, 50137, 1067, 50164, 1068, 50165, 1067, 50192, 1068, 50193, 1067, 50220, 1068, + 50221, 1067, 50248, 1068, 50249, 1067, 50276, 1068, 50277, 1067, 50304, 1068, 50305, 1067, 50332, 1068, 50333, 1067, 50360, 1068, 50361, 1067, 50388, 1068, + 50389, 1067, 50416, 1068, 50417, 1067, 50444, 1068, 50445, 1067, 50472, 1068, 50473, 1067, 50500, 1068, 50501, 1067, 50528, 1068, 50529, 1067, 50556, 1068, + 50557, 1067, 50584, 1068, 50585, 1067, 50612, 1068, 50613, 1067, 50640, 1068, 50641, 1067, 50668, 1068, 50669, 1067, 50696, 1068, 50697, 1067, 50724, 1068, + 50725, 1067, 50752, 1068, 50753, 1067, 50780, 1068, 50781, 1067, 50808, 1068, 50809, 1067, 50836, 1068, 50837, 1067, 50864, 1068, 50865, 1067, 50892, 1068, + 50893, 1067, 50920, 1068, 50921, 1067, 50948, 1068, 50949, 1067, 50976, 1068, 50977, 1067, 51004, 1068, 51005, 1067, 51032, 1068, 51033, 1067, 51060, 1068, + 51061, 1067, 51088, 1068, 51089, 1067, 51116, 1068, 51117, 1067, 51144, 1068, 51145, 1067, 51172, 1068, 51173, 1067, 51200, 1068, 51201, 1067, 51228, 1068, + 51229, 1067, 51256, 1068, 51257, 1067, 51284, 1068, 51285, 1067, 51312, 1068, 51313, 1067, 51340, 1068, 51341, 1067, 51368, 1068, 51369, 1067, 51396, 1068, + 51397, 1067, 51424, 1068, 51425, 1067, 51452, 1068, 51453, 1067, 51480, 1068, 51481, 1067, 51508, 1068, 51509, 1067, 51536, 1068, 51537, 1067, 51564, 1068, + 51565, 1067, 51592, 1068, 51593, 1067, 51620, 1068, 51621, 1067, 51648, 1068, 51649, 1067, 51676, 1068, 51677, 1067, 51704, 1068, 51705, 1067, 51732, 1068, + 51733, 1067, 51760, 1068, 51761, 1067, 51788, 1068, 51789, 1067, 51816, 1068, 51817, 1067, 51844, 1068, 51845, 1067, 51872, 1068, 51873, 1067, 51900, 1068, + 51901, 1067, 51928, 1068, 51929, 1067, 51956, 1068, 51957, 1067, 51984, 1068, 51985, 1067, 52012, 1068, 52013, 1067, 52040, 1068, 52041, 1067, 52068, 1068, + 52069, 1067, 52096, 1068, 52097, 1067, 52124, 1068, 52125, 1067, 52152, 1068, 52153, 1067, 52180, 1068, 52181, 1067, 52208, 1068, 52209, 1067, 52236, 1068, + 52237, 1067, 52264, 1068, 52265, 1067, 52292, 1068, 52293, 1067, 52320, 1068, 52321, 1067, 52348, 1068, 52349, 1067, 52376, 1068, 52377, 1067, 52404, 1068, + 52405, 1067, 52432, 1068, 52433, 1067, 52460, 1068, 52461, 1067, 52488, 1068, 52489, 1067, 52516, 1068, 52517, 1067, 52544, 1068, 52545, 1067, 52572, 1068, + 52573, 1067, 52600, 1068, 52601, 1067, 52628, 1068, 52629, 1067, 52656, 1068, 52657, 1067, 52684, 1068, 52685, 1067, 52712, 1068, 52713, 1067, 52740, 1068, + 52741, 1067, 52768, 1068, 52769, 1067, 52796, 1068, 52797, 1067, 52824, 1068, 52825, 1067, 52852, 1068, 52853, 1067, 52880, 1068, 52881, 1067, 52908, 1068, + 52909, 1067, 52936, 1068, 52937, 1067, 52964, 1068, 52965, 1067, 52992, 1068, 52993, 1067, 53020, 1068, 53021, 1067, 53048, 1068, 53049, 1067, 53076, 1068, + 53077, 1067, 53104, 1068, 53105, 1067, 53132, 1068, 53133, 1067, 53160, 1068, 53161, 1067, 53188, 1068, 53189, 1067, 53216, 1068, 53217, 1067, 53244, 1068, + 53245, 1067, 53272, 1068, 53273, 1067, 53300, 1068, 53301, 1067, 53328, 1068, 53329, 1067, 53356, 1068, 53357, 1067, 53384, 1068, 53385, 1067, 53412, 1068, + 53413, 1067, 53440, 1068, 53441, 1067, 53468, 1068, 53469, 1067, 53496, 1068, 53497, 1067, 53524, 1068, 53525, 1067, 53552, 1068, 53553, 1067, 53580, 1068, + 53581, 1067, 53608, 1068, 53609, 1067, 53636, 1068, 53637, 1067, 53664, 1068, 53665, 1067, 53692, 1068, 53693, 1067, 53720, 1068, 53721, 1067, 53748, 1068, + 53749, 1067, 53776, 1068, 53777, 1067, 53804, 1068, 53805, 1067, 53832, 1068, 53833, 1067, 53860, 1068, 53861, 1067, 53888, 1068, 53889, 1067, 53916, 1068, + 53917, 1067, 53944, 1068, 53945, 1067, 53972, 1068, 53973, 1067, 54000, 1068, 54001, 1067, 54028, 1068, 54029, 1067, 54056, 1068, 54057, 1067, 54084, 1068, + 54085, 1067, 54112, 1068, 54113, 1067, 54140, 1068, 54141, 1067, 54168, 1068, 54169, 1067, 54196, 1068, 54197, 1067, 54224, 1068, 54225, 1067, 54252, 1068, + 54253, 1067, 54280, 1068, 54281, 1067, 54308, 1068, 54309, 1067, 54336, 1068, 54337, 1067, 54364, 1068, 54365, 1067, 54392, 1068, 54393, 1067, 54420, 1068, + 54421, 1067, 54448, 1068, 54449, 1067, 54476, 1068, 54477, 1067, 54504, 1068, 54505, 1067, 54532, 1068, 54533, 1067, 54560, 1068, 54561, 1067, 54588, 1068, + 54589, 1067, 54616, 1068, 54617, 1067, 54644, 1068, 54645, 1067, 54672, 1068, 54673, 1067, 54700, 1068, 54701, 1067, 54728, 1068, 54729, 1067, 54756, 1068, + 54757, 1067, 54784, 1068, 54785, 1067, 54812, 1068, 54813, 1067, 54840, 1068, 54841, 1067, 54868, 1068, 54869, 1067, 54896, 1068, 54897, 1067, 54924, 1068, + 54925, 1067, 54952, 1068, 54953, 1067, 54980, 1068, 54981, 1067, 55008, 1068, 55009, 1067, 55036, 1068, 55037, 1067, 55064, 1068, 55065, 1067, 55092, 1068, + 55093, 1067, 55120, 1068, 55121, 1067, 55148, 1068, 55149, 1067, 55176, 1068, 55177, 1067, 55204, 1068, 55216, 10, 55239, 28, 55243, 10, 55292, 29, + 63744, 10, 64256, 1058, 64285, 10, 64286, 25, 64287, 2, 64297, 25, 64298, 10, 64311, 25, 64312, 10, 64317, 25, 64318, 10, 64319, 25, + 64320, 10, 64322, 25, 64323, 10, 64325, 25, 64326, 10, 64336, 25, 64830, 10, 64831, 19, 64832, 13, 65020, 10, 65021, 12, 65024, 10, + 65040, 2, 65043, 1043, 65045, 1054, 65047, 1032, 65048, 1037, 65049, 1043, 65050, 1063, 65056, 10, 65057, 21, 65058, 2, 65059, 21, 65060, 2, + 65061, 21, 65062, 2, 65064, 21, 65065, 2, 65066, 21, 65067, 2, 65068, 21, 65069, 2, 65071, 21, 65072, 2, 65077, 1058, 65078, 1037, + 65079, 1043, 65080, 1037, 65081, 1043, 65082, 1037, 65083, 1043, 65084, 1037, 65085, 1043, 65086, 1037, 65087, 1043, 65088, 1037, 65089, 1043, 65090, 1037, + 65091, 1043, 65092, 1037, 65093, 1043, 65095, 1058, 65096, 1037, 65097, 1043, 65104, 1058, 65105, 1043, 65106, 1058, 65107, 1043, 65108, 10, 65110, 1054, + 65112, 1032, 65113, 1058, 65114, 1037, 65115, 1043, 65116, 1037, 65117, 1043, 65118, 1037, 65119, 1043, 65127, 1058, 65128, 10, 65129, 1058, 65130, 1035, + 65131, 1036, 65132, 1058, 65279, 10, 65280, 40, 65281, 10, 65282, 1032, 65284, 1058, 65285, 1035, 65286, 1036, 65288, 1058, 65289, 1037, 65290, 1043, + 65292, 1058, 65293, 1043, 65294, 1058, 65295, 1043, 65306, 1058, 65308, 1054, 65311, 1058, 65312, 1032, 65339, 1058, 65340, 1037, 65341, 1058, 65342, 1043, + 65371, 1058, 65372, 1037, 65373, 1058, 65374, 1043, 65375, 1058, 65376, 1037, 65378, 1043, 65379, 1037, 65381, 1043, 65382, 1054, 65383, 1058, 65393, 1054, + 65438, 1058, 65440, 1054, 65471, 1058, 65474, 10, 65480, 1058, 65482, 10, 65488, 1058, 65490, 10, 65496, 1058, 65498, 10, 65501, 1058, 65504, 10, + 65505, 1036, 65506, 1035, 65509, 1058, 65511, 1035, 65512, 10, 65519, 1034, 65529, 10, 65532, 2, 65533, 46, 65792, 10, 65795, 3, 66045, 10, + 66046, 2, 66272, 10, 66273, 2, 66422, 10, 66427, 2, 66463, 10, 66464, 3, 66512, 10, 66513, 3, 66720, 10, 66730, 18, 67671, 10, + 67672, 3, 67871, 10, 67872, 3, 68097, 10, 68100, 2, 68101, 10, 68103, 2, 68108, 10, 68112, 2, 68152, 10, 68155, 2, 68159, 10, + 68160, 2, 68176, 10, 68184, 3, 68325, 10, 68327, 2, 68336, 10, 68342, 3, 68343, 39, 68409, 10, 68416, 3, 68900, 10, 68904, 2, + 68912, 10, 68922, 18, 68928, 10, 68938, 18, 68969, 10, 68974, 2, 68975, 24, 69291, 10, 69293, 2, 69294, 24, 69328, 10, 69329, 3, + 69370, 10, 69376, 2, 69446, 10, 69457, 2, 69506, 10, 69510, 2, 69632, 10, 69635, 2, 69637, 47, 69688, 31, 69702, 2, 69703, 32, + 69705, 3, 69710, 34, 69714, 10, 69734, 34, 69744, 33, 69745, 2, 69747, 31, 69749, 2, 69750, 31, 69759, 10, 69760, 21, 69763, 2, + 69808, 10, 69819, 2, 69821, 10, 69822, 18, 69826, 3, 69827, 2, 69837, 10, 69838, 18, 69872, 10, 69882, 18, 69888, 10, 69891, 2, + 69927, 10, 69941, 2, 69942, 10, 69952, 18, 69956, 3, 69957, 10, 69959, 2, 70003, 10, 70004, 2, 70005, 10, 70006, 23, 70016, 10, + 70019, 2, 70067, 10, 70081, 2, 70085, 10, 70087, 3, 70088, 10, 70089, 3, 70093, 2, 70094, 10, 70096, 2, 70106, 18, 70107, 10, + 70108, 23, 70109, 10, 70112, 3, 70188, 10, 70200, 2, 70202, 3, 70203, 10, 70205, 3, 70206, 10, 70207, 2, 70209, 10, 70210, 2, + 70313, 10, 70314, 3, 70367, 10, 70379, 2, 70384, 10, 70394, 18, 70400, 10, 70404, 2, 70405, 10, 70413, 31, 70415, 10, 70417, 31, + 70419, 10, 70441, 31, 70442, 10, 70449, 31, 70450, 10, 70452, 31, 70453, 10, 70458, 31, 70459, 10, 70461, 2, 70462, 3, 70469, 2, + 70471, 10, 70473, 2, 70475, 10, 70477, 2, 70478, 32, 70480, 10, 70481, 33, 70487, 10, 70488, 2, 70493, 10, 70494, 3, 70496, 33, + 70498, 31, 70500, 2, 70502, 10, 70509, 2, 70512, 10, 70517, 2, 70528, 10, 70538, 33, 70539, 10, 70540, 33, 70542, 10, 70543, 33, + 70544, 10, 70546, 33, 70582, 31, 70583, 10, 70584, 34, 70593, 2, 70594, 10, 70595, 2, 70597, 10, 70598, 2, 70599, 10, 70603, 2, + 70604, 10, 70608, 2, 70609, 32, 70610, 47, 70611, 2, 70614, 34, 70615, 10, 70617, 34, 70625, 10, 70627, 2, 70709, 10, 70727, 2, + 70731, 10, 70735, 3, 70736, 10, 70746, 18, 70748, 3, 70750, 10, 70751, 2, 70832, 10, 70852, 2, 70864, 10, 70874, 18, 71087, 10, + 71094, 2, 71096, 10, 71105, 2, 71106, 23, 71108, 3, 71110, 8, 71113, 10, 71128, 3, 71132, 10, 71134, 2, 71216, 10, 71233, 2, + 71235, 3, 71248, 10, 71258, 18, 71264, 10, 71277, 23, 71339, 10, 71352, 2, 71360, 10, 71370, 18, 71376, 10, 71396, 18, 71453, 10, + 71468, 2, 71472, 10, 71482, 18, 71484, 10, 71487, 3, 71724, 10, 71739, 2, 71904, 10, 71914, 18, 71936, 10, 71943, 31, 71945, 10, + 71946, 31, 71948, 10, 71956, 31, 71957, 10, 71959, 31, 71960, 10, 71984, 31, 71990, 2, 71991, 10, 71993, 2, 71995, 10, 71998, 2, + 71999, 32, 72000, 47, 72001, 2, 72002, 47, 72004, 2, 72007, 3, 72016, 10, 72026, 33, 72145, 10, 72152, 2, 72154, 10, 72161, 2, + 72162, 10, 72163, 23, 72164, 10, 72165, 2, 72193, 10, 72203, 2, 72243, 10, 72250, 2, 72251, 10, 72255, 2, 72256, 23, 72257, 10, + 72261, 3, 72262, 23, 72263, 10, 72264, 2, 72273, 10, 72284, 2, 72330, 10, 72346, 2, 72349, 3, 72350, 10, 72353, 23, 72355, 3, + 72448, 10, 72458, 23, 72544, 10, 72552, 2, 72688, 10, 72698, 18, 72751, 10, 72759, 2, 72760, 10, 72768, 2, 72769, 10, 72774, 3, + 72784, 10, 72794, 18, 72816, 10, 72817, 23, 72818, 8, 72850, 10, 72872, 2, 72873, 10, 72887, 2, 73009, 10, 73015, 2, 73018, 10, + 73019, 2, 73020, 10, 73022, 2, 73023, 10, 73030, 2, 73031, 10, 73032, 2, 73040, 10, 73050, 18, 73098, 10, 73103, 2, 73104, 10, + 73106, 2, 73107, 10, 73112, 2, 73120, 10, 73130, 18, 73184, 10, 73194, 18, 73440, 10, 73458, 33, 73459, 3, 73463, 2, 73465, 3, + 73472, 10, 73474, 2, 73475, 47, 73476, 2, 73489, 31, 73490, 10, 73524, 31, 73531, 2, 73534, 10, 73538, 2, 73539, 32, 73541, 3, + 73552, 34, 73562, 33, 73563, 2, 73693, 10, 73697, 12, 73727, 10, 73728, 3, 74864, 10, 74869, 3, 78424, 10, 78427, 13, 78430, 19, + 78466, 10, 78467, 19, 78470, 10, 78471, 13, 78472, 19, 78473, 13, 78474, 19, 78713, 10, 78714, 13, 78716, 19, 78895, 10, 78896, 13, + 78903, 21, 78904, 13, 78905, 19, 78908, 21, 78909, 13, 78910, 19, 78911, 13, 78912, 19, 78913, 2, 78919, 10, 78934, 2, 83406, 10, + 83407, 13, 83408, 19, 90368, 10, 90398, 33, 90416, 2, 90426, 33, 92768, 10, 92778, 18, 92782, 10, 92784, 3, 92864, 10, 92874, 18, + 92912, 10, 92917, 2, 92918, 3, 92976, 10, 92983, 2, 92986, 3, 92996, 10, 92997, 3, 93008, 10, 93018, 18, 93550, 10, 93552, 3, + 93562, 18, 93847, 10, 93849, 3, 94031, 10, 94032, 2, 94033, 10, 94088, 2, 94095, 10, 94099, 2, 94176, 10, 94180, 1054, 94181, 1045, + 94192, 10, 94194, 1026, 94196, 1054, 94199, 1058, 94208, 10, 101120, 1058, 101590, 1034, 101631, 10, 101632, 1034, 101663, 1058, 101760, 10, 101875, 1058, + 110576, 10, 110580, 1034, 110581, 10, 110588, 1034, 110589, 10, 110591, 1034, 110592, 10, 110883, 1058, 110898, 10, 110899, 1054, 110928, 10, 110931, 1054, + 110933, 10, 110934, 1054, 110948, 10, 110952, 1054, 110960, 10, 111356, 1058, 113821, 10, 113823, 2, 113824, 3, 113828, 2, 118000, 10, 118010, 18, + 118528, 10, 118574, 2, 118576, 10, 118599, 2, 119141, 10, 119146, 2, 119149, 10, 119171, 2, 119173, 10, 119180, 2, 119210, 10, 119214, 2, + 119362, 10, 119365, 2, 119552, 10, 119639, 1034, 119648, 10, 119671, 1034, 120782, 10, 120832, 18, 121344, 10, 121399, 2, 121403, 10, 121453, 2, + 121461, 10, 121462, 2, 121476, 10, 121477, 2, 121479, 10, 121483, 3, 121499, 10, 121504, 2, 121505, 10, 121520, 2, 122880, 10, 122887, 2, + 122888, 10, 122905, 2, 122907, 10, 122914, 2, 122915, 10, 122917, 2, 122918, 10, 122923, 2, 123023, 10, 123024, 2, 123184, 10, 123191, 2, + 123200, 10, 123210, 18, 123566, 10, 123567, 2, 123628, 10, 123632, 2, 123642, 18, 123647, 10, 123648, 11, 124140, 10, 124144, 2, 124154, 18, + 124398, 10, 124400, 2, 124401, 10, 124411, 18, 124643, 10, 124644, 2, 124646, 10, 124647, 2, 124654, 10, 124656, 2, 124661, 10, 124662, 2, + 125136, 10, 125143, 2, 125252, 10, 125259, 2, 125264, 10, 125274, 18, 125278, 10, 125280, 13, 126124, 10, 126125, 12, 126128, 10, 126129, 12, + 126976, 10, 126980, 34, 126981, 1058, 127020, 34, 127024, 2082, 127124, 34, 127136, 2082, 127151, 34, 127153, 2082, 127168, 34, 127169, 2082, 127183, 34, + 127184, 1058, 127185, 2082, 127222, 34, 127232, 2082, 127374, 10, 127375, 1034, 127377, 10, 127387, 1034, 127406, 10, 127462, 2082, 127488, 48, 127491, 1058, + 127504, 2082, 127548, 1058, 127552, 2082, 127561, 1058, 127568, 2082, 127570, 1058, 127584, 2082, 127590, 1058, 127744, 2082, 127777, 1058, 127789, 34, 127798, 1058, + 127799, 34, 127869, 1058, 127870, 34, 127877, 1058, 127878, 1065, 127892, 1058, 127900, 34, 127902, 10, 127904, 34, 127925, 1058, 127927, 1034, 127932, 1058, + 127933, 1034, 127938, 1058, 127941, 1065, 127943, 1058, 127944, 1065, 127946, 1058, 127947, 1065, 127949, 41, 127951, 34, 127956, 1058, 127968, 34, 127985, 1058, + 127988, 34, 127989, 1058, 127992, 34, 127995, 1058, 128000, 1073, 128063, 1058, 128064, 34, 128065, 1058, 128066, 34, 128068, 1065, 128070, 1058, 128081, 1065, + 128102, 1058, 128121, 1065, 128124, 1058, 128125, 1065, 128129, 1058, 128132, 1065, 128133, 1058, 128136, 1065, 128143, 1058, 128144, 1065, 128145, 1058, 128146, 1065, + 128160, 1058, 128161, 1034, 128162, 1058, 128163, 1034, 128164, 1058, 128165, 1034, 128170, 1058, 128171, 1065, 128175, 1058, 128176, 1034, 128177, 1058, 128179, 1034, + 128253, 1058, 128255, 34, 128256, 1058, 128263, 1034, 128279, 1058, 128293, 1034, 128306, 1058, 128318, 1034, 128330, 10, 128331, 34, 128335, 1058, 128336, 34, + 128360, 1058, 128372, 34, 128374, 41, 128378, 34, 128379, 1065, 128400, 34, 128401, 41, 128405, 34, 128407, 1065, 128420, 34, 128421, 1058, 128468, 34, + 128476, 10, 128500, 34, 128506, 10, 128507, 34, 128581, 1058, 128584, 1065, 128587, 1058, 128592, 1065, 128630, 10, 128633, 9, 128636, 30, 128640, 10, + 128675, 1058, 128676, 1065, 128692, 1058, 128695, 1065, 128704, 1058, 128705, 1065, 128710, 1058, 128716, 34, 128717, 1065, 128720, 34, 128723, 1058, 128725, 34, + 128729, 1058, 128732, 2082, 128736, 1058, 128747, 34, 128749, 1058, 128752, 2082, 128756, 34, 128765, 1058, 128768, 2082, 128884, 10, 128887, 34, 128891, 10, + 128896, 34, 128981, 10, 128986, 34, 128992, 2082, 129004, 1058, 129008, 2082, 129009, 1058, 129024, 2082, 129036, 10, 129040, 2058, 129096, 10, 129104, 2058, + 129114, 10, 129120, 2058, 129160, 10, 129168, 2058, 129198, 10, 129200, 2058, 129212, 10, 129216, 2058, 129218, 10, 129232, 2058, 129241, 10, 129280, 2058, + 129292, 10, 129293, 1065, 129295, 1058, 129296, 1065, 129304, 1058, 129312, 1065, 129318, 1058, 129319, 1065, 129328, 1058, 129338, 1065, 129339, 1058, 129340, 34, + 129343, 1065, 129350, 1058, 129351, 34, 129399, 1058, 129400, 1065, 129461, 1058, 129463, 1065, 129464, 1058, 129466, 1065, 129467, 1058, 129468, 1065, 129485, 1058, + 129488, 1065, 129489, 1058, 129502, 1065, 129536, 1058, 129624, 10, 129632, 2082, 129646, 34, 129648, 2082, 129661, 1058, 129664, 2082, 129675, 1058, 129678, 2082, + 129731, 1058, 129734, 1065, 129735, 1058, 129736, 2082, 129737, 1058, 129741, 2082, 129757, 1058, 129759, 2082, 129771, 1058, 129775, 2082, 129776, 1058, 129785, 1065, + 129792, 2082, 130032, 10, 130042, 18, 130048, 10, 131070, 2082, 131072, 10, 196606, 1058, 196608, 10, 262142, 1058, 917505, 10, 917506, 2, 917536, 10, + 917632, 2, 917760, 10, 918000, 2, 1114112, 10, +]; diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index f8a34734..42f95abb 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; mod abi_contract; pub mod bidi; pub mod engine; +pub mod line_break; pub mod unicode; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] mod wire; diff --git a/packages/text/rust/shaper/src/line_break.rs b/packages/text/rust/shaper/src/line_break.rs new file mode 100644 index 00000000..af2916fd --- /dev/null +++ b/packages/text/rust/shaper/src/line_break.rs @@ -0,0 +1,571 @@ +// UAX #14 rule behavior is ported from @cto.af/linebreak 4.0.3. Its MIT license is retained in +// LICENSES/cto-af-linebreak-MIT.txt; the implementation is specialized to retained no_std Rust storage. +use alloc::vec::Vec; + +use crate::unicode::UnicodeError; + +#[allow(dead_code)] +mod generated { + include!("generated/line_break_data.rs"); +} + +const SOT: i16 = -1; +const EOT: i16 = -2; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct LineBreak { + pub position: u32, + pub required: bool, +} + +#[derive(Clone, Copy, Debug)] +struct BreakChar { + code_point: u32, + class: i16, + start: u32, + end: u32, + properties: u32, + source_index: usize, + ignored: bool, +} + +impl BreakChar { + const fn sentinel(class: i16, position: u32) -> Self { + Self { + code_point: 0, + class, + start: position, + end: position, + properties: 0, + source_index: usize::MAX, + ignored: false, + } + } + + fn has(self, property: u32) -> bool { + self.properties & property != 0 + } +} + +#[derive(Default)] +pub struct LineBreakAnalysis { + characters: Vec, + breaks: Vec, +} + +impl LineBreakAnalysis { + pub fn reserve(&mut self, capacity: usize) -> Result<(), UnicodeError> { + reserve(&mut self.characters, capacity)?; + reserve(&mut self.breaks, capacity.saturating_add(1)) + } + + pub fn analyze(&mut self, text: &[u16]) -> Result<(), UnicodeError> { + self.characters.clear(); + self.breaks.clear(); + self.reserve(text.len())?; + let mut offset = 0usize; + while offset < text.len() { + let start = offset; + let (code_point, consumed) = decode_scalar(text, offset)?; + offset += consumed; + let properties = properties(code_point).ok_or(UnicodeError::InvalidUtf16)?; + self.characters.push(BreakChar { + code_point, + class: (properties & 0xff) as i16, + start: u32::try_from(start).map_err(|_| UnicodeError::ResultTooLarge)?, + end: u32::try_from(offset).map_err(|_| UnicodeError::ResultTooLarge)?, + properties, + source_index: self.characters.len(), + ignored: false, + }); + } + let mut state = BreakState::new(&self.characters); + for character in self.characters.iter().copied() { + state.push(character); + if let Some(line_break) = state.decide() { + self.breaks.push(line_break); + } + } + let end = u32::try_from(text.len()).map_err(|_| UnicodeError::ResultTooLarge)?; + state.push(BreakChar::sentinel(EOT, end)); + if let Some(line_break) = state.decide() { + self.breaks.push(line_break); + } + Ok(()) + } + + pub fn breaks(&self) -> &[LineBreak] { + &self.breaks + } +} + +struct BreakState<'a> { + source: &'a [BreakChar], + previous: BreakChar, + current: BreakChar, + next: BreakChar, + previous_chunk: u32, + lb8: bool, + spaces: bool, + regional_indicators: u32, +} + +impl<'a> BreakState<'a> { + fn new(source: &'a [BreakChar]) -> Self { + let start = BreakChar::sentinel(SOT, 0); + Self { + source, + previous: start, + current: start, + next: start, + previous_chunk: 0, + lb8: false, + spaces: false, + regional_indicators: 0, + } + } + + fn push(&mut self, character: BreakChar) { + if self.next.ignored { + self.current.end = self.next.end; + } else { + self.previous = self.current; + self.current = self.next; + } + self.next = character; + } + + #[allow(clippy::too_many_lines)] + fn decide(&mut self) -> Option { + use generated::*; + let cur = self.current.class; + let next = self.next.class; + + // LB2-LB6. + if cur == SOT && next != EOT { + return None; + } + if next == EOT && (self.current.end == 0 || self.current.end != self.previous_chunk) { + return self.emit(true); + } + if cur == i16::from(BK) { + return self.emit(true); + } + if cur == i16::from(CR) { + return if next == i16::from(LF) { + None + } else { + self.emit(true) + }; + } + if matches!(cur, value if value == i16::from(LF) || value == i16::from(NL)) { + return self.emit(true); + } + if matches!(next, value if value == i16::from(BK) || value == i16::from(CR) || value == i16::from(LF) || value == i16::from(NL)) + { + return None; + } + + // Shared state before LB7. + if cur != i16::from(RI) { + self.regional_indicators = 0; + } + if self.spaces { + if next != i16::from(SP) { + self.spaces = false; + } + return None; + } + + // LB7-LB10. + if next == i16::from(ZW) { + return None; + } + if next == i16::from(SP) + && !matches!(cur, value if value == i16::from(ZW) || value == i16::from(OP) || value == i16::from(QU) || value == i16::from(CL) || value == i16::from(CP) || value == i16::from(B2)) + { + return None; + } + if self.lb8 { + self.lb8 = false; + return self.emit(false); + } + if cur == i16::from(ZW) { + if next == i16::from(SP) { + self.lb8 = true; + return None; + } + return self.emit(false); + } + if cur == i16::from(ZWJ) { + return None; + } + if !is_one_of(cur, &[BK, CR, LF, NL, SP, ZW]) + && matches!(next, value if value == i16::from(CM) || value == i16::from(ZWJ)) + { + self.next.ignored = true; + return None; + } + if self.current.class == i16::from(CM) { + self.current.class = i16::from(AL); + } + if self.next.class == i16::from(CM) { + self.next.class = i16::from(AL); + } + let cur = self.current.class; + let next = self.next.class; + + // LB11-LB15d. + if cur == i16::from(WJ) || next == i16::from(WJ) || cur == i16::from(GL) { + return None; + } + if next == i16::from(GL) && !is_one_of(cur, &[SP, BA, HY, HH]) { + return None; + } + if is_one_of(next, &[CL, CP, EX, SY]) { + return None; + } + if cur == i16::from(OP) { + self.spaces = next == i16::from(SP); + return None; + } + if (is_one_of(self.previous.class, &[BK, CR, LF, NL, OP, QU, GL, SP, ZW]) + || self.previous.class == SOT) + && self.current.has(INITIAL_PUNCTUATION) + && cur == i16::from(QU) + { + self.spaces = true; + return None; + } + if self.next.has(FINAL_PUNCTUATION) && next == i16::from(QU) { + let after = self.after_next(1); + if after.is_none_or(|character| { + is_one_of( + character.class, + &[SP, GL, WJ, CL, QU, CP, EX, IS, SY, BK, CR, LF, NL, ZW], + ) + }) { + return None; + } + } + if cur == i16::from(SP) + && next == i16::from(IS) + && self + .after_next(1) + .is_some_and(|after| after.class == i16::from(NU)) + { + return self.emit(false); + } + if next == i16::from(IS) { + return None; + } + + // LB16-LB20a. + if is_one_of(cur, &[CL, CP]) { + if self.class_after_spaces(self.current.end) == i16::from(NS) { + self.spaces = next == i16::from(SP); + return None; + } + if next == i16::from(SP) { + return None; + } + } + if cur == i16::from(B2) { + if self.class_after_spaces(self.current.end) == i16::from(B2) { + self.spaces = next == i16::from(SP); + return None; + } + if next == i16::from(SP) { + return None; + } + } + if cur == i16::from(SP) { + return self.emit(false); + } + if next == i16::from(QU) && !self.next.has(INITIAL_PUNCTUATION) { + return None; + } + if cur == i16::from(QU) && !self.current.has(FINAL_PUNCTUATION) { + return None; + } + if !self.current.has(EAST_ASIAN) && next == i16::from(QU) { + return None; + } + if next == i16::from(QU) + && self + .after_next(1) + .is_none_or(|after| !after.has(EAST_ASIAN)) + { + return None; + } + if cur == i16::from(QU) && !self.next.has(EAST_ASIAN) { + return None; + } + if cur == i16::from(QU) && (self.previous.class == SOT || !self.previous.has(EAST_ASIAN)) { + return None; + } + if cur == i16::from(CB) || next == i16::from(CB) { + return self.emit(false); + } + if (self.previous.class == SOT + || is_one_of(self.previous.class, &[BK, CR, LF, NL, SP, ZW, CB, GL])) + && is_one_of(cur, &[HY, HH]) + && is_one_of(next, &[AL, HL]) + { + return None; + } + + // LB21-LB24. + if cur == i16::from(BB) || is_one_of(next, &[BA, HH, HY, NS]) { + return None; + } + if self.previous.class == i16::from(HL) + && is_one_of(cur, &[HY, HH]) + && next != i16::from(HL) + { + return None; + } + if cur == i16::from(SY) && next == i16::from(HL) { + return None; + } + if next == i16::from(IN) { + return None; + } + if (is_one_of(cur, &[AL, HL]) && next == i16::from(NU)) + || (cur == i16::from(NU) && is_one_of(next, &[AL, HL])) + { + return None; + } + if (cur == i16::from(PR) && is_one_of(next, &[ID, EB, EM])) + || (next == i16::from(PO) && is_one_of(cur, &[ID, EB, EM])) + { + return None; + } + if (is_one_of(cur, &[PR, PO]) && is_one_of(next, &[AL, HL])) + || (is_one_of(cur, &[AL, HL]) && is_one_of(next, &[PR, PO])) + { + return None; + } + + // LB25. + if is_one_of(next, &[PO, PR, NU]) { + let before = if is_one_of(next, &[PO, PR]) && is_one_of(cur, &[CL, CP]) { + self.previous.end + } else { + self.current.end + }; + if self.numeric_prefix_before(before) { + return None; + } + } + if is_one_of(cur, &[PO, PR]) { + if next == i16::from(OP) { + if let Some(after) = self.after_next(1) + && (after.class == i16::from(NU) + || (after.class == i16::from(IS) + && self + .after_next(2) + .is_some_and(|value| value.class == i16::from(NU)))) + { + return None; + } + } else if next == i16::from(NU) { + return None; + } + } + if is_one_of(cur, &[HY, IS]) && next == i16::from(NU) { + return None; + } + + // LB26-LB30b. + if (cur == i16::from(JL) && is_one_of(next, &[JL, JV, H2, H3])) + || (is_one_of(cur, &[JV, H2]) && is_one_of(next, &[JV, JT])) + || (is_one_of(cur, &[JT, H3]) && next == i16::from(JT)) + { + return None; + } + if (is_one_of(cur, &[JL, JV, JT, H2, H3]) && next == i16::from(PO)) + || (cur == i16::from(PR) && is_one_of(next, &[JL, JV, JT, H2, H3])) + { + return None; + } + if is_one_of(cur, &[AL, HL]) && is_one_of(next, &[AL, HL]) { + return None; + } + let dotted_circle = 0x25cc; + let current_ak = is_one_of(cur, &[AK, AS]) || self.current.code_point == dotted_circle; + let next_ak = is_one_of(next, &[AK, AS]) || self.next.code_point == dotted_circle; + let previous_ak = + is_one_of(self.previous.class, &[AK, AS]) || self.previous.code_point == dotted_circle; + if (cur == i16::from(AP) && next_ak) + || (current_ak && is_one_of(next, &[VF, VI])) + || (previous_ak + && cur == i16::from(VI) + && (next == i16::from(AK) || self.next.code_point == dotted_circle)) + || (current_ak + && next_ak + && self + .after_next(1) + .is_some_and(|after| after.class == i16::from(VF))) + { + return None; + } + if cur == i16::from(IS) && is_one_of(next, &[AL, HL]) { + return None; + } + if is_one_of(cur, &[AL, HL, NU]) && next == i16::from(OP) && !self.next.has(EAST_ASIAN) { + return None; + } + if cur == i16::from(CP) && !self.current.has(EAST_ASIAN) && is_one_of(next, &[AL, HL, NU]) { + return None; + } + if cur == i16::from(RI) && next == i16::from(RI) { + self.regional_indicators += 1; + if !self.regional_indicators.is_multiple_of(2) { + return None; + } + } else if cur != i16::from(RI) { + self.regional_indicators = 0; + } + if cur == i16::from(EB) && next == i16::from(EM) { + return None; + } + if next == i16::from(EM) && self.current.has(UNASSIGNED_EXTENDED_PICTOGRAPHIC) { + return None; + } + + // LB31. + self.emit(false) + } + + fn emit(&mut self, required: bool) -> Option { + let position = self.current.end; + self.previous_chunk = position; + Some(LineBreak { position, required }) + } + + fn after_next(&self, offset: usize) -> Option { + let index = self.next.source_index.checked_add(offset)?; + self.source.get(index).copied() + } + + fn class_after_spaces(&self, position: u32) -> i16 { + self.source + .iter() + .find(|character| { + character.start >= position && character.class != i16::from(generated::SP) + }) + .map_or(EOT, |character| character.class) + } + + fn numeric_prefix_before(&self, mut position: u32) -> bool { + while position > 0 { + let character = if position == self.current.end { + self.current + } else if position == self.previous.end { + self.previous + } else if let Some(character) = self + .source + .iter() + .rev() + .find(|character| character.end <= position) + .copied() + { + character + } else { + return false; + }; + if character.class == i16::from(generated::NU) { + return true; + } + if !is_one_of(character.class, &[generated::SY, generated::IS]) { + return false; + } + position = character.start; + } + false + } +} + +fn properties(code_point: u32) -> Option { + if code_point > 0x10_ffff { + return None; + } + let values = generated::LINE_BREAK_END_VALUES; + let mut low = 0usize; + let mut high = values.len() / 2; + while low < high { + let middle = low + (high - low) / 2; + if code_point >= values[middle * 2] { + low = middle + 1; + } else { + high = middle; + } + } + values.get(low.checked_mul(2)?.checked_add(1)?).copied() +} + +fn is_one_of(class: i16, values: &[u8]) -> bool { + values.iter().any(|value| class == i16::from(*value)) +} + +fn decode_scalar(text: &[u16], index: usize) -> Result<(u32, usize), UnicodeError> { + let first = text[index]; + if (0xd800..=0xdbff).contains(&first) { + let second = text + .get(index + 1) + .copied() + .filter(|unit| (0xdc00..=0xdfff).contains(unit)) + .ok_or(UnicodeError::InvalidUtf16)?; + return Ok(( + 0x1_0000 + (((u32::from(first) - 0xd800) << 10) | (u32::from(second) - 0xdc00)), + 2, + )); + } + if (0xdc00..=0xdfff).contains(&first) { + return Err(UnicodeError::InvalidUtf16); + } + Ok((u32::from(first), 1)) +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), UnicodeError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| UnicodeError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finds_required_and_allowed_utf16_breaks() { + let mut analysis = LineBreakAnalysis::default(); + analysis + .analyze(&"one two".encode_utf16().collect::>()) + .unwrap(); + assert_eq!( + analysis.breaks(), + &[ + LineBreak { + position: 4, + required: false, + }, + LineBreak { + position: 7, + required: true, + }, + ] + ); + analysis + .analyze(&"one\r\ntwo".encode_utf16().collect::>()) + .unwrap(); + assert!(analysis.breaks().contains(&LineBreak { + position: 5, + required: true, + })); + } +} diff --git a/packages/text/rust/shaper/src/unicode.rs b/packages/text/rust/shaper/src/unicode.rs index f45d4133..a4031c11 100644 --- a/packages/text/rust/shaper/src/unicode.rs +++ b/packages/text/rust/shaper/src/unicode.rs @@ -1,6 +1,8 @@ use alloc::{string::String, vec::Vec}; use unicode_segmentation::UnicodeSegmentation; +use crate::line_break::{LineBreak, LineBreakAnalysis}; + mod generated { include!("generated/script_data.rs"); } @@ -27,6 +29,7 @@ pub struct ScriptItem { #[derive(Default)] pub struct UnicodeAnalysis { utf8: String, + line_breaks: LineBreakAnalysis, grapheme_boundaries: Vec, grapheme_scripts: Vec, candidate_offsets: Vec, @@ -48,6 +51,7 @@ impl UnicodeAnalysis { )?; reserve(&mut self.candidate_tags, utf16_capacity)?; reserve(&mut self.script_items, utf16_capacity)?; + self.line_breaks.reserve(utf16_capacity)?; self.utf8 .try_reserve(utf16_capacity.saturating_mul(3)) .map_err(|_| UnicodeError::ResultTooLarge) @@ -57,6 +61,7 @@ impl UnicodeAnalysis { self.clear(); self.reserve(text.len())?; encode_utf16(text, &mut self.utf8)?; + self.line_breaks.analyze(text)?; self.segment_graphemes()?; self.resolve_grapheme_scripts(text)?; self.resolve_neutral_scripts(); @@ -72,6 +77,10 @@ impl UnicodeAnalysis { &self.script_items } + pub fn line_breaks(&self) -> &[LineBreak] { + self.line_breaks.breaks() + } + fn clear(&mut self) { self.utf8.clear(); self.grapheme_boundaries.clear(); diff --git a/packages/text/rust/shaper/tests/unicode_line_break_conformance.rs b/packages/text/rust/shaper/tests/unicode_line_break_conformance.rs new file mode 100644 index 00000000..064f7777 --- /dev/null +++ b/packages/text/rust/shaper/tests/unicode_line_break_conformance.rs @@ -0,0 +1,60 @@ +use std::{fs::File, io::Read, path::PathBuf}; + +use flate2::read::GzDecoder; +use pmndrs_text_shaper::line_break::LineBreakAnalysis; + +const LINE_BREAK_TEST_CASES: usize = 19_338; + +#[test] +fn unicode_17_line_break_test_is_fully_conformant() { + let source = fixture("LineBreakTest.txt.gz"); + let mut analysis = LineBreakAnalysis::default(); + let mut cases = 0usize; + for (line_index, source) in source.lines().enumerate() { + let body = source.split('#').next().unwrap_or_default().trim(); + if body.is_empty() { + continue; + } + let tokens: Vec<&str> = body.split_whitespace().collect(); + let mut text = Vec::new(); + let mut expected = Vec::new(); + let mut cursor = 0usize; + while cursor < tokens.len() { + if tokens[cursor] == "÷" && !text.is_empty() { + expected.push(u32::try_from(text.len()).expect("UTF-16 offset")); + } + cursor += 1; + let Some(hexadecimal) = tokens.get(cursor) else { + break; + }; + let code_point = u32::from_str_radix(hexadecimal, 16).expect("code point"); + let character = char::from_u32(code_point).expect("Unicode scalar"); + let mut encoded = [0u16; 2]; + text.extend_from_slice(character.encode_utf16(&mut encoded)); + cursor += 1; + } + analysis + .analyze(&text) + .unwrap_or_else(|error| panic!("LineBreakTest:{}: {error:?}", line_index + 1)); + let actual: Vec = analysis + .breaks() + .iter() + .map(|line_break| line_break.position) + .collect(); + assert_eq!(actual, expected, "LineBreakTest:{}", line_index + 1); + cases += 1; + } + assert_eq!(cases, LINE_BREAK_TEST_CASES); +} + +fn fixture(name: &str) -> String { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/fixtures/unicode-17.0.0") + .join(name); + let mut decoder = GzDecoder::new(File::open(path).expect("open Unicode fixture")); + let mut source = String::new(); + decoder + .read_to_string(&mut source) + .expect("decode Unicode fixture"); + source +} diff --git a/packages/text/scripts/generate-unicode-line-break-data.mjs b/packages/text/scripts/generate-unicode-line-break-data.mjs new file mode 100644 index 00000000..c8b2a1d9 --- /dev/null +++ b/packages/text/scripts/generate-unicode-line-break-data.mjs @@ -0,0 +1,83 @@ +import { createRequire } from 'node:module'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; + +import { EastAsianWidth } from '@cto.af/linebreak'; +import { LineBreak, names } from '@cto.af/linebreak/lib/LineBreak.js'; +import { resolve } from '@cto.af/linebreak/lib/state.js'; + +const require = createRequire(import.meta.url); +const output = new URL('../rust/shaper/src/generated/line_break_data.rs', import.meta.url); +const check = process.argv.includes('--check'); +const initial = codePointSet('General_Category/Initial_Punctuation'); +const final = codePointSet('General_Category/Final_Punctuation'); +const extendedPictographic = codePointSet('Binary_Property/Extended_Pictographic'); +const unassigned = codePointSet('General_Category/Unassigned'); + +const values = new Uint16Array(0x11_0000); +for (let codePoint = 0; codePoint < values.length; codePoint += 1) { + const character = String.fromCodePoint(codePoint); + const lineClass = resolve(LineBreak.get(codePoint), character); + values[codePoint] = + lineClass | + (initial.has(codePoint) ? 1 << 8 : 0) | + (final.has(codePoint) ? 1 << 9 : 0) | + (EastAsianWidth.get(codePoint) ? 1 << 10 : 0) | + (extendedPictographic.has(codePoint) && unassigned.has(codePoint) ? 1 << 11 : 0); +} + +const endValues = []; +let start = 0; +for (let codePoint = 1; codePoint <= values.length; codePoint += 1) { + if (codePoint === values.length || values[codePoint] !== values[start]) { + endValues.push(codePoint, values[start]); + start = codePoint; + } +} + +const constants = Object.entries(names) + .sort((left, right) => left[1] - right[1]) + .map(([name, value]) => `pub const ${name}: u8 = ${value};`) + .join('\n'); +const source = `// Generated by scripts/generate-unicode-line-break-data.mjs from +// @cto.af/linebreak@4.0.3 and @unicode/unicode-17.0.0@1.6.17. Do not edit by hand. + +pub const UNICODE_VERSION: &str = "17.0.0"; +pub const INITIAL_PUNCTUATION: u32 = 1 << 8; +pub const FINAL_PUNCTUATION: u32 = 1 << 9; +pub const EAST_ASIAN: u32 = 1 << 10; +pub const UNASSIGNED_EXTENDED_PICTOGRAPHIC: u32 = 1 << 11; + +${constants} + +pub const LINE_BREAK_END_VALUES: &[u32] = &[ +${wrapped(endValues)} +]; +`; + +if (check) { + let existing; + try { + existing = await readFile(output, 'utf8'); + } catch { + existing = undefined; + } + if (existing !== source) { + console.error('generated Unicode line-break data is stale; run pnpm run unicode generate-data'); + process.exitCode = 1; + } +} else { + await mkdir(new URL('../rust/shaper/src/generated/', import.meta.url), { recursive: true }); + await writeFile(output, source); +} + +function codePointSet(path) { + return new Set(require(`@unicode/unicode-17.0.0/${path}/code-points.js`)); +} + +function wrapped(entries) { + const lines = []; + for (let index = 0; index < entries.length; index += 24) { + lines.push(` ${entries.slice(index, index + 24).join(', ')},`); + } + return lines.join('\n'); +} diff --git a/packages/text/scripts/unicode.mts b/packages/text/scripts/unicode.mts index aa818df9..d431c028 100644 --- a/packages/text/scripts/unicode.mts +++ b/packages/text/scripts/unicode.mts @@ -43,10 +43,12 @@ export async function runUnicode(arguments_: readonly string[]): Promise { case 'generate-data': await runNode('scripts/generate-unicode-script-data.mjs', rest); await runNode('scripts/generate-unicode-bidi-data.mjs', rest); + await runNode('scripts/generate-unicode-line-break-data.mjs', rest); return; case 'check-data': await runNode('scripts/generate-unicode-script-data.mjs', ['--check', ...rest]); await runNode('scripts/generate-unicode-bidi-data.mjs', ['--check', ...rest]); + await runNode('scripts/generate-unicode-line-break-data.mjs', ['--check', ...rest]); return; case 'sync-tests': await runNode('scripts/sync-unicode-test-data.mjs', rest); From 9639b55801346f1f348991297ed9521215e772f9 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 15:18:26 -0400 Subject: [PATCH 034/128] feat(text): retain rust measured clusters --- docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 10 + .../rust/shaper/src/engine/cluster_state.rs | 351 ++++++++++++++++++ packages/text/rust/shaper/src/engine/mod.rs | 1 + packages/text/rust/shaper/src/engine/state.rs | 93 ++++- .../rust/shaper/src/engine/style_state.rs | 12 + packages/text/rust/shaper/src/lib.rs | 23 ++ 8 files changed, 485 insertions(+), 10 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/cluster_state.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index 320db29a..71672b5f 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6a323c3e295101f2919510e10cd1e6b3da9d5970a5846a9c41e66a2b0faa9fa8' +source_digest: 'sha256:41917a3e5f759cd02ddb43da0a38d1ea178dda666cad7a410c4e84c0bbcb5103' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -671,6 +671,8 @@ Roadmap item 5.1 adds synchronous paragraph preparation and measurement. Unicode The target Rust frame path now derives the same line opportunities internally. Its generator resolves the pinned `@cto.af/linebreak` property trie into a compact Rust scalar partition, and a specialized allocation-reusing `no_std` evaluator ports the ordered UAX #14 rules while retaining the upstream MIT notice. The Rust lane independently passes all 19,338 unchanged official Unicode 17 line-break vectors at canonical UTF-16 offsets; its results are retained with the session's transactional Unicode analysis rather than serialized through the legacy shaping ABI. +The same frame path aggregates final fallback glyphs into a retained grapheme-cluster SoA. It keeps ordered double-precision advances, compact shaping/line-break flags, style/source/font identities, and a UTF-16 offset index. Font UPEM and horizontal line metrics are parsed once at registration; glyph design-unit advances are scaled once per contributing final font, and authored letter/word spacing joins that accumulation without constructing per-cluster objects. Optional Unicode line opportunities survive only where the next cluster is safe according to HarfRust output. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 820afa6d..7077b8f3 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -252,6 +252,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-185 | Frame shaping consumes retained runs through a borrowed HarfRust input rather than constructing the legacy owned batch request. Legacy exports and `text_update` share the module-global prewarmed UnicodeBuffer, UTF-16 context scratch, and reusable 128-feature vector. Frame language/features borrow the active retained style arena. Each shaped run appends its font/source identity and glyph ID, UTF-16 cluster, advances, offsets, and flags directly into a pre-reserved A/B session SoA arena that swaps only on commit. The production Wasm update borrows the one existing `ShaperRegistry`; it does not duplicate font bytes or serialize shaped output through the old ABI. A compiled real-Inter test proves shape-plan cache count changes 0→1 only after `text_update` and remains unchanged after an aborted frame. Rust unit tests, host/SIMD Clippy, and focused compiled-Wasm tests pass. Optimized Wasm changes from 968,086 / 362,664 / 286,438 to 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes. This checkpoint shapes only each stack's primary font; ordered fallback, layout, nonempty plan output, and complete-path timing remain open. | Accepted | | D-186 | Ordered fallback is resolved inside the same Rust frame transaction from actual HarfRust `.notdef` output, never from `cmap`, raster coverage, or a host callback. Reusable flat spans name source run, UTF-16 range, stack index, and concrete font; reusable cluster records collapse multi-glyph clusters with missing status ORed across glyph zero. Records sort by source run/logical cluster to normalize RTL output, then one linear merge advances only missing ranges. Font index increases monotonically, bounding passes by stack depth. Final spans and shaped SoA commit together and abort together. A compiled Inter→Noto Devanagari `text_update` constructs exactly two HarfRust plans, causally proving primary and fallback shaping. Host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 973,367 / 364,517 / 287,942 to 982,356 / 368,183 / 290,439 raw/gzip/Brotli bytes. Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. | Accepted | | D-187 | Unicode 17 line-break opportunities are retained Rust Unicode state, not host input. The pinned `@cto.af/linebreak` 4.0.3 property trie is generated into a compact scalar partition containing resolved line-break class and only the punctuation, East Asian, and unassigned-extended-pictographic flags its ordered UAX #14 rules consume. A specialized `no_std` Rust evaluator reuses flat scalar/break arrays, returns UTF-16 offsets, retains the upstream MIT notice, and commits/aborts with grapheme/script analysis. All 19,338 unchanged official `LineBreakTest` cases pass, plus focused required-break tests; host/SIMD Clippy passes. Optimized Wasm changes from 982,356 / 368,183 / 290,439 to 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli bytes. Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-188 | Measured clusters are retained A/B Rust SoA state derived from final fallback-shaped glyphs and Unicode analysis: grapheme UTF-16 starts/ends, ordered `f64` advances, compact safe/allowed/required/hard-break flags, resolved-style index, source-run/font identity, and one offset-to-cluster index. Glyph positions remain `i32` design units through shaping and are scaled once with cached per-font UPEM; letter spacing, word spacing, and zero-width hard breaks join that ordered accumulation. Optional UAX #14 breaks are admitted only when the next grapheme is a HarfRust-safe boundary. A synthetic kernel proof fixes exact `[9, 7, 9, 0]` advances for design widths plus letter/word spacing and proves unsafe suppression; compiled real-font primary/fallback updates reach the pass. Optimized Wasm changes from 1,009,460 / 377,053 / 295,875 to 1,014,577 / 379,510 / 295,708 raw/gzip/Brotli bytes. The Brotli decrease is compressor interaction, not a speed claim. Composition, nonempty plan output, and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 3624f9d6..7d2a3f9f 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -899,6 +899,16 @@ offsets, and required CRLF/end breaks have focused tests. Optimized Wasm is 1,00 bytes (+27,104 / +8,870 / +5,436). Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. +Retained cluster measurement now consumes final fallback-shaped glyphs and Unicode analysis without a host view. One A/B +SoA stores grapheme UTF-16 bounds, `f64` logical advances, compact safe/allowed/required/hard-break flags, resolved-style +index, source-run/font identity, and one offset-to-cluster index. Glyph advances remain `i32` design units until the +cluster pass scales them once with font size and cached UPEM; letter spacing, word spacing, and hard-break width are +applied in that same ordered accumulation. UAX #14 opportunities are admitted only at a HarfRust-safe next cluster. +Synthetic exact tests cover scaling, both spacing lanes, hard breaks, allowed/required flags, unsafe suppression, and +capacity reuse; real-font compiled Wasm reaches the pass for primary and fallback text. Optimized Wasm is 1,014,577 / +379,510 / 295,708 raw/gzip/Brotli bytes (+5,117 / +2,457 / -167). The Brotli change is recorded as compressor interaction, +not a performance claim. Line composition, nonempty plan output, and complete-path timing remain open. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs new file mode 100644 index 00000000..e10d1d34 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -0,0 +1,351 @@ +use alloc::vec::Vec; + +use crate::{FontMetrics, unicode::UnicodeAnalysis}; + +use super::{ + EngineError, + shaping_state::{ShapeArena, ShapingRun}, + style_state::StyleSegment, +}; + +pub(crate) const CLUSTER_SAFE_BEFORE: u8 = 1 << 0; +pub(crate) const CLUSTER_REQUIRED_BREAK: u8 = 1 << 1; +pub(crate) const CLUSTER_HARD_BREAK: u8 = 1 << 2; +pub(crate) const CLUSTER_ALLOWED_BREAK: u8 = 1 << 3; + +const GLYPH_UNSAFE_TO_BREAK: u16 = 1; +const NO_SOURCE_RUN: u32 = u32::MAX; + +#[derive(Default)] +pub(crate) struct ClusterArena { + pub starts: Vec, + pub ends: Vec, + pub advances: Vec, + pub flags: Vec, + pub style_indexes: Vec, + pub source_runs: Vec, + pub font_handles: Vec, + pub index_at: Vec, + shaped: Vec, + unsafe_before: Vec, +} + +impl ClusterArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve(&mut self.starts, capacity)?; + reserve(&mut self.ends, capacity)?; + reserve(&mut self.advances, capacity)?; + reserve(&mut self.flags, capacity)?; + reserve(&mut self.style_indexes, capacity)?; + reserve(&mut self.source_runs, capacity)?; + reserve(&mut self.font_handles, capacity)?; + reserve(&mut self.index_at, capacity.saturating_add(1))?; + reserve(&mut self.shaped, capacity)?; + reserve(&mut self.unsafe_before, capacity) + } + + pub(crate) fn build( + &mut self, + text: &[u16], + unicode: &UnicodeAnalysis, + styles: &[StyleSegment], + runs: &[ShapingRun], + shape: &ShapeArena, + metrics_for: impl Fn(u32) -> Option, + ) -> Result<(), EngineError> { + self.clear(); + let boundaries = unicode.grapheme_boundaries(); + let count = boundaries.len().saturating_sub(1); + self.reserve(text.len().max(count))?; + let mut style_index = 0usize; + for boundaries in boundaries.windows(2) { + let start = boundaries[0]; + let end = boundaries[1]; + while styles + .get(style_index) + .is_some_and(|style| style.text_end <= start) + { + style_index += 1; + } + let style = styles.get(style_index).ok_or(EngineError::InvalidRequest)?; + if style.text_start > start || style.text_end < end { + return Err(EngineError::InvalidRequest); + } + let hard_break = is_hard_break(text, start)?; + let word_spacing = if text.get(start as usize) == Some(&0x20) { + style.style.word_spacing + } else { + 0.0 + }; + self.starts.push(start); + self.ends.push(end); + self.advances.push(if hard_break { + 0.0 + } else { + f64::from(style.style.letter_spacing + word_spacing) + }); + self.flags + .push(if hard_break { CLUSTER_HARD_BREAK } else { 0 }); + self.style_indexes + .push(u32::try_from(style_index).map_err(|_| EngineError::ResultTooLarge)?); + self.source_runs.push(NO_SOURCE_RUN); + self.font_handles.push(0); + self.shaped.push(0); + self.unsafe_before.push(0); + } + self.build_index(text.len())?; + self.aggregate_shape(runs, shape, metrics_for)?; + self.apply_break_flags(unicode)?; + Ok(()) + } + + pub(crate) fn clear(&mut self) { + self.starts.clear(); + self.ends.clear(); + self.advances.clear(); + self.flags.clear(); + self.style_indexes.clear(); + self.source_runs.clear(); + self.font_handles.clear(); + self.index_at.clear(); + self.shaped.clear(); + self.unsafe_before.clear(); + } + + fn build_index(&mut self, text_length: usize) -> Result<(), EngineError> { + let mut cluster = 0usize; + for offset in 0..=text_length { + while self + .starts + .get(cluster) + .is_some_and(|start| *start < offset as u32) + { + cluster += 1; + } + self.index_at + .push(u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?); + } + Ok(()) + } + + fn aggregate_shape( + &mut self, + runs: &[ShapingRun], + shape: &ShapeArena, + metrics_for: impl Fn(u32) -> Option, + ) -> Result<(), EngineError> { + for shaped_run in &shape.runs { + let source_index = + usize::try_from(shaped_run.source_run).map_err(|_| EngineError::InvalidRequest)?; + let source = runs.get(source_index).ok_or(EngineError::InvalidRequest)?; + let metrics = metrics_for(shaped_run.font_handle).ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(source.style.font_size) / f64::from(metrics.units_per_em); + let start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in start..end { + let cluster = *shape + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let cluster_index = self.cluster_at(cluster)?; + let source_slot = &mut self.source_runs[cluster_index]; + let font_slot = &mut self.font_handles[cluster_index]; + if *source_slot == NO_SOURCE_RUN { + *source_slot = shaped_run.source_run; + *font_slot = shaped_run.font_handle; + } else if *source_slot != shaped_run.source_run + || *font_slot != shaped_run.font_handle + { + return Err(EngineError::InvalidRequest); + } + self.shaped[cluster_index] = 1; + self.unsafe_before[cluster_index] |= u8::from( + shape + .glyph_flags + .get(glyph) + .is_some_and(|flags| flags & GLYPH_UNSAFE_TO_BREAK != 0), + ); + self.advances[cluster_index] += f64::from( + shape + .x_advances + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)? + .unsigned_abs(), + ) * scale; + } + } + for index in 0..self.starts.len() { + if self.shaped[index] != 0 && self.unsafe_before[index] == 0 { + self.flags[index] |= CLUSTER_SAFE_BEFORE; + } + } + Ok(()) + } + + fn apply_break_flags(&mut self, unicode: &UnicodeAnalysis) -> Result<(), EngineError> { + for line_break in unicode.line_breaks() { + let end = line_break.position; + if self.ends.is_empty() && end == 0 { + continue; + } + let preceding = self + .ends + .binary_search(&end) + .map_err(|_| EngineError::InvalidRequest)?; + if line_break.required { + self.flags[preceding] |= CLUSTER_REQUIRED_BREAK; + continue; + } + let safe = end == self.ends.last().copied().unwrap_or(0) + || self + .starts + .binary_search(&end) + .ok() + .is_some_and(|next| self.flags[next] & CLUSTER_SAFE_BEFORE != 0); + if safe { + self.flags[preceding] |= CLUSTER_ALLOWED_BREAK; + } + } + Ok(()) + } + + fn cluster_at(&self, offset: u32) -> Result { + let index = *self + .index_at + .get(usize::try_from(offset).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let index = usize::try_from(index).map_err(|_| EngineError::InvalidRequest)?; + if self.starts.get(index) != Some(&offset) { + return Err(EngineError::InvalidRequest); + } + Ok(index) + } +} + +fn is_hard_break(text: &[u16], start: u32) -> Result { + let unit = *text + .get(usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + Ok(matches!( + unit, + 0x0a | 0x0b | 0x0c | 0x0d | 0x85 | 0x2028 | 0x2029 + )) +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + shaping_state::{ShapedRun, ShapingRun}, + style_state::{ResolvedStyle, StyleSegment}, + }; + use alloc::vec; + + #[test] + fn aggregates_scaled_advances_spacing_and_legal_breaks() { + let text: Vec = "a b\n".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let style = ResolvedStyle::test_typography(16.0, 1.0, 2.0); + let styles = [StyleSegment { + text_start: 0, + text_end: 4, + style, + }]; + let runs = [ShapingRun { + text_start: 0, + text_end: 3, + script: u32::from_be_bytes(*b"Latn"), + direction: 4, + bidi_level: 0, + style, + }]; + let mut shape = ShapeArena { + runs: vec![ShapedRun { + source_run: 0, + font_handle: 9, + text_start: 0, + text_end: 3, + glyph_start: 0, + glyph_count: 3, + }], + glyph_ids: vec![1, 2, 3], + clusters: vec![0, 1, 2], + x_advances: vec![500, 250, 500], + y_advances: vec![0; 3], + x_offsets: vec![0; 3], + y_offsets: vec![0; 3], + glyph_flags: vec![0; 3], + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut clusters = ClusterArena::default(); + clusters + .build(&text, &unicode, &styles, &runs, &shape, metrics) + .unwrap(); + assert_eq!(clusters.starts, [0, 1, 2, 3]); + assert_eq!(clusters.ends, [1, 2, 3, 4]); + assert_eq!(clusters.advances, [9.0, 7.0, 9.0, 0.0]); + assert_eq!(clusters.style_indexes, [0; 4]); + assert_eq!(clusters.source_runs, [0, 0, 0, NO_SOURCE_RUN]); + assert_eq!(clusters.font_handles, [9, 9, 9, 0]); + assert_eq!(clusters.index_at, [0, 1, 2, 3, 4]); + assert_eq!(clusters.flags[0], CLUSTER_SAFE_BEFORE); + assert_eq!( + clusters.flags[1], + CLUSTER_SAFE_BEFORE | CLUSTER_ALLOWED_BREAK + ); + assert_eq!(clusters.flags[2], CLUSTER_SAFE_BEFORE); + assert_eq!( + clusters.flags[3], + CLUSTER_HARD_BREAK | CLUSTER_REQUIRED_BREAK + ); + + let capacities = ( + clusters.starts.capacity(), + clusters.advances.capacity(), + clusters.flags.capacity(), + clusters.index_at.capacity(), + ); + shape.glyph_flags[2] = GLYPH_UNSAFE_TO_BREAK; + clusters + .build(&text, &unicode, &styles, &runs, &shape, metrics) + .unwrap(); + assert_eq!( + capacities, + ( + clusters.starts.capacity(), + clusters.advances.capacity(), + clusters.flags.capacity(), + clusters.index_at.capacity(), + ) + ); + assert_eq!(clusters.flags[1], CLUSTER_SAFE_BEFORE); + assert_eq!(clusters.flags[2], 0); + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 4889d826..2d540aa7 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -3,6 +3,7 @@ //! The public types in this module are available to native consumers. Wasm memory ownership and //! pointer validation stay in the target-gated transport module. +mod cluster_state; pub mod font_binding; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod font_binding_wire; diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index e95ccf0c..09d6e5cb 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -7,6 +7,7 @@ use crate::{ }; use super::{ + cluster_state::ClusterArena, font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, policy::{CapabilitySetId, ValidatedPolicy}, @@ -91,6 +92,8 @@ struct EngineSession { pending_shaping_runs: ShapingRunArena, shape: ShapeArena, pending_shape: ShapeArena, + clusters: ClusterArena, + pending_clusters: ClusterArena, fallback_spans: Vec, pending_fallback_spans: Vec, fallback_span_scratch: Vec, @@ -104,6 +107,7 @@ struct EngineSession { bidi_prepared: bool, shaping_runs_prepared: bool, shape_prepared: bool, + clusters_prepared: bool, geometry_fingerprint: u64, pending_geometry_fingerprint: u64, geometry_prepared: bool, @@ -337,6 +341,8 @@ impl TextEngine { let glyph_capacity = capacity.saturating_mul(2); session.shape.reserve(glyph_capacity)?; session.pending_shape.reserve(glyph_capacity)?; + session.clusters.reserve(capacity)?; + session.pending_clusters.reserve(capacity)?; reserve_vec(&mut session.fallback_spans, capacity)?; reserve_vec(&mut session.pending_fallback_spans, capacity)?; reserve_vec(&mut session.fallback_span_scratch, capacity)?; @@ -490,15 +496,25 @@ impl TextEngine { session.abort_bidi(); return Err(error); } - if let Some(shaper) = shaper - && let Err(error) = session.prepare_shape(shaper, font_stacks) - { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - return Err(error); + if let Some(shaper) = shaper { + if let Err(error) = session.prepare_shape(shaper, font_stacks) { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); + return Err(error); + } + if let Err(error) = session.prepare_clusters(shaper) { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); + session.abort_shape(); + session.abort_clusters(); + return Err(error); + } } if let Err(error) = session.prepare_geometry(request.geometry) { session.abort_text(); @@ -507,6 +523,7 @@ impl TextEngine { session.abort_bidi(); session.abort_shaping_runs(); session.abort_shape(); + session.abort_clusters(); return Err(error); } if let Err(error) = gather.gather( @@ -530,6 +547,7 @@ impl TextEngine { session.abort_bidi(); session.abort_shaping_runs(); session.abort_shape(); + session.abort_clusters(); session.abort_geometry(); return Err(gather_error(error)); } @@ -548,6 +566,7 @@ impl TextEngine { session.abort_bidi(); session.abort_shaping_runs(); session.abort_shape(); + session.abort_clusters(); session.abort_geometry(); return Err(plan_error(error)); } @@ -599,6 +618,7 @@ impl TextEngine { session.abort_bidi(); session.abort_shaping_runs(); session.abort_shape(); + session.abort_clusters(); session.abort_geometry(); Ok(()) } @@ -621,6 +641,7 @@ impl TextEngine { session.commit_bidi(); session.commit_shaping_runs(); session.commit_shape(); + session.commit_clusters(); session.commit_geometry(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, @@ -1030,6 +1051,60 @@ impl EngineSession { self.abort_shape(); } + fn prepare_clusters(&mut self, shaper: &ShaperRegistry) -> Result<(), EngineError> { + self.abort_clusters(); + if !self.shape_prepared { + return Ok(()); + } + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let unicode = if self.unicode_prepared { + &self.pending_unicode + } else { + &self.unicode + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + if runs.is_empty() { + self.pending_clusters.clear(); + self.clusters_prepared = true; + return Ok(()); + } + self.pending_clusters.build( + text, + unicode, + styles, + runs, + &self.pending_shape, + |handle| shaper.font_metrics(handle), + )?; + self.clusters_prepared = true; + Ok(()) + } + + fn abort_clusters(&mut self) { + self.pending_clusters.clear(); + self.clusters_prepared = false; + } + + fn commit_clusters(&mut self) { + if self.clusters_prepared { + core::mem::swap(&mut self.clusters, &mut self.pending_clusters); + } + self.abort_clusters(); + } + fn prepare_geometry( &mut self, geometry: super::semantic_wire::GeometryBatch<'_>, diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs index 7d315c46..d71a1b68 100644 --- a/packages/text/rust/shaper/src/engine/style_state.rs +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -135,6 +135,18 @@ impl Default for ResolvedStyle { } } +#[cfg(test)] +impl ResolvedStyle { + pub(crate) fn test_typography(font_size: f32, letter_spacing: f32, word_spacing: f32) -> Self { + Self { + font_size, + letter_spacing, + word_spacing, + ..Self::default() + } + } +} + impl ResolvedStyleArena { pub(crate) fn reserve_default(&mut self) -> Result<(), EngineError> { self.segments diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 42f95abb..9826318e 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -73,10 +73,19 @@ struct RegisteredFont { sfnt: Vec, extents: Vec, availability: Vec, + metrics: FontMetrics, data: ShaperData, plans: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FontMetrics { + pub units_per_em: u16, + pub ascender: i16, + pub descender: i16, + pub line_gap: i16, +} + struct CachedPlan { key: PlanKey, plan: ShapePlan, @@ -221,6 +230,15 @@ impl ShaperRegistry { if !valid_extents(glyph_count, extents, availability) { return STATUS_INVALID_EXTENTS; } + let metrics = match (font.head(), font.hhea()) { + (Ok(head), Ok(hhea)) => FontMetrics { + units_per_em: head.units_per_em(), + ascender: hhea.ascender().to_i16(), + descender: hhea.descender().to_i16(), + line_gap: hhea.line_gap().to_i16(), + }, + _ => return STATUS_INVALID_FONT, + }; if let Some(existing) = self.fonts.get(&handle) { return if existing.sfnt == sfnt && existing.extents == extents @@ -238,6 +256,7 @@ impl ShaperRegistry { sfnt: sfnt.to_vec(), extents: extents.to_vec(), availability: availability.to_vec(), + metrics, data, plans: Vec::new(), }, @@ -245,6 +264,10 @@ impl ShaperRegistry { STATUS_OK } + pub(crate) fn font_metrics(&self, handle: u32) -> Option { + self.fonts.get(&handle).map(|font| font.metrics) + } + pub fn dispose_font(&mut self, handle: u32) -> u32 { self.result.clear(); if self.fonts.remove(&handle).is_some() { From b36763944e332871f63f44e957b27e31fd6e257f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 15:27:17 -0400 Subject: [PATCH 035/128] feat(text): add rust line composition kernel --- docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 6 + .../rust/shaper/src/engine/cluster_state.rs | 4 +- .../shaper/src/engine/line_composition.rs | 264 ++++++++++++++++++ packages/text/rust/shaper/src/engine/mod.rs | 2 + 6 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/line_composition.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index 71672b5f..69e056dc 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:41917a3e5f759cd02ddb43da0a38d1ea178dda666cad7a410c4e84c0bbcb5103' +source_digest: 'sha256:560c885f3d3e7034a35e967be4e608498fa38496058ef1ec618f96573b17ddba' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -673,6 +673,8 @@ The target Rust frame path now derives the same line opportunities internally. I The same frame path aggregates final fallback glyphs into a retained grapheme-cluster SoA. It keeps ordered double-precision advances, compact shaping/line-break flags, style/source/font identities, and a UTF-16 offset index. Font UPEM and horizontal line metrics are parsed once at registration; glyph design-unit advances are scaled once per contributing final font, and authored letter/word spacing joins that accumulation without constructing per-cluster objects. Optional Unicode line opportunities survive only where the next cluster is safe according to HarfRust output. +An allocation-free Rust line kernel advances that retained grapheme cursor for a supplied width while preserving word, character, no-wrap, required-break, over-wide-cluster, and trailing-hard-break behavior. It remains an internal proof until declarative region bands and exclusions drive it during a production frame update. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 7077b8f3..763c9476 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -253,6 +253,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-186 | Ordered fallback is resolved inside the same Rust frame transaction from actual HarfRust `.notdef` output, never from `cmap`, raster coverage, or a host callback. Reusable flat spans name source run, UTF-16 range, stack index, and concrete font; reusable cluster records collapse multi-glyph clusters with missing status ORed across glyph zero. Records sort by source run/logical cluster to normalize RTL output, then one linear merge advances only missing ranges. Font index increases monotonically, bounding passes by stack depth. Final spans and shaped SoA commit together and abort together. A compiled Inter→Noto Devanagari `text_update` constructs exactly two HarfRust plans, causally proving primary and fallback shaping. Host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 973,367 / 364,517 / 287,942 to 982,356 / 368,183 / 290,439 raw/gzip/Brotli bytes. Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. | Accepted | | D-187 | Unicode 17 line-break opportunities are retained Rust Unicode state, not host input. The pinned `@cto.af/linebreak` 4.0.3 property trie is generated into a compact scalar partition containing resolved line-break class and only the punctuation, East Asian, and unassigned-extended-pictographic flags its ordered UAX #14 rules consume. A specialized `no_std` Rust evaluator reuses flat scalar/break arrays, returns UTF-16 offsets, retains the upstream MIT notice, and commits/aborts with grapheme/script analysis. All 19,338 unchanged official `LineBreakTest` cases pass, plus focused required-break tests; host/SIMD Clippy passes. Optimized Wasm changes from 982,356 / 368,183 / 290,439 to 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli bytes. Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. | Accepted | | D-188 | Measured clusters are retained A/B Rust SoA state derived from final fallback-shaped glyphs and Unicode analysis: grapheme UTF-16 starts/ends, ordered `f64` advances, compact safe/allowed/required/hard-break flags, resolved-style index, source-run/font identity, and one offset-to-cluster index. Glyph positions remain `i32` design units through shaping and are scaled once with cached per-font UPEM; letter spacing, word spacing, and zero-width hard breaks join that ordered accumulation. Optional UAX #14 breaks are admitted only when the next grapheme is a HarfRust-safe boundary. A synthetic kernel proof fixes exact `[9, 7, 9, 0]` advances for design widths plus letter/word spacing and proves unsafe suppression; compiled real-font primary/fallback updates reach the pass. Optimized Wasm changes from 1,009,460 / 377,053 / 295,875 to 1,014,577 / 379,510 / 295,708 raw/gzip/Brotli bytes. The Brotli decrease is compressor interaction, not a speed claim. Composition, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-189 | The allocation-free Rust `layout_next_line` kernel consumes retained clusters through a grapheme cursor and one caller-supplied `f64` width. Word mode prefers safe UAX #14 opportunities and falls back to HarfRust-safe boundaries; character mode accepts every safe grapheme boundary; no-wrap ignores width but preserves required breaks; over-wide first clusters always make progress; and a terminal hard break emits the canonical trailing empty line. Focused tests prove these modes and cursor termination. The kernel is not yet connected to region-band resolution or frame output, so this checkpoint makes no end-to-end timing or production Wasm-size claim. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 7d2a3f9f..c6abf95b 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -909,6 +909,12 @@ capacity reuse; real-font compiled Wasm reaches the pass for primary and fallbac 379,510 / 295,708 raw/gzip/Brotli bytes (+5,117 / +2,457 / -167). The Brotli change is recorded as compressor interaction, not a performance claim. Line composition, nonempty plan output, and complete-path timing remain open. +The first line-composition kernel now advances a grapheme cursor for one caller-supplied width without allocating. It +matches the retained TypeScript break order: word wrapping prefers safe UAX #14 opportunities, then safe HarfRust +boundaries; character wrapping admits every safe grapheme boundary; no-wrap still honors required breaks; and a final +hard break produces the trailing empty line. Width accumulation remains `f64`. This is a kernel proof only: region-band +resolution has not yet connected it to production frame output, so it carries no end-to-end timing or Wasm-size claim. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index e10d1d34..563882dc 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -26,8 +26,8 @@ pub(crate) struct ClusterArena { pub source_runs: Vec, pub font_handles: Vec, pub index_at: Vec, - shaped: Vec, - unsafe_before: Vec, + pub(super) shaped: Vec, + pub(super) unsafe_before: Vec, } impl ClusterArena { diff --git a/packages/text/rust/shaper/src/engine/line_composition.rs b/packages/text/rust/shaper/src/engine/line_composition.rs new file mode 100644 index 00000000..4186f054 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/line_composition.rs @@ -0,0 +1,264 @@ +use super::{ + EngineError, + cluster_state::{ + CLUSTER_ALLOWED_BREAK, CLUSTER_HARD_BREAK, CLUSTER_REQUIRED_BREAK, CLUSTER_SAFE_BEFORE, + ClusterArena, + }, + frame::{WRAP_CHARACTER, WRAP_NONE, WRAP_WORD}, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct LineCursor { + cluster: usize, + trailing_empty: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct ComposedLine { + pub cluster_start: u32, + pub cluster_end: u32, + pub text_start: u32, + pub text_end: u32, + pub advance: f64, + pub hard_break: bool, +} + +pub(crate) fn layout_next_line( + clusters: &ClusterArena, + cursor: &mut LineCursor, + max_width: f64, + wrap: u8, +) -> Result, EngineError> { + if max_width.is_nan() + || max_width < 0.0 + || !matches!(wrap, WRAP_NONE | WRAP_WORD | WRAP_CHARACTER) + { + return Err(EngineError::InvalidRequest); + } + let count = clusters.starts.len(); + if cursor.cluster > count { + return Err(EngineError::InvalidRequest); + } + if cursor.trailing_empty { + cursor.trailing_empty = false; + cursor.cluster = count; + let text_end = clusters.ends.last().copied().unwrap_or(0); + let count = u32::try_from(count).map_err(|_| EngineError::ResultTooLarge)?; + return Ok(Some(ComposedLine { + cluster_start: count, + cluster_end: count, + text_start: text_end, + text_end, + advance: 0.0, + hard_break: false, + })); + } + if cursor.cluster == count { + return Ok(None); + } + + let line_start = cursor.cluster; + let mut advance = 0.0; + let mut last_allowed = None; + let mut last_allowed_advance = 0.0; + let mut last_safe = None; + let mut last_safe_advance = 0.0; + let mut selected_end = count; + let mut selected_advance = 0.0; + + for index in line_start..count { + let flags = clusters.flags[index]; + if index > line_start && flags & CLUSTER_SAFE_BEFORE != 0 { + last_safe = Some(index); + last_safe_advance = advance; + } + let required_break = flags & CLUSTER_REQUIRED_BREAK != 0; + let next_advance = advance + clusters.advances[index]; + if wrap != WRAP_NONE + && max_width.is_finite() + && next_advance > max_width + && index > line_start + { + if let Some(end) = last_allowed.filter(|end| *end > line_start) { + selected_end = end; + selected_advance = last_allowed_advance; + } else if let Some(end) = last_safe.filter(|end| *end > line_start) { + selected_end = end; + selected_advance = last_safe_advance; + } else { + advance = next_advance; + if required_break || index + 1 == count { + selected_end = index + 1; + selected_advance = advance; + break; + } + continue; + } + break; + } + advance = next_advance; + if required_break { + selected_end = index + 1; + selected_advance = advance; + break; + } + let allowed = match wrap { + WRAP_WORD => flags & CLUSTER_ALLOWED_BREAK != 0, + WRAP_CHARACTER => { + index + 1 == count || clusters.flags[index + 1] & CLUSTER_SAFE_BEFORE != 0 + } + WRAP_NONE => false, + _ => unreachable!(), + }; + if allowed { + last_allowed = Some(index + 1); + last_allowed_advance = advance; + } + if index + 1 == count { + selected_advance = advance; + } + } + + if selected_end <= line_start { + selected_end = line_start + 1; + selected_advance = clusters.advances[line_start]; + } + let last = selected_end - 1; + let hard_break = clusters.flags[last] & CLUSTER_HARD_BREAK != 0; + let text_start = clusters.starts[line_start]; + let text_end = if hard_break { + clusters.starts[last] + } else { + clusters.ends[last] + }; + cursor.cluster = selected_end; + cursor.trailing_empty = selected_end == count && hard_break; + Ok(Some(ComposedLine { + cluster_start: u32::try_from(line_start).map_err(|_| EngineError::ResultTooLarge)?, + cluster_end: u32::try_from(selected_end).map_err(|_| EngineError::ResultTooLarge)?, + text_start, + text_end, + advance: selected_advance, + hard_break, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + fn make_clusters(advances: &[f64], flags: &[u8]) -> ClusterArena { + let count = advances.len(); + ClusterArena { + starts: (0..count as u32).collect(), + ends: (1..=count as u32).collect(), + advances: advances.to_vec(), + flags: flags.to_vec(), + style_indexes: vec![0; count], + source_runs: vec![0; count], + font_handles: vec![1; count], + index_at: (0..=count as u32).collect(), + ..ClusterArena::default() + } + } + + #[test] + fn composes_word_character_and_unwrapped_lines_without_allocating() { + let clusters = make_clusters( + &[4.0, 4.0, 4.0, 4.0], + &[ + CLUSTER_SAFE_BEFORE, + CLUSTER_SAFE_BEFORE | CLUSTER_ALLOWED_BREAK, + CLUSTER_SAFE_BEFORE, + CLUSTER_SAFE_BEFORE, + ], + ); + let mut cursor = LineCursor::default(); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD).unwrap(), + Some(ComposedLine { + cluster_start: 0, + cluster_end: 2, + text_start: 0, + text_end: 2, + advance: 8.0, + hard_break: false, + }) + ); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD) + .unwrap() + .unwrap() + .cluster_end, + 4 + ); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 10.0, WRAP_WORD).unwrap(), + None + ); + + let mut character = LineCursor::default(); + assert_eq!( + layout_next_line(&clusters, &mut character, 5.0, WRAP_CHARACTER) + .unwrap() + .unwrap() + .cluster_end, + 1 + ); + let mut unwrapped = LineCursor::default(); + assert_eq!( + layout_next_line(&clusters, &mut unwrapped, 1.0, WRAP_NONE) + .unwrap() + .unwrap() + .advance, + 16.0 + ); + + let unsafe_boundary = make_clusters( + &[4.0, 4.0, 4.0], + &[CLUSTER_SAFE_BEFORE, 0, CLUSTER_SAFE_BEFORE], + ); + let mut unsafe_cursor = LineCursor::default(); + let line = layout_next_line(&unsafe_boundary, &mut unsafe_cursor, 5.0, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!((line.cluster_end, line.advance), (2, 8.0)); + + let oversized = make_clusters(&[7.0, 3.0], &[CLUSTER_SAFE_BEFORE, CLUSTER_SAFE_BEFORE]); + let mut oversized_cursor = LineCursor::default(); + let line = layout_next_line(&oversized, &mut oversized_cursor, 5.0, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!((line.cluster_end, line.advance), (1, 7.0)); + } + + #[test] + fn required_break_and_trailing_empty_line_match_paragraph_semantics() { + let clusters = make_clusters( + &[3.0, 0.0], + &[ + CLUSTER_SAFE_BEFORE, + CLUSTER_SAFE_BEFORE | CLUSTER_REQUIRED_BREAK | CLUSTER_HARD_BREAK, + ], + ); + let mut cursor = LineCursor::default(); + let first = layout_next_line(&clusters, &mut cursor, f64::INFINITY, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!( + (first.text_start, first.text_end, first.advance), + (0, 1, 3.0) + ); + assert!(first.hard_break); + let trailing = layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD) + .unwrap() + .unwrap(); + assert_eq!((trailing.cluster_start, trailing.cluster_end), (2, 2)); + assert_eq!((trailing.text_start, trailing.text_end), (2, 2)); + assert_eq!( + layout_next_line(&clusters, &mut cursor, 0.0, WRAP_WORD).unwrap(), + None + ); + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 2d540aa7..58621a3a 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -14,6 +14,8 @@ pub(crate) mod frame_wire; #[cfg(feature = "kernel-lab")] #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod kernel_lab; +#[cfg_attr(not(test), allow(dead_code))] +mod line_composition; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] mod state; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] From 4be6770c79fdaa7e2507084a5769f6c8ccec4f9e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 15:37:32 -0400 Subject: [PATCH 036/128] feat(text): retain rust flow geometry --- docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 8 + .../rust/shaper/src/engine/flow_geometry.rs | 271 ++++++++++++++++++ packages/text/rust/shaper/src/engine/mod.rs | 2 + .../rust/shaper/src/engine/semantic_wire.rs | 193 +++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 6 + 7 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 packages/text/rust/shaper/src/engine/flow_geometry.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index 69e056dc..c3e05c44 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:560c885f3d3e7034a35e967be4e608498fa38496058ef1ec618f96573b17ddba' +source_digest: 'sha256:ec94ea3821e3a72a7cf944b536921d90436bba3aeb849f5f914d9f661cbe9db3' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -675,6 +675,8 @@ The same frame path aggregates final fallback glyphs into a retained grapheme-cl An allocation-free Rust line kernel advances that retained grapheme cursor for a supplied width while preserving word, character, no-wrap, required-break, over-wide-cluster, and trailing-hard-break behavior. It remains an internal proof until declarative region bands and exclusions drive it during a production frame update. +Validated flow snapshots are now copied into transactional A/B Rust storage: constraints, ordered regions, exclusions, and rebased polygon vertices remain owned after request memory is reused. The rectangle band kernel subtracts intersecting exclusions into retained, bounded inline-slot scratch and fails explicitly if the declared per-band slot envelope is exceeded. Polygon intersection and line placement are not yet connected. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 763c9476..84e30b7f 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -254,6 +254,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-187 | Unicode 17 line-break opportunities are retained Rust Unicode state, not host input. The pinned `@cto.af/linebreak` 4.0.3 property trie is generated into a compact scalar partition containing resolved line-break class and only the punctuation, East Asian, and unassigned-extended-pictographic flags its ordered UAX #14 rules consume. A specialized `no_std` Rust evaluator reuses flat scalar/break arrays, returns UTF-16 offsets, retains the upstream MIT notice, and commits/aborts with grapheme/script analysis. All 19,338 unchanged official `LineBreakTest` cases pass, plus focused required-break tests; host/SIMD Clippy passes. Optimized Wasm changes from 982,356 / 368,183 / 290,439 to 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli bytes. Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. | Accepted | | D-188 | Measured clusters are retained A/B Rust SoA state derived from final fallback-shaped glyphs and Unicode analysis: grapheme UTF-16 starts/ends, ordered `f64` advances, compact safe/allowed/required/hard-break flags, resolved-style index, source-run/font identity, and one offset-to-cluster index. Glyph positions remain `i32` design units through shaping and are scaled once with cached per-font UPEM; letter spacing, word spacing, and zero-width hard breaks join that ordered accumulation. Optional UAX #14 breaks are admitted only when the next grapheme is a HarfRust-safe boundary. A synthetic kernel proof fixes exact `[9, 7, 9, 0]` advances for design widths plus letter/word spacing and proves unsafe suppression; compiled real-font primary/fallback updates reach the pass. Optimized Wasm changes from 1,009,460 / 377,053 / 295,875 to 1,014,577 / 379,510 / 295,708 raw/gzip/Brotli bytes. The Brotli decrease is compressor interaction, not a speed claim. Composition, nonempty plan output, and complete-path timing remain open. | Accepted | | D-189 | The allocation-free Rust `layout_next_line` kernel consumes retained clusters through a grapheme cursor and one caller-supplied `f64` width. Word mode prefers safe UAX #14 opportunities and falls back to HarfRust-safe boundaries; character mode accepts every safe grapheme boundary; no-wrap ignores width but preserves required breaks; over-wide first clusters always make progress; and a terminal hard break emits the canonical trailing empty line. Focused tests prove these modes and cursor termination. The kernel is not yet connected to region-band resolution or frame output, so this checkpoint makes no end-to-end timing or production Wasm-size claim. | Accepted | +| D-190 | Validated flow snapshots become retained A/B Rust state during the production update transaction. Constraints, ordered regions, exclusions, and polygon vertices are copied into reusable session arrays; vertex offsets are rebased, so no request pointer survives `text_update`. The rectangle fast path subtracts intersecting exclusions through two reusable sorted slot vectors, applies each declared wrap side, and enforces `max_slots_per_band`. An exact fixture maps region `0..100` minus exclusion `20..40` to slots `[0..20, 40..100]`; a polygon fixture proves three vertices survive as owned values. Optimized Wasm changes from 1,014,577 / 379,510 / 295,708 to 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes. Polygon band intersection, line-cursor connection, nonempty plan output, and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index c6abf95b..6d5ae28b 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -915,6 +915,14 @@ boundaries; character wrapping admits every safe grapheme boundary; no-wrap stil hard break produces the trailing empty line. Width accumulation remains `f64`. This is a kernel proof only: region-band resolution has not yet connected it to production frame output, so it carries no end-to-end timing or Wasm-size claim. +Declarative flow input now crosses from validated compiler-mapped request bytes into retained A/B Rust state in the +production transaction. Constraints, ordered regions, exclusions, and rebased polygon vertices are owned by the session; +no request pointer survives the call. The rectangle band fast path reuses two bounded slot vectors and subtracts +intersecting exclusion rectangles according to their wrap side, rejecting output beyond `max_slots_per_band`. An exact +fixture resolves a `0..100` region around a `20..40` exclusion to `[0..20, 40..100]`. Polygon intersection and driving +the line cursor remain open. Optimized Wasm is 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes (+2,143 / +5,083 / ++1,341 from the last production Wasm checkpoint); compressed deltas are recorded as transport evidence only. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/flow_geometry.rs b/packages/text/rust/shaper/src/engine/flow_geometry.rs new file mode 100644 index 00000000..4c53e712 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/flow_geometry.rs @@ -0,0 +1,271 @@ +use alloc::vec::Vec; + +use super::{ + EngineError, + frame::{ + EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, + EXCLUSION_WRAP_LARGEST, SHAPE_RECTANGLE, + }, + semantic_wire::{FlowConstraint, FlowExclusion, FlowRegion, FlowVertex, GeometryBatch}, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct InlineSlot { + pub start: f64, + pub end: f64, +} + +#[derive(Default)] +pub(crate) struct InlineSlotArena { + slots: Vec, + scratch: Vec, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RetainedRegion { + pub record: FlowRegion, + pub vertex_start: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct RetainedExclusion { + pub record: FlowExclusion, + pub vertex_start: u32, +} + +#[derive(Default)] +pub(crate) struct FlowGeometryArena { + pub constraints: Vec, + pub regions: Vec, + pub exclusions: Vec, + pub vertices: Vec, +} + +impl FlowGeometryArena { + pub(crate) fn build(&mut self, geometry: GeometryBatch<'_>) -> Result<(), EngineError> { + self.clear(); + reserve(&mut self.constraints, geometry.constraint_count())?; + for index in 0..geometry.constraint_count() { + self.constraints.push( + geometry + .constraint(index) + .ok_or(EngineError::InvalidRequest)?, + ); + } + let region_count = self + .constraints + .iter() + .map(|constraint| { + usize::try_from(constraint.region_start) + .ok() + .and_then(|start| start.checked_add(usize::from(constraint.region_count))) + }) + .try_fold(0usize, |maximum, end| { + end.map(|end| maximum.max(end)) + .ok_or(EngineError::InvalidRequest) + })?; + reserve(&mut self.regions, region_count)?; + for index in 0..region_count { + let record = geometry.region(index).ok_or(EngineError::InvalidRequest)?; + let vertex_start = append_vertices( + &mut self.vertices, + geometry, + record.vertices_offset, + record.vertex_count, + )?; + self.regions.push(RetainedRegion { + record, + vertex_start, + }); + } + let exclusion_count = self + .regions + .iter() + .map(|region| { + usize::from(region.record.exclusion_start) + .checked_add(usize::from(region.record.exclusion_count)) + }) + .try_fold(0usize, |maximum, end| { + end.map(|end| maximum.max(end)) + .ok_or(EngineError::InvalidRequest) + })?; + reserve(&mut self.exclusions, exclusion_count)?; + for index in 0..exclusion_count { + let record = geometry + .exclusion(index) + .ok_or(EngineError::InvalidRequest)?; + let vertex_start = append_vertices( + &mut self.vertices, + geometry, + record.vertices_offset, + record.vertex_count, + )?; + self.exclusions.push(RetainedExclusion { + record, + vertex_start, + }); + } + Ok(()) + } + + pub(crate) fn clear(&mut self) { + self.constraints.clear(); + self.regions.clear(); + self.exclusions.clear(); + self.vertices.clear(); + } +} + +impl InlineSlotArena { + pub(crate) fn resolve_rectangle_band<'a>( + &'a mut self, + geometry: &FlowGeometryArena, + region_index: usize, + block_start: f64, + block_end: f64, + max_slots: usize, + ) -> Result<&'a [InlineSlot], EngineError> { + if !block_start.is_finite() || !block_end.is_finite() || block_start >= block_end { + return Err(EngineError::InvalidRequest); + } + let region = geometry + .regions + .get(region_index) + .ok_or(EngineError::InvalidRequest)?; + if region.record.shape != SHAPE_RECTANGLE || max_slots == 0 { + return Err(EngineError::InvalidRequest); + } + self.slots.clear(); + self.scratch.clear(); + reserve(&mut self.slots, max_slots)?; + reserve(&mut self.scratch, max_slots)?; + self.slots.push(InlineSlot { + start: f64::from(region.record.inline_start), + end: f64::from(region.record.inline_end), + }); + let exclusion_start = usize::from(region.record.exclusion_start); + let exclusion_end = exclusion_start + .checked_add(usize::from(region.record.exclusion_count)) + .ok_or(EngineError::InvalidRequest)?; + for exclusion in geometry + .exclusions + .get(exclusion_start..exclusion_end) + .ok_or(EngineError::InvalidRequest)? + { + let record = exclusion.record; + if record.shape != SHAPE_RECTANGLE { + return Err(EngineError::InvalidRequest); + } + let margin_block = f64::from(record.margin_block); + if f64::from(record.block_start) - margin_block >= block_end + || f64::from(record.block_end) + margin_block <= block_start + { + continue; + } + let margin_inline = f64::from(record.margin_inline); + let cut_start = f64::from(record.inline_start) - margin_inline; + let cut_end = f64::from(record.inline_end) + margin_inline; + self.scratch.clear(); + for slot in self.slots.iter().copied() { + subtract_slot( + &mut self.scratch, + slot, + cut_start, + cut_end, + record.wrap_side, + max_slots, + )?; + } + core::mem::swap(&mut self.slots, &mut self.scratch); + } + Ok(&self.slots) + } +} + +fn subtract_slot( + destination: &mut Vec, + slot: InlineSlot, + cut_start: f64, + cut_end: f64, + wrap_side: u8, + max_slots: usize, +) -> Result<(), EngineError> { + if cut_end <= slot.start || cut_start >= slot.end { + return push_slot(destination, slot, max_slots); + } + let before = InlineSlot { + start: slot.start, + end: cut_start.min(slot.end), + }; + let after = InlineSlot { + start: cut_end.max(slot.start), + end: slot.end, + }; + match wrap_side { + EXCLUSION_WRAP_BOTH => { + push_nonempty(destination, before, max_slots)?; + push_nonempty(destination, after, max_slots) + } + EXCLUSION_WRAP_INLINE_START => push_nonempty(destination, before, max_slots), + EXCLUSION_WRAP_INLINE_END => push_nonempty(destination, after, max_slots), + EXCLUSION_WRAP_LARGEST => { + let selected = if before.end - before.start >= after.end - after.start { + before + } else { + after + }; + push_nonempty(destination, selected, max_slots) + } + _ => Err(EngineError::InvalidRequest), + } +} + +fn push_nonempty( + destination: &mut Vec, + slot: InlineSlot, + max_slots: usize, +) -> Result<(), EngineError> { + if slot.start < slot.end { + push_slot(destination, slot, max_slots)?; + } + Ok(()) +} + +fn push_slot( + destination: &mut Vec, + slot: InlineSlot, + max_slots: usize, +) -> Result<(), EngineError> { + if destination.len() >= max_slots { + return Err(EngineError::ResultTooLarge); + } + destination.push(slot); + Ok(()) +} + +fn append_vertices( + destination: &mut Vec, + geometry: GeometryBatch<'_>, + offset: u32, + count: u16, +) -> Result { + let start = u32::try_from(destination.len()).map_err(|_| EngineError::ResultTooLarge)?; + reserve(destination, usize::from(count))?; + for index in 0..usize::from(count) { + destination.push( + geometry + .vertex(offset, index) + .ok_or(EngineError::InvalidRequest)?, + ); + } + Ok(start) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), EngineError> { + if values.capacity().saturating_sub(values.len()) < additional { + values + .try_reserve_exact(additional) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 58621a3a..dc9e0c8e 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -4,6 +4,8 @@ //! pointer validation stay in the target-gated transport module. mod cluster_state; +#[cfg_attr(not(test), allow(dead_code))] +mod flow_geometry; pub mod font_binding; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod font_binding_wire; diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index ec5da6bc..8ac87052 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -94,6 +94,71 @@ pub(crate) struct GeometryBatch<'a> { inline_objects: &'a [u8], } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowConstraint { + pub flow_thread_id: u32, + pub width: f32, + pub height: f32, + pub viewport_block_start: f32, + pub viewport_block_end: f32, + pub resume_block_offset: f32, + pub max_lines: u32, + pub region_start: u32, + pub resume_cluster: u32, + pub region_count: u16, + pub resume_region: u16, + pub width_mode: u8, + pub height_mode: u8, + pub wrap: u8, + pub align: u8, + pub overflow: u8, + pub block_align: u8, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowRegion { + pub id: u32, + pub geometry_revision: u32, + pub vertices_offset: u32, + pub vertex_count: u16, + pub exclusion_start: u16, + pub exclusion_count: u16, + pub shape: u8, + pub writing_mode: u8, + pub text_orientation: u8, + pub inline_start: f32, + pub block_start: f32, + pub inline_end: f32, + pub block_end: f32, + pub clip_inline_start: f32, + pub clip_block_start: f32, + pub clip_inline_end: f32, + pub clip_block_end: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowExclusion { + pub id: u32, + pub region_id: u32, + pub geometry_revision: u32, + pub vertices_offset: u32, + pub vertex_count: u16, + pub shape: u8, + pub wrap_side: u8, + pub inline_start: f32, + pub block_start: f32, + pub inline_end: f32, + pub block_end: f32, + pub margin_inline: f32, + pub margin_block: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowVertex { + pub inline: f32, + pub block: f32, +} + impl GeometryBatch<'_> { pub(crate) const fn empty() -> Self { Self { @@ -129,6 +194,91 @@ impl GeometryBatch<'_> { Ok(()) } + pub(crate) fn constraint_count(self) -> usize { + self.constraints.len() / abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize + } + + pub(crate) fn constraint(self, index: usize) -> Option { + let record = record_at(self.constraints, abi::ENGINE_CONSTRAINT_RECORD_SIZE, index)?; + Some(FlowConstraint { + flow_thread_id: read_u32(record, abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID).ok()?, + width: read_f32(record, abi::ENGINE_CONSTRAINT_WIDTH).ok()?, + height: read_f32(record, abi::ENGINE_CONSTRAINT_HEIGHT).ok()?, + viewport_block_start: read_f32(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_START) + .ok()?, + viewport_block_end: read_f32(record, abi::ENGINE_CONSTRAINT_VIEWPORT_BLOCK_END).ok()?, + resume_block_offset: read_f32(record, abi::ENGINE_CONSTRAINT_RESUME_BLOCK_OFFSET) + .ok()?, + max_lines: read_u32(record, abi::ENGINE_CONSTRAINT_MAX_LINES).ok()?, + region_start: read_u32(record, abi::ENGINE_CONSTRAINT_REGION_START).ok()?, + resume_cluster: read_u32(record, abi::ENGINE_CONSTRAINT_RESUME_CLUSTER).ok()?, + region_count: read_u16(record, abi::ENGINE_CONSTRAINT_REGION_COUNT).ok()?, + resume_region: read_u16(record, abi::ENGINE_CONSTRAINT_RESUME_REGION).ok()?, + width_mode: record[abi::ENGINE_CONSTRAINT_WIDTH_MODE], + height_mode: record[abi::ENGINE_CONSTRAINT_HEIGHT_MODE], + wrap: record[abi::ENGINE_CONSTRAINT_WRAP], + align: record[abi::ENGINE_CONSTRAINT_ALIGN], + overflow: record[abi::ENGINE_CONSTRAINT_OVERFLOW], + block_align: record[abi::ENGINE_CONSTRAINT_BLOCK_ALIGN], + }) + } + + pub(crate) fn region(self, index: usize) -> Option { + let record = record_at(self.regions, abi::ENGINE_REGION_RECORD_SIZE, index)?; + Some(FlowRegion { + id: read_u32(record, abi::ENGINE_REGION_ID).ok()?, + geometry_revision: read_u32(record, abi::ENGINE_REGION_GEOMETRY_REVISION).ok()?, + vertices_offset: read_u32(record, abi::ENGINE_REGION_VERTICES_OFFSET).ok()?, + vertex_count: read_u16(record, abi::ENGINE_REGION_VERTEX_COUNT).ok()?, + exclusion_start: read_u16(record, abi::ENGINE_REGION_EXCLUSION_START).ok()?, + exclusion_count: read_u16(record, abi::ENGINE_REGION_EXCLUSION_COUNT).ok()?, + shape: record[abi::ENGINE_REGION_SHAPE], + writing_mode: record[abi::ENGINE_REGION_WRITING_MODE], + text_orientation: record[abi::ENGINE_REGION_TEXT_ORIENTATION], + inline_start: read_f32(record, abi::ENGINE_REGION_INLINE_START).ok()?, + block_start: read_f32(record, abi::ENGINE_REGION_BLOCK_START).ok()?, + inline_end: read_f32(record, abi::ENGINE_REGION_INLINE_END).ok()?, + block_end: read_f32(record, abi::ENGINE_REGION_BLOCK_END).ok()?, + clip_inline_start: read_f32(record, abi::ENGINE_REGION_CLIP_INLINE_START).ok()?, + clip_block_start: read_f32(record, abi::ENGINE_REGION_CLIP_BLOCK_START).ok()?, + clip_inline_end: read_f32(record, abi::ENGINE_REGION_CLIP_INLINE_END).ok()?, + clip_block_end: read_f32(record, abi::ENGINE_REGION_CLIP_BLOCK_END).ok()?, + }) + } + + pub(crate) fn exclusion(self, index: usize) -> Option { + let record = record_at(self.exclusions, abi::ENGINE_EXCLUSION_RECORD_SIZE, index)?; + Some(FlowExclusion { + id: read_u32(record, abi::ENGINE_EXCLUSION_ID).ok()?, + region_id: read_u32(record, abi::ENGINE_EXCLUSION_REGION_ID).ok()?, + geometry_revision: read_u32(record, abi::ENGINE_EXCLUSION_GEOMETRY_REVISION).ok()?, + vertices_offset: read_u32(record, abi::ENGINE_EXCLUSION_VERTICES_OFFSET).ok()?, + vertex_count: read_u16(record, abi::ENGINE_EXCLUSION_VERTEX_COUNT).ok()?, + shape: record[abi::ENGINE_EXCLUSION_SHAPE], + wrap_side: record[abi::ENGINE_EXCLUSION_WRAP_SIDE], + inline_start: read_f32(record, abi::ENGINE_EXCLUSION_INLINE_START).ok()?, + block_start: read_f32(record, abi::ENGINE_EXCLUSION_BLOCK_START).ok()?, + inline_end: read_f32(record, abi::ENGINE_EXCLUSION_INLINE_END).ok()?, + block_end: read_f32(record, abi::ENGINE_EXCLUSION_BLOCK_END).ok()?, + margin_inline: read_f32(record, abi::ENGINE_EXCLUSION_MARGIN_INLINE).ok()?, + margin_block: read_f32(record, abi::ENGINE_EXCLUSION_MARGIN_BLOCK).ok()?, + }) + } + + pub(crate) fn vertex(self, offset: u32, index: usize) -> Option { + let stride = usize::try_from(abi::ENGINE_FLOW_VERTEX_RECORD_SIZE).ok()?; + let byte_offset = usize::try_from(offset) + .ok()? + .checked_add(index.checked_mul(stride)?)?; + let record = self + .request + .get(byte_offset..byte_offset.checked_add(stride)?)?; + Some(FlowVertex { + inline: read_f32(record, abi::ENGINE_FLOW_VERTEX_INLINE).ok()?, + block: read_f32(record, abi::ENGINE_FLOW_VERTEX_BLOCK).ok()?, + }) + } + pub(crate) fn fingerprint(self) -> u64 { let mut hash = 0xcbf2_9ce4_8422_2325_u64; for section in [self.constraints, self.inline_objects] { @@ -873,6 +1023,12 @@ fn record_table( array(request, offset, count, stride, alignment) } +fn record_at(records: &[u8], stride: u32, index: usize) -> Option<&[u8]> { + let stride = usize::try_from(stride).ok()?; + let start = index.checked_mul(stride)?; + records.get(start..start.checked_add(stride)?) +} + fn validate_constraints( constraints: &[u8], region_count: u32, @@ -1370,6 +1526,7 @@ fn mix_vertex_payload( #[cfg(test)] mod tests { use super::*; + use crate::engine::flow_geometry::{FlowGeometryArena, InlineSlot, InlineSlotArena}; use crate::{ abi_contract::{ ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, @@ -1554,6 +1711,10 @@ mod tests { let geometry = parse_valid_geometry(&bytes).unwrap(); geometry.validate_text_length(0).unwrap(); assert_ne!(geometry.fingerprint(), 0); + assert_eq!(geometry.constraint_count(), 1); + assert_eq!(geometry.constraint(0).unwrap().flow_thread_id, 1); + assert_eq!(geometry.region(0).unwrap().inline_end, 100.0); + assert_eq!(geometry.exclusion(0).unwrap().region_id, 1); let mut polygon = bytes.clone(); polygon.resize( @@ -1585,6 +1746,38 @@ mod tests { } let polygon_geometry = parse_valid_geometry(&polygon).unwrap(); assert_ne!(polygon_geometry.fingerprint(), geometry.fingerprint()); + let mut retained = FlowGeometryArena::default(); + retained.build(polygon_geometry).unwrap(); + assert_eq!(retained.constraints.len(), 1); + assert_eq!(retained.regions.len(), 1); + assert_eq!(retained.exclusions.len(), 1); + assert_eq!(retained.vertices.len(), 3); + assert_eq!(retained.regions[0].vertex_start, 0); + assert_eq!( + retained.vertices[2], + FlowVertex { + inline: 0.0, + block: 100.0 + } + ); + let mut rectangle = FlowGeometryArena::default(); + rectangle.build(geometry).unwrap(); + let mut slots = InlineSlotArena::default(); + assert_eq!( + slots + .resolve_rectangle_band(&rectangle, 0, 20.0, 30.0, 4) + .unwrap(), + [ + InlineSlot { + start: 0.0, + end: 20.0, + }, + InlineSlot { + start: 40.0, + end: 100.0, + }, + ] + ); let mut relocated_polygon = polygon[..GEOMETRY_LENGTH].to_vec(); relocated_polygon.resize(GEOMETRY_LENGTH + 8, 0); relocated_polygon.extend_from_slice(&polygon[GEOMETRY_LENGTH..]); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 09d6e5cb..3d6c9936 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -8,6 +8,7 @@ use crate::{ use super::{ cluster_state::ClusterArena, + flow_geometry::FlowGeometryArena, font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, policy::{CapabilitySetId, ValidatedPolicy}, @@ -94,6 +95,8 @@ struct EngineSession { pending_shape: ShapeArena, clusters: ClusterArena, pending_clusters: ClusterArena, + geometry: FlowGeometryArena, + pending_geometry: FlowGeometryArena, fallback_spans: Vec, pending_fallback_spans: Vec, fallback_span_scratch: Vec, @@ -1118,12 +1121,14 @@ impl EngineSession { geometry .validate_text_length(text_length) .map_err(|_| EngineError::InvalidRequest)?; + self.pending_geometry.build(geometry)?; self.pending_geometry_fingerprint = geometry.fingerprint(); self.geometry_prepared = true; Ok(()) } fn abort_geometry(&mut self) { + self.pending_geometry.clear(); self.pending_geometry_fingerprint = 0; self.geometry_prepared = false; } @@ -1131,6 +1136,7 @@ impl EngineSession { fn commit_geometry(&mut self) { if self.geometry_prepared { self.geometry_fingerprint = self.pending_geometry_fingerprint; + core::mem::swap(&mut self.geometry, &mut self.pending_geometry); } self.abort_geometry(); } From 88b041e74d230e48eac2a4f07ce3567d17404025 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 15:46:52 -0400 Subject: [PATCH 037/128] feat(text): resolve rust polygon bands --- docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 8 + .../rust/shaper/src/engine/flow_geometry.rs | 416 +++++++++++++++++- .../rust/shaper/src/engine/semantic_wire.rs | 11 + 5 files changed, 420 insertions(+), 20 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index c3e05c44..3b70768d 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:ec94ea3821e3a72a7cf944b536921d90436bba3aeb849f5f914d9f661cbe9db3' +source_digest: 'sha256:3c2123625efa37228ccddc3853ba2f1c449b8aa90e9ff73f6e541085b519ec8b' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -677,6 +677,8 @@ An allocation-free Rust line kernel advances that retained grapheme cursor for a Validated flow snapshots are now copied into transactional A/B Rust storage: constraints, ordered regions, exclusions, and rebased polygon vertices remain owned after request memory is reused. The rectangle band kernel subtracts intersecting exclusions into retained, bounded inline-slot scratch and fails explicitly if the declared per-band slot envelope is exceeded. Polygon intersection and line placement are not yet connected. +The bounded simple-polygon kernel reuses critical-block, edge-crossing, section, and intersection arrays. Concave region cross-sections can yield multiple normalized inline slots; polygon exclusions conservatively project over the full margin-expanded line band before subtraction. Focused tests cover triangle, concave, exclusion, horizontal-boundary, and slot-limit behavior. Production line placement is still the next connection. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 84e30b7f..1c248a14 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -255,6 +255,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-188 | Measured clusters are retained A/B Rust SoA state derived from final fallback-shaped glyphs and Unicode analysis: grapheme UTF-16 starts/ends, ordered `f64` advances, compact safe/allowed/required/hard-break flags, resolved-style index, source-run/font identity, and one offset-to-cluster index. Glyph positions remain `i32` design units through shaping and are scaled once with cached per-font UPEM; letter spacing, word spacing, and zero-width hard breaks join that ordered accumulation. Optional UAX #14 breaks are admitted only when the next grapheme is a HarfRust-safe boundary. A synthetic kernel proof fixes exact `[9, 7, 9, 0]` advances for design widths plus letter/word spacing and proves unsafe suppression; compiled real-font primary/fallback updates reach the pass. Optimized Wasm changes from 1,009,460 / 377,053 / 295,875 to 1,014,577 / 379,510 / 295,708 raw/gzip/Brotli bytes. The Brotli decrease is compressor interaction, not a speed claim. Composition, nonempty plan output, and complete-path timing remain open. | Accepted | | D-189 | The allocation-free Rust `layout_next_line` kernel consumes retained clusters through a grapheme cursor and one caller-supplied `f64` width. Word mode prefers safe UAX #14 opportunities and falls back to HarfRust-safe boundaries; character mode accepts every safe grapheme boundary; no-wrap ignores width but preserves required breaks; over-wide first clusters always make progress; and a terminal hard break emits the canonical trailing empty line. Focused tests prove these modes and cursor termination. The kernel is not yet connected to region-band resolution or frame output, so this checkpoint makes no end-to-end timing or production Wasm-size claim. | Accepted | | D-190 | Validated flow snapshots become retained A/B Rust state during the production update transaction. Constraints, ordered regions, exclusions, and polygon vertices are copied into reusable session arrays; vertex offsets are rebased, so no request pointer survives `text_update`. The rectangle fast path subtracts intersecting exclusions through two reusable sorted slot vectors, applies each declared wrap side, and enforces `max_slots_per_band`. An exact fixture maps region `0..100` minus exclusion `20..40` to slots `[0..20, 40..100]`; a polygon fixture proves three vertices survive as owned values. Optimized Wasm changes from 1,014,577 / 379,510 / 295,708 to 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes. Polygon band intersection, line-cursor connection, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-191 | Bounded simple polygons resolve through retained allocation-reusing sweep scratch. Region bands intersect normalized cross-sections at every in-band vertex boundary and within each linear-edge slab; polygon exclusions conservatively project vertices and band-edge intersections over the margin-expanded block range before applying the declared wrap side. Horizontal boundary segments participate before interval normalization. Exact fixtures cover a serialized triangle, a concave U region yielding `[0..40, 60..100]`, a diamond exclusion yielding `[0..20, 60..100]`, and explicit `ResultTooLarge` when two normalized slots exceed a one-slot envelope. The kernel is not yet called by frame line placement, so no production Wasm-size or complete-path timing claim is made. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 6d5ae28b..573e67ee 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -923,6 +923,14 @@ fixture resolves a `0..100` region around a `20..40` exclusion to `[0..20, 40..1 the line cursor remain open. Optimized Wasm is 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes (+2,143 / +5,083 / +1,341 from the last production Wasm checkpoint); compressed deltas are recorded as transport evidence only. +The bounded polygon band kernel now reuses retained critical-block, crossing, section, and intersection vectors. Region +slots are conservatively intersected across vertex boundaries and intervening linear-edge slabs; horizontal boundary +segments are included before normalized interval intersection. Polygon exclusions project every vertex and band-edge +intersection over the margin-expanded band, then subtract that conservative inline range using the declared wrap side. +Focused fixtures cover a triangular serialized region, a concave U-shaped region that yields two slots, a diamond +exclusion, and rejection when the normalized answer exceeds the public slot envelope. The kernel is not yet called by +frame line placement, so this checkpoint makes no production Wasm-size or end-to-end timing claim. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/flow_geometry.rs b/packages/text/rust/shaper/src/engine/flow_geometry.rs index 4c53e712..b9b548ee 100644 --- a/packages/text/rust/shaper/src/engine/flow_geometry.rs +++ b/packages/text/rust/shaper/src/engine/flow_geometry.rs @@ -4,7 +4,7 @@ use super::{ EngineError, frame::{ EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, - EXCLUSION_WRAP_LARGEST, SHAPE_RECTANGLE, + EXCLUSION_WRAP_LARGEST, SHAPE_POLYGON, SHAPE_RECTANGLE, }, semantic_wire::{FlowConstraint, FlowExclusion, FlowRegion, FlowVertex, GeometryBatch}, }; @@ -19,6 +19,9 @@ pub(crate) struct InlineSlot { pub(crate) struct InlineSlotArena { slots: Vec, scratch: Vec, + section: Vec, + crossings: Vec, + critical_blocks: Vec, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -117,7 +120,7 @@ impl FlowGeometryArena { } impl InlineSlotArena { - pub(crate) fn resolve_rectangle_band<'a>( + pub(crate) fn resolve_band<'a>( &'a mut self, geometry: &FlowGeometryArena, region_index: usize, @@ -132,17 +135,27 @@ impl InlineSlotArena { .regions .get(region_index) .ok_or(EngineError::InvalidRequest)?; - if region.record.shape != SHAPE_RECTANGLE || max_slots == 0 { + if max_slots == 0 { return Err(EngineError::InvalidRequest); } self.slots.clear(); self.scratch.clear(); reserve(&mut self.slots, max_slots)?; reserve(&mut self.scratch, max_slots)?; - self.slots.push(InlineSlot { - start: f64::from(region.record.inline_start), - end: f64::from(region.record.inline_end), - }); + match region.record.shape { + SHAPE_RECTANGLE => self.slots.push(InlineSlot { + start: f64::from(region.record.inline_start), + end: f64::from(region.record.inline_end), + }), + SHAPE_POLYGON => { + self.slots.push(InlineSlot { + start: f64::from(region.record.inline_start), + end: f64::from(region.record.inline_end), + }); + self.resolve_polygon_region(geometry, region, block_start, block_end, max_slots)? + } + _ => return Err(EngineError::InvalidRequest), + } let exclusion_start = usize::from(region.record.exclusion_start); let exclusion_end = exclusion_start .checked_add(usize::from(region.record.exclusion_count)) @@ -153,25 +166,39 @@ impl InlineSlotArena { .ok_or(EngineError::InvalidRequest)? { let record = exclusion.record; - if record.shape != SHAPE_RECTANGLE { - return Err(EngineError::InvalidRequest); - } let margin_block = f64::from(record.margin_block); - if f64::from(record.block_start) - margin_block >= block_end - || f64::from(record.block_end) + margin_block <= block_start - { - continue; - } let margin_inline = f64::from(record.margin_inline); - let cut_start = f64::from(record.inline_start) - margin_inline; - let cut_end = f64::from(record.inline_end) + margin_inline; + let cut = match record.shape { + SHAPE_RECTANGLE => { + if f64::from(record.block_start) - margin_block >= block_end + || f64::from(record.block_end) + margin_block <= block_start + { + continue; + } + Some(InlineSlot { + start: f64::from(record.inline_start) - margin_inline, + end: f64::from(record.inline_end) + margin_inline, + }) + } + SHAPE_POLYGON => polygon_projection( + polygon_vertices(geometry, exclusion.vertex_start, record.vertex_count)?, + block_start - margin_block, + block_end + margin_block, + )? + .map(|slot| InlineSlot { + start: slot.start - margin_inline, + end: slot.end + margin_inline, + }), + _ => return Err(EngineError::InvalidRequest), + }; + let Some(cut) = cut else { continue }; self.scratch.clear(); for slot in self.slots.iter().copied() { subtract_slot( &mut self.scratch, slot, - cut_start, - cut_end, + cut.start, + cut.end, record.wrap_side, max_slots, )?; @@ -180,6 +207,230 @@ impl InlineSlotArena { } Ok(&self.slots) } + + pub(crate) fn resolve_rectangle_band<'a>( + &'a mut self, + geometry: &FlowGeometryArena, + region_index: usize, + block_start: f64, + block_end: f64, + max_slots: usize, + ) -> Result<&'a [InlineSlot], EngineError> { + self.resolve_band(geometry, region_index, block_start, block_end, max_slots) + } + + fn resolve_polygon_region( + &mut self, + geometry: &FlowGeometryArena, + region: &RetainedRegion, + block_start: f64, + block_end: f64, + max_slots: usize, + ) -> Result<(), EngineError> { + let vertices = polygon_vertices(geometry, region.vertex_start, region.record.vertex_count)?; + reserve(&mut self.critical_blocks, vertices.len().saturating_add(2))?; + reserve(&mut self.crossings, vertices.len())?; + reserve(&mut self.section, vertices.len())?; + self.critical_blocks.clear(); + self.critical_blocks.push(block_start); + for vertex in vertices { + let block = f64::from(vertex.block); + if block_start < block && block < block_end { + self.critical_blocks.push(block); + } + } + self.critical_blocks.push(block_end); + self.critical_blocks.sort_by(f64::total_cmp); + self.critical_blocks.dedup(); + let mut sample_index = 0usize; + while sample_index < self.critical_blocks.len() { + let block = self.critical_blocks[sample_index]; + self.intersect_polygon_section(vertices, block, max_slots)?; + if let Some(next) = self.critical_blocks.get(sample_index + 1).copied() + && block < next + { + self.intersect_polygon_section(vertices, block + (next - block) * 0.5, max_slots)?; + } + if self.slots.is_empty() { + break; + } + sample_index += 1; + } + Ok(()) + } + + fn intersect_polygon_section( + &mut self, + vertices: &[FlowVertex], + block: f64, + max_slots: usize, + ) -> Result<(), EngineError> { + polygon_section( + vertices, + block, + &mut self.crossings, + &mut self.section, + max_slots, + )?; + self.scratch.clear(); + intersect_sorted(&self.slots, &self.section, &mut self.scratch, max_slots)?; + core::mem::swap(&mut self.slots, &mut self.scratch); + Ok(()) + } +} + +fn polygon_vertices( + geometry: &FlowGeometryArena, + start: u32, + count: u16, +) -> Result<&[FlowVertex], EngineError> { + let start = usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(count)) + .ok_or(EngineError::InvalidRequest)?; + geometry + .vertices + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + +fn polygon_section( + vertices: &[FlowVertex], + block: f64, + crossings: &mut Vec, + output: &mut Vec, + max_slots: usize, +) -> Result<(), EngineError> { + crossings.clear(); + output.clear(); + if vertices.len() < 3 { + return Err(EngineError::InvalidRequest); + } + reserve(crossings, vertices.len())?; + reserve(output, vertices.len().saturating_mul(2))?; + for index in 0..vertices.len() { + let first = vertices[index]; + let second = vertices[(index + 1) % vertices.len()]; + let first_block = f64::from(first.block); + let second_block = f64::from(second.block); + if first_block == second_block { + if block == first_block { + push_raw_nonempty( + output, + InlineSlot { + start: f64::from(first.inline.min(second.inline)), + end: f64::from(first.inline.max(second.inline)), + }, + ); + } + continue; + } + if (first_block <= block && block < second_block) + || (second_block <= block && block < first_block) + { + let ratio = (block - first_block) / (second_block - first_block); + crossings.push( + f64::from(first.inline) + + (f64::from(second.inline) - f64::from(first.inline)) * ratio, + ); + } + } + crossings.sort_by(f64::total_cmp); + for pair in crossings.chunks_exact(2) { + push_raw_nonempty( + output, + InlineSlot { + start: pair[0], + end: pair[1], + }, + ); + } + normalize_slots(output); + if output.len() > max_slots { + return Err(EngineError::ResultTooLarge); + } + Ok(()) +} + +fn polygon_projection( + vertices: &[FlowVertex], + block_start: f64, + block_end: f64, +) -> Result, EngineError> { + if vertices.len() < 3 || block_start >= block_end { + return Err(EngineError::InvalidRequest); + } + let mut minimum = f64::INFINITY; + let mut maximum = f64::NEG_INFINITY; + for index in 0..vertices.len() { + let first = vertices[index]; + let second = vertices[(index + 1) % vertices.len()]; + let first_block = f64::from(first.block); + let second_block = f64::from(second.block); + if block_start <= first_block && first_block <= block_end { + minimum = minimum.min(f64::from(first.inline)); + maximum = maximum.max(f64::from(first.inline)); + } + for boundary in [block_start, block_end] { + if first_block != second_block + && ((first_block <= boundary && boundary <= second_block) + || (second_block <= boundary && boundary <= first_block)) + { + let ratio = (boundary - first_block) / (second_block - first_block); + let inline = f64::from(first.inline) + + (f64::from(second.inline) - f64::from(first.inline)) * ratio; + minimum = minimum.min(inline); + maximum = maximum.max(inline); + } + } + } + Ok((minimum < maximum).then_some(InlineSlot { + start: minimum, + end: maximum, + })) +} + +fn intersect_sorted( + first: &[InlineSlot], + second: &[InlineSlot], + output: &mut Vec, + max_slots: usize, +) -> Result<(), EngineError> { + let mut first_index = 0usize; + let mut second_index = 0usize; + while first_index < first.len() && second_index < second.len() { + let left = first[first_index]; + let right = second[second_index]; + push_nonempty( + output, + InlineSlot { + start: left.start.max(right.start), + end: left.end.min(right.end), + }, + max_slots, + )?; + if left.end <= right.end { + first_index += 1; + } else { + second_index += 1; + } + } + Ok(()) +} + +fn normalize_slots(slots: &mut Vec) { + slots.sort_by(|first, second| first.start.total_cmp(&second.start)); + let mut write = 0usize; + for read in 0..slots.len() { + let slot = slots[read]; + if write > 0 && slot.start <= slots[write - 1].end { + slots[write - 1].end = slots[write - 1].end.max(slot.end); + } else { + slots[write] = slot; + write += 1; + } + } + slots.truncate(write); } fn subtract_slot( @@ -231,6 +482,12 @@ fn push_nonempty( Ok(()) } +fn push_raw_nonempty(destination: &mut Vec, slot: InlineSlot) { + if slot.start < slot.end { + destination.push(slot); + } +} + fn push_slot( destination: &mut Vec, slot: InlineSlot, @@ -269,3 +526,124 @@ fn reserve(values: &mut Vec, additional: usize) -> Result<(), EngineError> } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::frame::{ORIENTATION_MIXED, WRITING_HORIZONTAL_TB}; + use alloc::vec; + + #[test] + fn concave_region_and_polygon_exclusion_resolve_conservatively() { + let mut concave = FlowGeometryArena { + vertices: vec![ + vertex(0.0, 0.0), + vertex(100.0, 0.0), + vertex(100.0, 100.0), + vertex(60.0, 100.0), + vertex(60.0, 40.0), + vertex(40.0, 40.0), + vertex(40.0, 100.0), + vertex(0.0, 100.0), + ], + ..FlowGeometryArena::default() + }; + concave.regions.push(RetainedRegion { + record: region(SHAPE_POLYGON, 8, 0), + vertex_start: 0, + }); + let mut slots = InlineSlotArena::default(); + assert_eq!( + slots.resolve_band(&concave, 0, 20.0, 60.0, 4).unwrap(), + [ + InlineSlot { + start: 0.0, + end: 40.0, + }, + InlineSlot { + start: 60.0, + end: 100.0, + }, + ] + ); + assert_eq!( + slots.resolve_band(&concave, 0, 20.0, 60.0, 1), + Err(EngineError::ResultTooLarge) + ); + + let mut excluded = FlowGeometryArena { + vertices: vec![ + vertex(40.0, 20.0), + vertex(60.0, 40.0), + vertex(40.0, 60.0), + vertex(20.0, 40.0), + ], + ..FlowGeometryArena::default() + }; + excluded.regions.push(RetainedRegion { + record: region(SHAPE_RECTANGLE, 0, 1), + vertex_start: 0, + }); + excluded.exclusions.push(RetainedExclusion { + record: exclusion(SHAPE_POLYGON, 4), + vertex_start: 0, + }); + assert_eq!( + slots.resolve_band(&excluded, 0, 30.0, 50.0, 4).unwrap(), + [ + InlineSlot { + start: 0.0, + end: 20.0, + }, + InlineSlot { + start: 60.0, + end: 100.0, + }, + ] + ); + } + + fn vertex(inline: f32, block: f32) -> FlowVertex { + FlowVertex { inline, block } + } + + fn region(shape: u8, vertex_count: u16, exclusion_count: u16) -> FlowRegion { + FlowRegion { + id: 1, + geometry_revision: 1, + vertices_offset: 0, + vertex_count, + exclusion_start: 0, + exclusion_count, + shape, + writing_mode: WRITING_HORIZONTAL_TB, + text_orientation: ORIENTATION_MIXED, + inline_start: 0.0, + block_start: 0.0, + inline_end: 100.0, + block_end: 100.0, + clip_inline_start: 0.0, + clip_block_start: 0.0, + clip_inline_end: 100.0, + clip_block_end: 100.0, + } + } + + fn exclusion(shape: u8, vertex_count: u16) -> FlowExclusion { + FlowExclusion { + id: 2, + region_id: 1, + geometry_revision: 1, + vertices_offset: 0, + vertex_count, + shape, + wrap_side: EXCLUSION_WRAP_BOTH, + inline_start: 20.0, + block_start: 20.0, + inline_end: 60.0, + block_end: 60.0, + margin_inline: 0.0, + margin_block: 0.0, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 8ac87052..57c3b260 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -1760,6 +1760,17 @@ mod tests { block: 100.0 } ); + retained.regions[0].record.exclusion_count = 0; + let mut polygon_slots = InlineSlotArena::default(); + assert_eq!( + polygon_slots + .resolve_band(&retained, 0, 0.0, 50.0, 4) + .unwrap(), + [InlineSlot { + start: 0.0, + end: 50.0, + }] + ); let mut rectangle = FlowGeometryArena::default(); rectangle.build(geometry).unwrap(); let mut slots = InlineSlotArena::default(); From ecbee801879c491c165950fc53b403ea78a90075 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 15:57:34 -0400 Subject: [PATCH 038/128] feat(text): compose rust flow regions --- docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 10 + .../shaper/src/engine/flow_composition.rs | 623 ++++++++++++++++++ .../shaper/src/engine/line_composition.rs | 17 + packages/text/rust/shaper/src/engine/mod.rs | 2 + packages/text/rust/shaper/src/engine/state.rs | 86 ++- 7 files changed, 740 insertions(+), 3 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/flow_composition.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index 3b70768d..c6759521 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:3c2123625efa37228ccddc3853ba2f1c449b8aa90e9ff73f6e541085b519ec8b' +source_digest: 'sha256:8b5e6bb25ae155f20da2f7b70469f514899d81c946cb4b4239d3ca46570e51c9' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -679,6 +679,8 @@ Validated flow snapshots are now copied into transactional A/B Rust storage: con The bounded simple-polygon kernel reuses critical-block, edge-crossing, section, and intersection arrays. Concave region cross-sections can yield multiple normalized inline slots; polygon exclusions conservatively project over the full margin-expanded line band before subtraction. Focused tests cover triangle, concave, exclusion, horizontal-boundary, and slot-limit behavior. Production line placement is still the next connection. +Production horizontal frame updates now retain line and fragment arrays derived from those slots. Each band may carry multiple same-baseline fragments around holes, uses actual selected fallback-font metrics, and performs at most one conservative height retry. Sequential regions consume one cursor without balancing; an exact fixture overflows four lines through region IDs `[1, 2, 2, 2]`. Vertical placement and render-plan gather are still open. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 1c248a14..1b437954 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -256,6 +256,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-189 | The allocation-free Rust `layout_next_line` kernel consumes retained clusters through a grapheme cursor and one caller-supplied `f64` width. Word mode prefers safe UAX #14 opportunities and falls back to HarfRust-safe boundaries; character mode accepts every safe grapheme boundary; no-wrap ignores width but preserves required breaks; over-wide first clusters always make progress; and a terminal hard break emits the canonical trailing empty line. Focused tests prove these modes and cursor termination. The kernel is not yet connected to region-band resolution or frame output, so this checkpoint makes no end-to-end timing or production Wasm-size claim. | Accepted | | D-190 | Validated flow snapshots become retained A/B Rust state during the production update transaction. Constraints, ordered regions, exclusions, and polygon vertices are copied into reusable session arrays; vertex offsets are rebased, so no request pointer survives `text_update`. The rectangle fast path subtracts intersecting exclusions through two reusable sorted slot vectors, applies each declared wrap side, and enforces `max_slots_per_band`. An exact fixture maps region `0..100` minus exclusion `20..40` to slots `[0..20, 40..100]`; a polygon fixture proves three vertices survive as owned values. Optimized Wasm changes from 1,014,577 / 379,510 / 295,708 to 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes. Polygon band intersection, line-cursor connection, nonempty plan output, and complete-path timing remain open. | Accepted | | D-191 | Bounded simple polygons resolve through retained allocation-reusing sweep scratch. Region bands intersect normalized cross-sections at every in-band vertex boundary and within each linear-edge slab; polygon exclusions conservatively project vertices and band-edge intersections over the margin-expanded block range before applying the declared wrap side. Horizontal boundary segments participate before interval normalization. Exact fixtures cover a serialized triangle, a concave U region yielding `[0..40, 60..100]`, a diamond exclusion yielding `[0..20, 60..100]`, and explicit `ResultTooLarge` when two normalized slots exceed a one-slot envelope. The kernel is not yet called by frame line placement, so no production Wasm-size or complete-path timing claim is made. | Accepted | +| D-192 | Production horizontal frame layout now consumes retained clusters and flow geometry into transactional line/fragment A/B arrays. A session-global slot workspace remains allocation-free after its declared high water mark. A band composes every available slot on one baseline, derives ascent/descent/line-height from each cluster's actual fallback font and resolved style in `f64`, and retries at most once with the maximum metrics found by the first widest pass. Enlarging the band conservatively intersects region slots and expands exclusion projection, so the retry consumes a subset rather than exposing later styles. Exact tests produce two fragments around a hole, raise a 10 px estimate to a 20 px line/16 px baseline, and overflow sequentially through region IDs `[1, 2, 2, 2]` without balancing. Rebuilt frame ABI and real-font/fallback integration tests pass. Optimized Wasm changes from 1,016,720 / 384,593 / 297,049 to 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes. Vertical flow, final positioning, boundary reshaping, nonempty plan output, and complete-path timing remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 573e67ee..6c466ef0 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -931,6 +931,16 @@ Focused fixtures cover a triangular serialized region, a concave U-shaped region exclusion, and rejection when the normalized answer exceeds the public slot envelope. The kernel is not yet called by frame line placement, so this checkpoint makes no production Wasm-size or end-to-end timing claim. +Horizontal frame layout now calls the retained band and line kernels in production. A flow-layout A/B arena owns lines +and same-baseline fragments; one reusable slot workspace serves the session. Each band starts from the next cluster's +actual fallback-font metrics, composes over every available slot, and performs at most one conservative retry when the +widest first pass discovers taller content. Enlarging a band only intersects more region cross-sections and projects +more exclusion coverage, so the retry cannot expose later clusters. Exact tests place two fragments around one hole, +retry 10 px text to a 20 px mixed-style line with 16 px baseline, and continue four lines through region IDs +`[1, 2, 2, 2]` without balancing. Rebuilt Wasm preserves the frame and real-font/fallback integration tests. Optimized +Wasm is 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes (+22,684 / +8,078 / +6,656). Vertical flow, positioning, +boundary reshaping, semantic/glyph gather, nonempty plan output, and complete-path timing remain open. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs new file mode 100644 index 00000000..43acb8fa --- /dev/null +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -0,0 +1,623 @@ +use alloc::vec::Vec; + +use crate::FontMetrics; + +use super::{ + EngineError, + cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, + flow_geometry::{FlowGeometryArena, InlineSlotArena}, + frame::WRITING_HORIZONTAL_TB, + line_composition::{ComposedLine, LineCursor, layout_next_line}, + style_state::StyleSegment, +}; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowLine { + pub flow_thread_id: u32, + pub region_id: u32, + pub fragment_start: u32, + pub fragment_count: u16, + pub block_start: f64, + pub baseline: f64, + pub height: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct FlowFragment { + pub line: ComposedLine, + pub slot_start: f64, + pub slot_end: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +struct LineExtents { + above: f64, + below: f64, +} + +impl LineExtents { + fn height(self) -> f64 { + self.above + self.below + } + + fn include(&mut self, other: Self) { + self.above = self.above.max(other.above); + self.below = self.below.max(other.below); + } +} + +#[derive(Default)] +pub(crate) struct FlowLayoutArena { + pub lines: Vec, + pub fragments: Vec, +} + +impl FlowLayoutArena { + #[allow(clippy::too_many_arguments)] + pub(crate) fn build( + &mut self, + geometry: &FlowGeometryArena, + clusters: &ClusterArena, + styles: &[StyleSegment], + slots: &mut InlineSlotArena, + max_lines: usize, + max_slots_per_band: usize, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, + ) -> Result<(), EngineError> { + self.clear(); + if clusters.starts.is_empty() || geometry.constraints.is_empty() { + return Ok(()); + } + reserve(&mut self.lines, max_lines)?; + reserve( + &mut self.fragments, + max_lines.saturating_mul(max_slots_per_band), + )?; + for constraint in geometry.constraints.iter().copied() { + let resume = cluster_for_offset(clusters, constraint.resume_cluster)?; + let mut cursor = LineCursor::at_cluster(resume); + let region_start = usize::try_from(constraint.region_start) + .map_err(|_| EngineError::InvalidRequest)?; + let first_region = region_start + .checked_add(usize::from(constraint.resume_region)) + .ok_or(EngineError::InvalidRequest)?; + let region_end = region_start + .checked_add(usize::from(constraint.region_count)) + .ok_or(EngineError::InvalidRequest)?; + let constraint_line_limit = if constraint.max_lines == 0 { + max_lines + } else { + usize::try_from(constraint.max_lines) + .map_err(|_| EngineError::ResultTooLarge)? + .min(max_lines) + }; + let thread_line_start = self.lines.len(); + for region_index in first_region..region_end { + if cursor.is_complete(clusters.starts.len()) + || self.lines.len().saturating_sub(thread_line_start) >= constraint_line_limit + { + break; + } + let region = geometry + .regions + .get(region_index) + .ok_or(EngineError::InvalidRequest)?; + if region.record.writing_mode != WRITING_HORIZONTAL_TB { + return Err(EngineError::InvalidRequest); + } + let mut block = f64::from(region.record.block_start); + if region_index == first_region { + block += f64::from(constraint.resume_block_offset); + } + let block_end = f64::from(region.record.block_end); + while !cursor.is_complete(clusters.starts.len()) + && self.lines.len().saturating_sub(thread_line_start) < constraint_line_limit + && self.lines.len() < max_lines + && block < block_end + { + let estimate = extents_for_cluster( + clusters, + styles, + cursor + .cluster() + .min(clusters.starts.len().saturating_sub(1)), + metrics_for, + first_font_for_stack, + )?; + let estimate = positive_extents(estimate, styles, clusters, cursor.cluster())?; + match self.compose_band( + geometry, + region_index, + constraint.flow_thread_id, + region.record.id, + clusters, + styles, + slots, + &mut cursor, + block, + block_end, + estimate, + constraint.wrap, + max_slots_per_band, + metrics_for, + first_font_for_stack, + )? { + Some(height) => block += height, + None => block += estimate.height(), + } + } + } + } + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn compose_band( + &mut self, + geometry: &FlowGeometryArena, + region_index: usize, + flow_thread_id: u32, + region_id: u32, + clusters: &ClusterArena, + styles: &[StyleSegment], + slot_arena: &mut InlineSlotArena, + cursor: &mut LineCursor, + block_start: f64, + region_block_end: f64, + initial_extents: LineExtents, + wrap: u8, + max_slots: usize, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, + ) -> Result, EngineError> { + let saved_cursor = *cursor; + let fragment_start = self.fragments.len(); + let mut extents = initial_extents; + for attempt in 0..2 { + let height = extents.height(); + if block_start + height > region_block_end { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + return Ok(None); + } + let available = slot_arena.resolve_band( + geometry, + region_index, + block_start, + block_start + height, + max_slots, + )?; + if available.is_empty() { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + return Ok(None); + } + let mut measured = LineExtents::default(); + let mut composed = false; + for slot in available.iter().copied() { + let Some(line) = layout_next_line(clusters, cursor, slot.end - slot.start, wrap)? + else { + break; + }; + include_range_extents( + &mut measured, + clusters, + styles, + line, + metrics_for, + first_font_for_stack, + )?; + self.fragments.push(FlowFragment { + line, + slot_start: slot.start, + slot_end: slot.end, + }); + composed = true; + if line.hard_break || cursor.is_complete(clusters.starts.len()) { + break; + } + } + if !composed { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + return Ok(None); + } + measured.include(initial_extents); + if attempt == 0 && measured.height() > height { + self.fragments.truncate(fragment_start); + *cursor = saved_cursor; + extents = measured; + continue; + } + let fragment_count = self.fragments.len() - fragment_start; + self.lines.push(FlowLine { + flow_thread_id, + region_id, + fragment_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + fragment_count: u16::try_from(fragment_count) + .map_err(|_| EngineError::ResultTooLarge)?, + block_start, + baseline: extents.above, + height: extents.height(), + }); + return Ok(Some(extents.height())); + } + Err(EngineError::InvalidRequest) + } + + pub(crate) fn clear(&mut self) { + self.lines.clear(); + self.fragments.clear(); + } +} + +fn cluster_for_offset(clusters: &ClusterArena, offset: u32) -> Result { + let offset = usize::try_from(offset).map_err(|_| EngineError::InvalidRequest)?; + let index = usize::try_from( + *clusters + .index_at + .get(offset) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + if offset != 0 + && index < clusters.starts.len() + && clusters.starts[index] + != u32::try_from(offset).map_err(|_| EngineError::InvalidRequest)? + { + return Err(EngineError::InvalidRequest); + } + Ok(index) +} + +fn include_range_extents( + target: &mut LineExtents, + clusters: &ClusterArena, + styles: &[StyleSegment], + line: ComposedLine, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, +) -> Result<(), EngineError> { + let start = usize::try_from(line.cluster_start).map_err(|_| EngineError::InvalidRequest)?; + let end = usize::try_from(line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + if start == end { + let fallback = start + .saturating_sub(1) + .min(clusters.starts.len().saturating_sub(1)); + target.include(extents_for_cluster( + clusters, + styles, + fallback, + metrics_for, + first_font_for_stack, + )?); + return Ok(()); + } + for index in start..end { + if clusters.flags[index] & CLUSTER_HARD_BREAK == 0 { + target.include(extents_for_cluster( + clusters, + styles, + index, + metrics_for, + first_font_for_stack, + )?); + } + } + Ok(()) +} + +fn extents_for_cluster( + clusters: &ClusterArena, + styles: &[StyleSegment], + index: usize, + metrics_for: impl Fn(u32) -> Option, + first_font_for_stack: impl Fn(u32) -> Option, +) -> Result { + let style_index = usize::try_from( + *clusters + .style_indexes + .get(index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style; + let selected = clusters.font_handles.get(index).copied().unwrap_or(0); + let font_handle = if selected == 0 { + first_font_for_stack(style.font_stack_handle).ok_or(EngineError::FontStackMissing)? + } else { + selected + }; + let metrics = metrics_for(font_handle).ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); + let ascent = (f64::from(metrics.ascender) * scale).max(0.0); + let descent = (-f64::from(metrics.descender) * scale).max(0.0); + let natural = (f64::from(metrics.ascender) - f64::from(metrics.descender) + + f64::from(metrics.line_gap)) + * scale; + let requested = if style.has_line_height { + f64::from(style.font_size * style.line_height) + } else { + natural + }; + let leading = (requested - ascent - descent).max(0.0); + let shift = f64::from(style.baseline_shift); + Ok(LineExtents { + above: (ascent + leading * 0.5 + shift).max(0.0), + below: (descent + leading * 0.5 - shift).max(0.0), + }) +} + +fn positive_extents( + extents: LineExtents, + styles: &[StyleSegment], + clusters: &ClusterArena, + index: usize, +) -> Result { + if extents.height().is_finite() && extents.height() > 0.0 { + return Ok(extents); + } + let style_index = usize::try_from( + *clusters + .style_indexes + .get(index.min(clusters.starts.len().saturating_sub(1))) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let fallback = f64::from( + styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style + .font_size, + ); + if !fallback.is_finite() || fallback <= 0.0 { + return Err(EngineError::InvalidRequest); + } + Ok(LineExtents { + above: fallback, + below: 0.0, + }) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), EngineError> { + if values.capacity().saturating_sub(values.len()) < additional { + values + .try_reserve_exact(additional) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + cluster_state::CLUSTER_SAFE_BEFORE, + flow_geometry::{RetainedExclusion, RetainedRegion}, + frame::{ + ALIGN_START, AXIS_EXACT, BLOCK_ALIGN_START, EXCLUSION_WRAP_BOTH, ORIENTATION_MIXED, + OVERFLOW_VISIBLE, SHAPE_RECTANGLE, WRAP_CHARACTER, + }, + semantic_wire::{FlowConstraint, FlowExclusion, FlowRegion}, + style_state::ResolvedStyle, + }; + use alloc::vec; + + #[test] + fn one_update_flows_fragments_around_a_hole_and_retries_for_tall_text() { + let clusters = ClusterArena { + starts: vec![0, 1, 2, 3], + ends: vec![1, 2, 3, 4], + advances: vec![2.0; 4], + flags: vec![CLUSTER_SAFE_BEFORE; 4], + style_indexes: vec![0, 0, 1, 1], + source_runs: vec![0; 4], + font_handles: vec![1; 4], + index_at: vec![0, 1, 2, 3, 4], + ..ClusterArena::default() + }; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 2, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }, + StyleSegment { + text_start: 2, + text_end: 4, + style: ResolvedStyle::test_typography(20.0, 0.0, 0.0), + }, + ]; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: region(), + vertex_start: 0, + }], + exclusions: vec![RetainedExclusion { + record: exclusion(), + vertex_start: 0, + }], + vertices: vec![], + }; + let mut layout = FlowLayoutArena::default(); + let mut slots = InlineSlotArena::default(); + layout + .build( + &geometry, + &clusters, + &styles, + &mut slots, + 8, + 4, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + |_| Some(1), + ) + .unwrap(); + assert_eq!(layout.lines.len(), 1); + assert_eq!(layout.fragments.len(), 2); + assert_eq!(layout.lines[0].height, 20.0); + assert_eq!(layout.lines[0].baseline, 16.0); + assert_eq!(layout.lines[0].fragment_count, 2); + assert_eq!(layout.fragments[0].slot_start, 0.0); + assert_eq!(layout.fragments[0].slot_end, 4.0); + assert_eq!(layout.fragments[0].line.cluster_end, 2); + assert_eq!(layout.fragments[1].slot_start, 6.0); + assert_eq!(layout.fragments[1].line.cluster_end, 4); + } + + #[test] + fn overflowing_text_continues_through_ordered_regions_without_balancing() { + let clusters = ClusterArena { + starts: vec![0, 1, 2, 3], + ends: vec![1, 2, 3, 4], + advances: vec![3.0; 4], + flags: vec![CLUSTER_SAFE_BEFORE; 4], + style_indexes: vec![0; 4], + source_runs: vec![0; 4], + font_handles: vec![1; 4], + index_at: vec![0, 1, 2, 3, 4], + ..ClusterArena::default() + }; + let styles = [StyleSegment { + text_start: 0, + text_end: 4, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut first = region(); + first.id = 1; + first.inline_end = 4.0; + first.clip_inline_end = 4.0; + first.block_end = 10.0; + first.clip_block_end = 10.0; + first.exclusion_count = 0; + let mut second = first; + second.id = 2; + second.block_end = 30.0; + second.clip_block_end = 30.0; + let mut flow = constraint(); + flow.region_count = 2; + let geometry = FlowGeometryArena { + constraints: vec![flow], + regions: vec![ + RetainedRegion { + record: first, + vertex_start: 0, + }, + RetainedRegion { + record: second, + vertex_start: 0, + }, + ], + exclusions: vec![], + vertices: vec![], + }; + let mut layout = FlowLayoutArena::default(); + layout + .build( + &geometry, + &clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 2, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + |_| Some(1), + ) + .unwrap(); + assert_eq!( + layout + .lines + .iter() + .map(|line| line.region_id) + .collect::>(), + [1, 2, 2, 2] + ); + assert_eq!(layout.fragments.last().unwrap().line.cluster_end, 4); + } + + fn constraint() -> FlowConstraint { + FlowConstraint { + flow_thread_id: 1, + width: 10.0, + height: 100.0, + viewport_block_start: 0.0, + viewport_block_end: 100.0, + resume_block_offset: 0.0, + max_lines: 8, + region_start: 0, + resume_cluster: 0, + region_count: 1, + resume_region: 0, + width_mode: AXIS_EXACT, + height_mode: AXIS_EXACT, + wrap: WRAP_CHARACTER, + align: ALIGN_START, + overflow: OVERFLOW_VISIBLE, + block_align: BLOCK_ALIGN_START, + } + } + + fn region() -> FlowRegion { + FlowRegion { + id: 7, + geometry_revision: 1, + vertices_offset: 0, + vertex_count: 0, + exclusion_start: 0, + exclusion_count: 1, + shape: SHAPE_RECTANGLE, + writing_mode: WRITING_HORIZONTAL_TB, + text_orientation: ORIENTATION_MIXED, + inline_start: 0.0, + block_start: 0.0, + inline_end: 10.0, + block_end: 100.0, + clip_inline_start: 0.0, + clip_block_start: 0.0, + clip_inline_end: 10.0, + clip_block_end: 100.0, + } + } + + fn exclusion() -> FlowExclusion { + FlowExclusion { + id: 9, + region_id: 7, + geometry_revision: 1, + vertices_offset: 0, + vertex_count: 0, + shape: SHAPE_RECTANGLE, + wrap_side: EXCLUSION_WRAP_BOTH, + inline_start: 4.0, + block_start: 0.0, + inline_end: 6.0, + block_end: 40.0, + margin_inline: 0.0, + margin_block: 0.0, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/line_composition.rs b/packages/text/rust/shaper/src/engine/line_composition.rs index 4186f054..a841d2cd 100644 --- a/packages/text/rust/shaper/src/engine/line_composition.rs +++ b/packages/text/rust/shaper/src/engine/line_composition.rs @@ -13,6 +13,23 @@ pub(crate) struct LineCursor { trailing_empty: bool, } +impl LineCursor { + pub(crate) const fn at_cluster(cluster: usize) -> Self { + Self { + cluster, + trailing_empty: false, + } + } + + pub(crate) const fn cluster(self) -> usize { + self.cluster + } + + pub(crate) const fn is_complete(self, cluster_count: usize) -> bool { + self.cluster == cluster_count && !self.trailing_empty + } +} + #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct ComposedLine { pub cluster_start: u32, diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index dc9e0c8e..098b5d59 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -5,6 +5,8 @@ mod cluster_state; #[cfg_attr(not(test), allow(dead_code))] +mod flow_composition; +#[cfg_attr(not(test), allow(dead_code))] mod flow_geometry; pub mod font_binding; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 3d6c9936..510218c1 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -8,6 +8,7 @@ use crate::{ use super::{ cluster_state::ClusterArena, + flow_composition::FlowLayoutArena, flow_geometry::FlowGeometryArena, font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, @@ -97,6 +98,9 @@ struct EngineSession { pending_clusters: ClusterArena, geometry: FlowGeometryArena, pending_geometry: FlowGeometryArena, + flow_layout: FlowLayoutArena, + pending_flow_layout: FlowLayoutArena, + flow_slot_scratch: super::flow_geometry::InlineSlotArena, fallback_spans: Vec, pending_fallback_spans: Vec, fallback_span_scratch: Vec, @@ -114,6 +118,7 @@ struct EngineSession { geometry_fingerprint: u64, pending_geometry_fingerprint: u64, geometry_prepared: bool, + flow_layout_prepared: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -416,7 +421,7 @@ impl TextEngine { fn prepare_update_inner( &mut self, - shaper: Option<&mut ShaperRegistry>, + mut shaper: Option<&mut ShaperRegistry>, request: UpdateRequest<'_>, publication_generation: u32, ) -> Result { @@ -499,7 +504,7 @@ impl TextEngine { session.abort_bidi(); return Err(error); } - if let Some(shaper) = shaper { + if let Some(shaper) = shaper.as_deref_mut() { if let Err(error) = session.prepare_shape(shaper, font_stacks) { session.abort_text(); session.abort_styles(); @@ -529,6 +534,25 @@ impl TextEngine { session.abort_clusters(); return Err(error); } + if let Some(shaper) = shaper + && let Err(error) = session.prepare_flow_layout( + shaper, + font_stacks, + request.limits.max_lines, + request.limits.max_slots_per_band, + ) + { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); + session.abort_shape(); + session.abort_clusters(); + session.abort_geometry(); + session.abort_flow_layout(); + return Err(error); + } if let Err(error) = gather.gather( policy, CapabilitySetId(request.capability_set), @@ -552,6 +576,7 @@ impl TextEngine { session.abort_shape(); session.abort_clusters(); session.abort_geometry(); + session.abort_flow_layout(); return Err(gather_error(error)); } let gathered = gather.view(); @@ -571,6 +596,7 @@ impl TextEngine { session.abort_shape(); session.abort_clusters(); session.abort_geometry(); + session.abort_flow_layout(); return Err(plan_error(error)); } Ok(PreparedUpdate { @@ -623,6 +649,7 @@ impl TextEngine { session.abort_shape(); session.abort_clusters(); session.abort_geometry(); + session.abort_flow_layout(); Ok(()) } @@ -646,6 +673,7 @@ impl TextEngine { session.commit_shape(); session.commit_clusters(); session.commit_geometry(); + session.commit_flow_layout(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, @@ -1140,6 +1168,60 @@ impl EngineSession { } self.abort_geometry(); } + + fn prepare_flow_layout( + &mut self, + shaper: &ShaperRegistry, + font_stacks: &[RegisteredFontStack], + max_lines: u32, + max_slots_per_band: u32, + ) -> Result<(), EngineError> { + self.abort_flow_layout(); + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let geometry = if self.geometry_prepared { + &self.pending_geometry + } else { + &self.geometry + }; + self.pending_flow_layout.build( + geometry, + clusters, + styles, + &mut self.flow_slot_scratch, + usize::try_from(max_lines).map_err(|_| EngineError::ResultTooLarge)?, + usize::try_from(max_slots_per_band).map_err(|_| EngineError::ResultTooLarge)?, + |handle| shaper.font_metrics(handle), + |stack_handle| { + font_stacks + .binary_search_by_key(&stack_handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks[index].fonts.first().copied()) + }, + )?; + self.flow_layout_prepared = true; + Ok(()) + } + + fn abort_flow_layout(&mut self) { + self.pending_flow_layout.clear(); + self.flow_layout_prepared = false; + } + + fn commit_flow_layout(&mut self) { + if self.flow_layout_prepared { + core::mem::swap(&mut self.flow_layout, &mut self.pending_flow_layout); + } + self.abort_flow_layout(); + } } fn apply_text_mutation( From 7e514740357b8ec6d9151330fc15f5133cf1e0e7 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 16:10:58 -0400 Subject: [PATCH 039/128] feat(text): retain stable text identities --- docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 9 ++ .../rust/shaper/src/engine/cluster_state.rs | 59 ++++++++-- packages/text/rust/shaper/src/engine/state.rs | 107 ++++++++++++++++-- 5 files changed, 164 insertions(+), 16 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index c6759521..b738a682 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:8b5e6bb25ae155f20da2f7b70469f514899d81c946cb4b4239d3ca46570e51c9' +source_digest: 'sha256:b025aa70a32177a27b8c205fc169225cdb07037571dca571fe94b32feb7f1504' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -681,6 +681,8 @@ The bounded simple-polygon kernel reuses critical-block, edge-crossing, section, Production horizontal frame updates now retain line and fragment arrays derived from those slots. Each band may carry multiple same-baseline fragments around holes, uses actual selected fallback-font metrics, and performs at most one conservative height retry. Sequential regions consume one cursor without balancing; an exact fixture overflows four lines through region IDs `[1, 2, 2, 2]`. Vertical placement and render-plan gather are still open. +Stable render identity begins with a transactional ID parallel to every retained UTF-16 unit. Ordered replacements preserve IDs for unchanged units even when earlier insertions shift their offsets; inserted units receive monotonic nonzero IDs, and abort/retry restores the allocator deterministically. Graphemes inherit their first unit ID. Per-glyph identity and revision still follow before plan publication. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 1b437954..9046aff4 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -257,6 +257,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-190 | Validated flow snapshots become retained A/B Rust state during the production update transaction. Constraints, ordered regions, exclusions, and polygon vertices are copied into reusable session arrays; vertex offsets are rebased, so no request pointer survives `text_update`. The rectangle fast path subtracts intersecting exclusions through two reusable sorted slot vectors, applies each declared wrap side, and enforces `max_slots_per_band`. An exact fixture maps region `0..100` minus exclusion `20..40` to slots `[0..20, 40..100]`; a polygon fixture proves three vertices survive as owned values. Optimized Wasm changes from 1,014,577 / 379,510 / 295,708 to 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes. Polygon band intersection, line-cursor connection, nonempty plan output, and complete-path timing remain open. | Accepted | | D-191 | Bounded simple polygons resolve through retained allocation-reusing sweep scratch. Region bands intersect normalized cross-sections at every in-band vertex boundary and within each linear-edge slab; polygon exclusions conservatively project vertices and band-edge intersections over the margin-expanded block range before applying the declared wrap side. Horizontal boundary segments participate before interval normalization. Exact fixtures cover a serialized triangle, a concave U region yielding `[0..40, 60..100]`, a diamond exclusion yielding `[0..20, 60..100]`, and explicit `ResultTooLarge` when two normalized slots exceed a one-slot envelope. The kernel is not yet called by frame line placement, so no production Wasm-size or complete-path timing claim is made. | Accepted | | D-192 | Production horizontal frame layout now consumes retained clusters and flow geometry into transactional line/fragment A/B arrays. A session-global slot workspace remains allocation-free after its declared high water mark. A band composes every available slot on one baseline, derives ascent/descent/line-height from each cluster's actual fallback font and resolved style in `f64`, and retries at most once with the maximum metrics found by the first widest pass. Enlarging the band conservatively intersects region slots and expands exclusion projection, so the retry consumes a subset rather than exposing later styles. Exact tests produce two fragments around a hole, raise a 10 px estimate to a 20 px line/16 px baseline, and overflow sequentially through region IDs `[1, 2, 2, 2]` without balancing. Rebuilt frame ABI and real-font/fallback integration tests pass. Optimized Wasm changes from 1,016,720 / 384,593 / 297,049 to 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes. Vertical flow, final positioning, boundary reshaping, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-193 | Stable render identity starts with transactional UTF-16 unit IDs, not mutable offsets or collision-prone content hashes. A/B unit-ID arrays undergo the same ordered replacements as text; only inserted units receive monotonic nonzero IDs, and abort restores both committed IDs and the allocator cursor so retry is deterministic. Each grapheme inherits its first unit's ID. An exact fixture preserves `[1,2,3,4]` through abort, then grows an edit to `[1,5,6,3,4,7]`, proving that the shifted `c/d` suffix retains IDs `3/4`; a later first-unit replacement yields `[8,5,6,3,4,7]`. Optimized Wasm changes from 1,039,404 / 392,671 / 303,705 to 1,041,582 / 393,214 / 304,815 raw/gzip/Brotli bytes. Glyph-range allocation, per-glyph revision, positioning, and nonempty plan output remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 6c466ef0..49d0fce2 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -941,6 +941,15 @@ retry 10 px text to a 20 px mixed-style line with 16 px baseline, and continue f Wasm is 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes (+22,684 / +8,078 / +6,656). Vertical flow, positioning, boundary reshaping, semantic/glyph gather, nonempty plan output, and complete-path timing remain open. +Stable render identity now begins at retained text rather than mutable offsets. Parallel A/B UTF-16 unit-ID arrays apply +the exact ordered replacement operations as text, allocate nonzero monotonic IDs only for inserted units, and commit or +abort their allocator transactionally. Grapheme clusters inherit the first unit ID, so unchanged clusters retain +identity when earlier edits shift every following UTF-16 offset; edits within a cluster retain identity while later +content revision can change. An exact abort/retry fixture keeps `[1,2,3,4]`, then an offset-growing edit produces +`[1,5,6,3,4,7]`, proving shifted suffix preservation and deterministic retry. Optimized Wasm is 1,041,582 / 393,214 / +304,815 raw/gzip/Brotli bytes (+2,178 / +543 / +1,110). Per-cluster glyph ranges, glyph identity/revision, positioning, +and nonempty plans remain open. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index 563882dc..8892d140 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -25,11 +25,21 @@ pub(crate) struct ClusterArena { pub style_indexes: Vec, pub source_runs: Vec, pub font_handles: Vec, + pub stable_ids: Vec, pub index_at: Vec, pub(super) shaped: Vec, pub(super) unsafe_before: Vec, } +pub(crate) struct ClusterBuildInput<'a> { + pub text: &'a [u16], + pub text_unit_ids: &'a [u32], + pub unicode: &'a UnicodeAnalysis, + pub styles: &'a [StyleSegment], + pub runs: &'a [ShapingRun], + pub shape: &'a ShapeArena, +} + impl ClusterArena { pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { reserve(&mut self.starts, capacity)?; @@ -39,6 +49,7 @@ impl ClusterArena { reserve(&mut self.style_indexes, capacity)?; reserve(&mut self.source_runs, capacity)?; reserve(&mut self.font_handles, capacity)?; + reserve(&mut self.stable_ids, capacity)?; reserve(&mut self.index_at, capacity.saturating_add(1))?; reserve(&mut self.shaped, capacity)?; reserve(&mut self.unsafe_before, capacity) @@ -46,14 +57,21 @@ impl ClusterArena { pub(crate) fn build( &mut self, - text: &[u16], - unicode: &UnicodeAnalysis, - styles: &[StyleSegment], - runs: &[ShapingRun], - shape: &ShapeArena, + input: ClusterBuildInput<'_>, metrics_for: impl Fn(u32) -> Option, ) -> Result<(), EngineError> { + let ClusterBuildInput { + text, + text_unit_ids, + unicode, + styles, + runs, + shape, + } = input; self.clear(); + if text.len() != text_unit_ids.len() || text_unit_ids.contains(&0) { + return Err(EngineError::InvalidRequest); + } let boundaries = unicode.grapheme_boundaries(); let count = boundaries.len().saturating_sub(1); self.reserve(text.len().max(count))?; @@ -90,6 +108,11 @@ impl ClusterArena { .push(u32::try_from(style_index).map_err(|_| EngineError::ResultTooLarge)?); self.source_runs.push(NO_SOURCE_RUN); self.font_handles.push(0); + self.stable_ids.push( + *text_unit_ids + .get(usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?, + ); self.shaped.push(0); self.unsafe_before.push(0); } @@ -107,6 +130,7 @@ impl ClusterArena { self.style_indexes.clear(); self.source_runs.clear(); self.font_handles.clear(); + self.stable_ids.clear(); self.index_at.clear(); self.shaped.clear(); self.unsafe_before.clear(); @@ -306,7 +330,17 @@ mod tests { }; let mut clusters = ClusterArena::default(); clusters - .build(&text, &unicode, &styles, &runs, &shape, metrics) + .build( + ClusterBuildInput { + text: &text, + text_unit_ids: &[1, 2, 3, 4], + unicode: &unicode, + styles: &styles, + runs: &runs, + shape: &shape, + }, + metrics, + ) .unwrap(); assert_eq!(clusters.starts, [0, 1, 2, 3]); assert_eq!(clusters.ends, [1, 2, 3, 4]); @@ -314,6 +348,7 @@ mod tests { assert_eq!(clusters.style_indexes, [0; 4]); assert_eq!(clusters.source_runs, [0, 0, 0, NO_SOURCE_RUN]); assert_eq!(clusters.font_handles, [9, 9, 9, 0]); + assert_eq!(clusters.stable_ids, [1, 2, 3, 4]); assert_eq!(clusters.index_at, [0, 1, 2, 3, 4]); assert_eq!(clusters.flags[0], CLUSTER_SAFE_BEFORE); assert_eq!( @@ -334,7 +369,17 @@ mod tests { ); shape.glyph_flags[2] = GLYPH_UNSAFE_TO_BREAK; clusters - .build(&text, &unicode, &styles, &runs, &shape, metrics) + .build( + ClusterBuildInput { + text: &text, + text_unit_ids: &[1, 2, 3, 4], + unicode: &unicode, + styles: &styles, + runs: &runs, + shape: &shape, + }, + metrics, + ) .unwrap(); assert_eq!( capacities, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 510218c1..caeafa0d 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -7,7 +7,7 @@ use crate::{ }; use super::{ - cluster_state::ClusterArena, + cluster_state::{ClusterArena, ClusterBuildInput}, flow_composition::FlowLayoutArena, flow_geometry::FlowGeometryArena, font_binding::FontRenderBinding, @@ -81,6 +81,10 @@ struct EngineSession { plan: RenderPlanCompiler, text: Vec, pending_text: Vec, + text_unit_ids: Vec, + pending_text_unit_ids: Vec, + next_text_unit_id: u32, + pending_next_text_unit_id: u32, text_prepared: bool, styles: StyleArena, pending_styles: StyleArena, @@ -337,6 +341,8 @@ impl TextEngine { .ok_or(EngineError::SessionMissing)?; reserve_text_buffer(&mut session.text, capacity)?; reserve_text_buffer(&mut session.pending_text, capacity)?; + reserve_vec(&mut session.text_unit_ids, capacity)?; + reserve_vec(&mut session.pending_text_unit_ids, capacity)?; session.unicode.reserve(capacity).map_err(unicode_error)?; session .pending_unicode @@ -700,7 +706,17 @@ impl EngineSession { if self.pending_text.try_reserve(self.text.len()).is_err() { return Err(EngineError::ResultTooLarge); } + if self + .pending_text_unit_ids + .try_reserve(self.text_unit_ids.len()) + .is_err() + { + return Err(EngineError::ResultTooLarge); + } self.pending_text.extend_from_slice(&self.text); + self.pending_text_unit_ids + .extend_from_slice(&self.text_unit_ids); + self.pending_next_text_unit_id = self.next_text_unit_id.max(1); for index in 0..mutations.len() { let Some(mutation) = mutations.get(index) else { self.abort_text(); @@ -713,6 +729,18 @@ impl EngineSession { TextMutationError::Allocation => EngineError::ResultTooLarge, }); } + if let Err(error) = apply_text_identity_mutation( + &mut self.pending_text_unit_ids, + &mut self.pending_next_text_unit_id, + mutation, + ) { + self.abort_text(); + return Err(error); + } + } + if self.pending_text.len() != self.pending_text_unit_ids.len() { + self.abort_text(); + return Err(EngineError::InvalidRequest); } self.text_prepared = true; Ok(()) @@ -720,6 +748,8 @@ impl EngineSession { fn abort_text(&mut self) { self.pending_text.clear(); + self.pending_text_unit_ids.clear(); + self.pending_next_text_unit_id = 0; self.text_prepared = false; } @@ -796,6 +826,8 @@ impl EngineSession { fn commit_text(&mut self) { if self.text_prepared { core::mem::swap(&mut self.text, &mut self.pending_text); + core::mem::swap(&mut self.text_unit_ids, &mut self.pending_text_unit_ids); + self.next_text_unit_id = self.pending_next_text_unit_id; } self.abort_text(); } @@ -1092,6 +1124,11 @@ impl EngineSession { } else { self.text.as_slice() }; + let text_unit_ids = if self.text_prepared { + self.pending_text_unit_ids.as_slice() + } else { + self.text_unit_ids.as_slice() + }; let unicode = if self.unicode_prepared { &self.pending_unicode } else { @@ -1113,11 +1150,14 @@ impl EngineSession { return Ok(()); } self.pending_clusters.build( - text, - unicode, - styles, - runs, - &self.pending_shape, + ClusterBuildInput { + text, + text_unit_ids, + unicode, + styles, + runs, + shape: &self.pending_shape, + }, |handle| shaper.font_metrics(handle), )?; self.clusters_prepared = true; @@ -1264,6 +1304,48 @@ fn apply_text_mutation( Ok(()) } +fn apply_text_identity_mutation( + identities: &mut Vec, + next_identity: &mut u32, + mutation: super::semantic_wire::TextMutation<'_>, +) -> Result<(), EngineError> { + let start = usize::try_from(mutation.text_start).map_err(|_| EngineError::InvalidRequest)?; + let delete_count = + usize::try_from(mutation.delete_count).map_err(|_| EngineError::InvalidRequest)?; + let delete_end = start + .checked_add(delete_count) + .ok_or(EngineError::InvalidRequest)?; + let insert_count = mutation.insert_utf16_le.len() / 2; + let old_len = identities.len(); + let new_len = old_len + .checked_sub(delete_count) + .and_then(|length| length.checked_add(insert_count)) + .ok_or(EngineError::InvalidRequest)?; + if delete_end > old_len { + return Err(EngineError::InvalidRequest); + } + if new_len > old_len { + identities + .try_reserve(new_len - old_len) + .map_err(|_| EngineError::ResultTooLarge)?; + identities.resize(new_len, 0); + } + identities.copy_within(delete_end..old_len, start + insert_count); + if new_len < old_len { + identities.truncate(new_len); + } + for identity in &mut identities[start..start + insert_count] { + if *next_identity == 0 { + return Err(EngineError::RevisionExhausted); + } + *identity = *next_identity; + *next_identity = next_identity + .checked_add(1) + .ok_or(EngineError::RevisionExhausted)?; + } + Ok(()) +} + fn reserve_text_buffer(text: &mut Vec, capacity: usize) -> Result<(), EngineError> { if text.capacity() < capacity { text.try_reserve_exact(capacity.saturating_sub(text.len())) @@ -1692,6 +1774,7 @@ mod tests { assert!(engine.session_text(4).unwrap().is_empty()); engine.commit_update(prepared).unwrap(); assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); + assert_eq!(engine.sessions.get(&4).unwrap().text_unit_ids, [1, 2, 3, 4]); assert_eq!( engine .sessions @@ -1702,7 +1785,7 @@ mod tests { &[0, 1, 2, 3, 4] ); - let edit_bytes = text_mutation_bytes(&[(1, 2, &[0x58, 0x59]), (4, 0, &[0x21])]); + let edit_bytes = text_mutation_bytes(&[(1, 1, &[0x58, 0x59]), (5, 0, &[0x21])]); let edit_batch = parse_text_mutations(&edit_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); let mut edit = update(1, 1, 1); @@ -1710,12 +1793,19 @@ mod tests { let prepared = engine.prepare_update(edit, 2).unwrap(); engine.abort_update(prepared).unwrap(); assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!(session.text_unit_ids, [1, 2, 3, 4]); + assert_eq!(session.next_text_unit_id, 5); let retry = engine.prepare_update(edit, 2).unwrap(); engine.commit_update(retry).unwrap(); assert_eq!( engine.session_text(4).unwrap(), - &[0x61, 0x58, 0x59, 0x64, 0x21] + &[0x61, 0x58, 0x59, 0x63, 0x64, 0x21] + ); + assert_eq!( + engine.sessions.get(&4).unwrap().text_unit_ids, + [1, 5, 6, 3, 4, 7] ); let settled_capacities = { @@ -1730,6 +1820,7 @@ mod tests { let prepared = engine.prepare_update(warm, 3).unwrap(); engine.commit_update(prepared).unwrap(); let session = engine.sessions.get(&4).unwrap(); + assert_eq!(session.text_unit_ids, [8, 5, 6, 3, 4, 7]); assert_eq!( [session.pending_text.capacity(), session.text.capacity()], settled_capacities From 07e28a4e5ef679cb2c9344c160ecbc3d06a235dc Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 16:17:25 -0400 Subject: [PATCH 040/128] feat(text): index shaped glyphs by cluster --- docs/packages/text.md | 6 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 6 ++ .../rust/shaper/src/engine/cluster_state.rs | 75 ++++++++++++++++++- 4 files changed, 83 insertions(+), 5 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index b738a682..1cc92d27 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:b025aa70a32177a27b8c205fc169225cdb07037571dca571fe94b32feb7f1504' +source_digest: 'sha256:19c16a5470c147e9c55f3094b5c97d4958489c8f662a5bad56e9017fdb670778' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -172,7 +172,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-08T10:15:42Z' + at: '2026-08-08T20:16:21Z' --- # Package reference: `@pmndrs/text` @@ -683,6 +683,8 @@ Production horizontal frame updates now retain line and fragment arrays derived Stable render identity begins with a transactional ID parallel to every retained UTF-16 unit. Ordered replacements preserve IDs for unchanged units even when earlier insertions shift their offsets; inserted units receive monotonic nonzero IDs, and abort/retry restores the allocator deterministically. Graphemes inherit their first unit ID. Per-glyph identity and revision still follow before plan publication. +Cluster construction now emits a flat logical-cluster-to-shaped-glyph adjacency alongside measured advances. One count, prefix-sum, and fill pass groups glyph indexes without changing HarfRust's run-local order, so an RTL-shaped `[2,1,0]` stream resolves to logical cluster slices `[2]`, `[1]`, `[0]`. Active and pending arrays reuse their high-water capacities. The optimized shaper is 1,043,289 raw, 394,074 gzip, and 304,902 Brotli bytes at this checkpoint. Stable glyph allocation, positioning, and nonempty plan publication remain open. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 9046aff4..77e35a54 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -258,6 +258,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-191 | Bounded simple polygons resolve through retained allocation-reusing sweep scratch. Region bands intersect normalized cross-sections at every in-band vertex boundary and within each linear-edge slab; polygon exclusions conservatively project vertices and band-edge intersections over the margin-expanded block range before applying the declared wrap side. Horizontal boundary segments participate before interval normalization. Exact fixtures cover a serialized triangle, a concave U region yielding `[0..40, 60..100]`, a diamond exclusion yielding `[0..20, 60..100]`, and explicit `ResultTooLarge` when two normalized slots exceed a one-slot envelope. The kernel is not yet called by frame line placement, so no production Wasm-size or complete-path timing claim is made. | Accepted | | D-192 | Production horizontal frame layout now consumes retained clusters and flow geometry into transactional line/fragment A/B arrays. A session-global slot workspace remains allocation-free after its declared high water mark. A band composes every available slot on one baseline, derives ascent/descent/line-height from each cluster's actual fallback font and resolved style in `f64`, and retries at most once with the maximum metrics found by the first widest pass. Enlarging the band conservatively intersects region slots and expands exclusion projection, so the retry consumes a subset rather than exposing later styles. Exact tests produce two fragments around a hole, raise a 10 px estimate to a 20 px line/16 px baseline, and overflow sequentially through region IDs `[1, 2, 2, 2]` without balancing. Rebuilt frame ABI and real-font/fallback integration tests pass. Optimized Wasm changes from 1,016,720 / 384,593 / 297,049 to 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes. Vertical flow, final positioning, boundary reshaping, nonempty plan output, and complete-path timing remain open. | Accepted | | D-193 | Stable render identity starts with transactional UTF-16 unit IDs, not mutable offsets or collision-prone content hashes. A/B unit-ID arrays undergo the same ordered replacements as text; only inserted units receive monotonic nonzero IDs, and abort restores both committed IDs and the allocator cursor so retry is deterministic. Each grapheme inherits its first unit's ID. An exact fixture preserves `[1,2,3,4]` through abort, then grows an edit to `[1,5,6,3,4,7]`, proving that the shifted `c/d` suffix retains IDs `3/4`; a later first-unit replacement yields `[8,5,6,3,4,7]`. Optimized Wasm changes from 1,039,404 / 392,671 / 303,705 to 1,041,582 / 393,214 / 304,815 raw/gzip/Brotli bytes. Glyph-range allocation, per-glyph revision, positioning, and nonempty plan output remain open. | Accepted | +| D-194 | Logical clusters retain flat shaped-glyph adjacency built by count, prefix sum, and fill. This preserves each shaped run's glyph order while allowing line positioning to traverse cluster slices directly, including RTL output; no per-glyph object, search, or map enters the hot path. An exact fixture maps shaped clusters `[2,1,0]` to logical glyph-index slices `[2]`, `[1]`, `[0]`, and rebuilding preserves every adjacency-array capacity. Optimized Wasm changes from 1,041,582 / 393,214 / 304,815 to 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes. Stable glyph allocation and positioning remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 49d0fce2..f603587d 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -950,6 +950,12 @@ content revision can change. An exact abort/retry fixture keeps `[1,2,3,4]`, the 304,815 raw/gzip/Brotli bytes (+2,178 / +543 / +1,110). Per-cluster glyph ranges, glyph identity/revision, positioning, and nonempty plans remain open. +Each cluster now owns a slice of one flat shaped-glyph index array. Construction counts glyphs by logical cluster, +prefix-sums those counts, then fills the slices while retaining HarfRust's run-local glyph order. This makes RTL and +multi-glyph clusters directly traversable without a per-glyph search, object, or map and reuses all arrays at their high +water mark. An exact reverse-order fixture maps shaped cluster stream `[2,1,0]` to logical slices `[2]`, `[1]`, `[0]`. +Optimized Wasm is 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes (+1,707 / +860 / +87). + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index 8892d140..1a65ce3e 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -26,6 +26,9 @@ pub(crate) struct ClusterArena { pub source_runs: Vec, pub font_handles: Vec, pub stable_ids: Vec, + pub glyph_starts: Vec, + pub glyph_counts: Vec, + pub glyph_indices: Vec, pub index_at: Vec, pub(super) shaped: Vec, pub(super) unsafe_before: Vec, @@ -50,6 +53,9 @@ impl ClusterArena { reserve(&mut self.source_runs, capacity)?; reserve(&mut self.font_handles, capacity)?; reserve(&mut self.stable_ids, capacity)?; + reserve(&mut self.glyph_starts, capacity)?; + reserve(&mut self.glyph_counts, capacity)?; + reserve(&mut self.glyph_indices, capacity.saturating_mul(2))?; reserve(&mut self.index_at, capacity.saturating_add(1))?; reserve(&mut self.shaped, capacity)?; reserve(&mut self.unsafe_before, capacity) @@ -113,6 +119,8 @@ impl ClusterArena { .get(usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?) .ok_or(EngineError::InvalidRequest)?, ); + self.glyph_starts.push(0); + self.glyph_counts.push(0); self.shaped.push(0); self.unsafe_before.push(0); } @@ -131,6 +139,9 @@ impl ClusterArena { self.source_runs.clear(); self.font_handles.clear(); self.stable_ids.clear(); + self.glyph_starts.clear(); + self.glyph_counts.clear(); + self.glyph_indices.clear(); self.index_at.clear(); self.shaped.clear(); self.unsafe_before.clear(); @@ -158,6 +169,11 @@ impl ClusterArena { shape: &ShapeArena, metrics_for: impl Fn(u32) -> Option, ) -> Result<(), EngineError> { + if shape.glyph_ids.len() != shape.clusters.len() { + return Err(EngineError::InvalidRequest); + } + reserve(&mut self.glyph_indices, shape.glyph_ids.len())?; + self.glyph_indices.resize(shape.glyph_ids.len(), 0); for shaped_run in &shape.runs { let source_index = usize::try_from(shaped_run.source_run).map_err(|_| EngineError::InvalidRequest)?; @@ -192,6 +208,9 @@ impl ClusterArena { return Err(EngineError::InvalidRequest); } self.shaped[cluster_index] = 1; + self.glyph_counts[cluster_index] = self.glyph_counts[cluster_index] + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; self.unsafe_before[cluster_index] |= u8::from( shape .glyph_flags @@ -208,6 +227,47 @@ impl ClusterArena { ) * scale; } } + let mut glyph_start = 0_u32; + for index in 0..self.glyph_starts.len() { + self.glyph_starts[index] = glyph_start; + glyph_start = glyph_start + .checked_add(self.glyph_counts[index]) + .ok_or(EngineError::ResultTooLarge)?; + self.glyph_counts[index] = 0; + } + if usize::try_from(glyph_start).ok() != Some(shape.glyph_ids.len()) { + return Err(EngineError::InvalidRequest); + } + for shaped_run in &shape.runs { + let start = usize::try_from(shaped_run.glyph_start) + .map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in start..end { + let cluster = *shape + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let cluster_index = self.cluster_at(cluster)?; + let ordinal = self.glyph_counts[cluster_index]; + let destination = self.glyph_starts[cluster_index] + .checked_add(ordinal) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::ResultTooLarge)?; + *self + .glyph_indices + .get_mut(destination) + .ok_or(EngineError::InvalidRequest)? = + u32::try_from(glyph).map_err(|_| EngineError::ResultTooLarge)?; + self.glyph_counts[cluster_index] = ordinal + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + } + } for index in 0..self.starts.len() { if self.shaped[index] != 0 && self.unsafe_before[index] == 0 { self.flags[index] |= CLUSTER_SAFE_BEFORE; @@ -312,8 +372,8 @@ mod tests { glyph_start: 0, glyph_count: 3, }], - glyph_ids: vec![1, 2, 3], - clusters: vec![0, 1, 2], + glyph_ids: vec![3, 2, 1], + clusters: vec![2, 1, 0], x_advances: vec![500, 250, 500], y_advances: vec![0; 3], x_offsets: vec![0; 3], @@ -349,6 +409,9 @@ mod tests { assert_eq!(clusters.source_runs, [0, 0, 0, NO_SOURCE_RUN]); assert_eq!(clusters.font_handles, [9, 9, 9, 0]); assert_eq!(clusters.stable_ids, [1, 2, 3, 4]); + assert_eq!(clusters.glyph_starts, [0, 1, 2, 3]); + assert_eq!(clusters.glyph_counts, [1, 1, 1, 0]); + assert_eq!(clusters.glyph_indices, [2, 1, 0]); assert_eq!(clusters.index_at, [0, 1, 2, 3, 4]); assert_eq!(clusters.flags[0], CLUSTER_SAFE_BEFORE); assert_eq!( @@ -365,9 +428,12 @@ mod tests { clusters.starts.capacity(), clusters.advances.capacity(), clusters.flags.capacity(), + clusters.glyph_starts.capacity(), + clusters.glyph_counts.capacity(), + clusters.glyph_indices.capacity(), clusters.index_at.capacity(), ); - shape.glyph_flags[2] = GLYPH_UNSAFE_TO_BREAK; + shape.glyph_flags[0] = GLYPH_UNSAFE_TO_BREAK; clusters .build( ClusterBuildInput { @@ -387,6 +453,9 @@ mod tests { clusters.starts.capacity(), clusters.advances.capacity(), clusters.flags.capacity(), + clusters.glyph_starts.capacity(), + clusters.glyph_counts.capacity(), + clusters.glyph_indices.capacity(), clusters.index_at.capacity(), ) ); From b3003dca43f8e149dce2c86a99f8de3560be5a5d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 16:21:29 -0400 Subject: [PATCH 041/128] refactor(text): share exact identity index --- docs/packages/text.md | 6 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 7 + .../rust/shaper/src/engine/identity_index.rs | 139 ++++++++++++++++++ packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/stable_pool.rs | 97 +++--------- 6 files changed, 176 insertions(+), 75 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/identity_index.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index 1cc92d27..dc68d028 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:19c16a5470c147e9c55f3094b5c97d4958489c8f662a5bad56e9017fdb670778' +source_digest: 'sha256:c4dfe1a2d98c2cb4ec0c9695c1db0b7083681fbf49feeda101dd4303136999df' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -172,7 +172,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-08T20:16:21Z' + at: '2026-08-08T20:20:37Z' --- # Package reference: `@pmndrs/text` @@ -685,6 +685,8 @@ Stable render identity begins with a transactional ID parallel to every retained Cluster construction now emits a flat logical-cluster-to-shaped-glyph adjacency alongside measured advances. One count, prefix-sum, and fill pass groups glyph indexes without changing HarfRust's run-local order, so an RTL-shaped `[2,1,0]` stream resolves to logical cluster slices `[2]`, `[1]`, `[0]`. Active and pending arrays reuse their high-water capacities. The optimized shaper is 1,043,289 raw, 394,074 gzip, and 304,902 Brotli bytes at this checkpoint. Stable glyph allocation, positioning, and nonempty plan publication remain open. +The stable GPU slot pool's exact open-addressed identity lookup is now one reusable epoch-cleared component for both plan storage and the upcoming cluster/glyph reconciliation. Hashes select probe positions only; full `u32` equality decides every match. A collision fixture proves distinct keys remain distinct, duplicate insertion is rejected, and a same-capacity prepare clears logically without reallocating. The refactor alone measures 1,043,094 raw, 394,035 gzip, and 307,259 Brotli bytes; the compressed regression is retained because removing a second hot-path identity-table implementation is the stronger invariant. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 77e35a54..028c0183 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -259,6 +259,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-192 | Production horizontal frame layout now consumes retained clusters and flow geometry into transactional line/fragment A/B arrays. A session-global slot workspace remains allocation-free after its declared high water mark. A band composes every available slot on one baseline, derives ascent/descent/line-height from each cluster's actual fallback font and resolved style in `f64`, and retries at most once with the maximum metrics found by the first widest pass. Enlarging the band conservatively intersects region slots and expands exclusion projection, so the retry consumes a subset rather than exposing later styles. Exact tests produce two fragments around a hole, raise a 10 px estimate to a 20 px line/16 px baseline, and overflow sequentially through region IDs `[1, 2, 2, 2]` without balancing. Rebuilt frame ABI and real-font/fallback integration tests pass. Optimized Wasm changes from 1,016,720 / 384,593 / 297,049 to 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes. Vertical flow, final positioning, boundary reshaping, nonempty plan output, and complete-path timing remain open. | Accepted | | D-193 | Stable render identity starts with transactional UTF-16 unit IDs, not mutable offsets or collision-prone content hashes. A/B unit-ID arrays undergo the same ordered replacements as text; only inserted units receive monotonic nonzero IDs, and abort restores both committed IDs and the allocator cursor so retry is deterministic. Each grapheme inherits its first unit's ID. An exact fixture preserves `[1,2,3,4]` through abort, then grows an edit to `[1,5,6,3,4,7]`, proving that the shifted `c/d` suffix retains IDs `3/4`; a later first-unit replacement yields `[8,5,6,3,4,7]`. Optimized Wasm changes from 1,039,404 / 392,671 / 303,705 to 1,041,582 / 393,214 / 304,815 raw/gzip/Brotli bytes. Glyph-range allocation, per-glyph revision, positioning, and nonempty plan output remain open. | Accepted | | D-194 | Logical clusters retain flat shaped-glyph adjacency built by count, prefix sum, and fill. This preserves each shaped run's glyph order while allowing line positioning to traverse cluster slices directly, including RTL output; no per-glyph object, search, or map enters the hot path. An exact fixture maps shaped clusters `[2,1,0]` to logical glyph-index slices `[2]`, `[1]`, `[0]`, and rebuilding preserves every adjacency-array capacity. Optimized Wasm changes from 1,041,582 / 393,214 / 304,815 to 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes. Stable glyph allocation and positioning remain open. | Accepted | +| D-195 | The stable plan pool and glyph reconciliation share one retained exact identity index. Its open-address hash chooses only a probe position; full-key equality decides matches, duplicate keys fail, and epoch clearing makes same-capacity prepare allocation-free. A collision fixture proves these properties. The isolated refactor changes optimized Wasm from 1,043,289 / 394,074 / 304,902 to 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes. The +2,357 Brotli regression is accepted to avoid a second correctness implementation and will be remeasured once glyph reconciliation makes the shared consumer reachable. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index f603587d..9206c859 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -956,6 +956,13 @@ multi-glyph clusters directly traversable without a per-glyph search, object, or water mark. An exact reverse-order fixture maps shaped cluster stream `[2,1,0]` to logical slices `[2]`, `[1]`, `[0]`. Optimized Wasm is 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes (+1,707 / +860 / +87). +The plan pool's allocation-reusing identity lookup is now a shared exact `u32 -> u32` scratch component. Epoch clearing +avoids zeroing the table on every prepare, open addressing resolves probe collisions with full-key equality, and growth +occurs only beyond the retained high water mark. This extraction prevents glyph reconciliation from carrying a second +identity-table implementation. Its isolated size is 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes (-195 / -39 / ++2,357); the Brotli regression is accepted for the single correctness implementation and will be remeasured with its +glyph-reconciliation consumer reachable. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/identity_index.rs b/packages/text/rust/shaper/src/engine/identity_index.rs new file mode 100644 index 00000000..e1cdbcde --- /dev/null +++ b/packages/text/rust/shaper/src/engine/identity_index.rs @@ -0,0 +1,139 @@ +//! Reusable exact-identity lookup scratch with epoch-based clearing. + +use alloc::vec::Vec; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum IdentityIndexError { + AllocationFailed, + ArithmeticOverflow, + DuplicateIdentity, +} + +#[derive(Default)] +pub(crate) struct IdentityIndex { + keys: Vec, + values: Vec, + epochs: Vec, + epoch: u32, +} + +impl IdentityIndex { + pub(crate) fn prepare(&mut self, entry_count: usize) -> Result<(), IdentityIndexError> { + let required = entry_count + .checked_mul(2) + .and_then(usize::checked_next_power_of_two) + .ok_or(IdentityIndexError::ArithmeticOverflow)? + .max(8); + if self.keys.len() < required { + let additional_keys = required - self.keys.len(); + let additional_values = required - self.values.len(); + let additional_epochs = required - self.epochs.len(); + reserve(&mut self.keys, additional_keys)?; + reserve(&mut self.values, additional_values)?; + reserve(&mut self.epochs, additional_epochs)?; + self.keys.resize(required, 0); + self.values.resize(required, 0); + self.epochs.resize(required, 0); + } + self.epoch = next_epoch(&mut self.epochs, self.epoch); + Ok(()) + } + + pub(crate) fn insert( + &mut self, + identity: u32, + value: u32, + ) -> Result<(), IdentityIndexError> { + let index = self.insert_position(identity)?; + if self.epochs[index] == self.epoch { + return Err(IdentityIndexError::DuplicateIdentity); + } + self.epochs[index] = self.epoch; + self.keys[index] = identity; + self.values[index] = value; + Ok(()) + } + + pub(crate) fn get(&self, identity: u32) -> Option { + let mask = self.keys.len().checked_sub(1)?; + let mut index = hash(identity) & mask; + loop { + if self.epochs[index] != self.epoch { + return None; + } + if self.keys[index] == identity { + return Some(self.values[index]); + } + index = (index + 1) & mask; + } + } + + #[cfg(test)] + pub(crate) fn capacities(&self) -> [usize; 3] { + [ + self.keys.capacity(), + self.values.capacity(), + self.epochs.capacity(), + ] + } + + fn insert_position(&self, identity: u32) -> Result { + let mask = self + .keys + .len() + .checked_sub(1) + .ok_or(IdentityIndexError::ArithmeticOverflow)?; + let mut index = hash(identity) & mask; + loop { + if self.epochs[index] != self.epoch || self.keys[index] == identity { + return Ok(index); + } + index = (index + 1) & mask; + } + } +} + +fn next_epoch(values: &mut [u32], current: u32) -> u32 { + match current.checked_add(1) { + Some(epoch) => epoch, + None => { + values.fill(0); + 1 + } + } +} + +fn hash(identity: u32) -> usize { + identity.wrapping_mul(0x9e37_79b1) as usize +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<(), IdentityIndexError> { + values + .try_reserve(additional) + .map_err(|_| IdentityIndexError::AllocationFailed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn collisions_remain_exact_and_prepare_reuses_storage() { + let mut index = IdentityIndex::default(); + index.prepare(3).unwrap(); + let capacities = index.capacities(); + index.insert(1, 10).unwrap(); + index.insert(9, 90).unwrap(); + assert_eq!(index.get(1), Some(10)); + assert_eq!(index.get(9), Some(90)); + assert_eq!(index.get(17), None); + assert_eq!( + index.insert(1, 11), + Err(IdentityIndexError::DuplicateIdentity) + ); + + index.prepare(3).unwrap(); + assert_eq!(index.capacities(), capacities); + assert_eq!(index.get(1), None); + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 098b5d59..e46da2b6 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -8,6 +8,7 @@ mod cluster_state; mod flow_composition; #[cfg_attr(not(test), allow(dead_code))] mod flow_geometry; +mod identity_index; pub mod font_binding; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod font_binding_wire; diff --git a/packages/text/rust/shaper/src/engine/stable_pool.rs b/packages/text/rust/shaper/src/engine/stable_pool.rs index 98fca5f7..b20b3a98 100644 --- a/packages/text/rust/shaper/src/engine/stable_pool.rs +++ b/packages/text/rust/shaper/src/engine/stable_pool.rs @@ -2,6 +2,8 @@ use alloc::vec::Vec; +use super::identity_index::{IdentityIndex, IdentityIndexError}; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SlotIdentity { pub stable_id: u32, @@ -47,10 +49,7 @@ pub struct StableSlotPool { assignments: Vec, retired_slots: Vec, allocated_free_slots: Vec, - identity_keys: Vec, - identity_slots: Vec, - identity_epochs: Vec, - identity_epoch: u32, + identity_index: IdentityIndex, seen_slots: Vec, seen_epoch: u32, pending_slot_count: u32, @@ -230,6 +229,7 @@ impl StableSlotPool { #[cfg(test)] pub fn scratch_capacities(&self) -> [usize; 10] { + let identity = self.identity_index.capacities(); [ self.slots.capacity(), self.free_slots.capacity(), @@ -237,9 +237,9 @@ impl StableSlotPool { self.assignments.capacity(), self.retired_slots.capacity(), self.allocated_free_slots.capacity(), - self.identity_keys.capacity(), - self.identity_slots.capacity(), - self.identity_epochs.capacity(), + identity[0], + identity[1], + identity[2], self.seen_slots.capacity(), ] } @@ -259,26 +259,10 @@ impl StableSlotPool { .count(); let required = live_count .checked_add(desired_count) - .and_then(|count| count.checked_mul(2)) - .and_then(usize::checked_next_power_of_two) - .unwrap_or(usize::MAX) - .max(8); - if required == usize::MAX { - return Err(StablePoolError::ArithmeticOverflow); - } - if self.identity_keys.len() < required { - let additional_keys = required - self.identity_keys.len(); - let additional_slots = required - self.identity_slots.len(); - let additional_epochs = required - self.identity_epochs.len(); - reserve(&mut self.identity_keys, additional_keys)?; - reserve(&mut self.identity_slots, additional_slots)?; - reserve(&mut self.identity_epochs, additional_epochs)?; - self.identity_keys.resize(required, 0); - self.identity_slots.resize(required, 0); - self.identity_epochs.resize(required, 0); - } - self.identity_epoch = next_epoch(&mut self.identity_epochs, self.identity_epoch); - Ok(()) + .ok_or(StablePoolError::ArithmeticOverflow)?; + self.identity_index + .prepare(required) + .map_err(identity_index_error) } fn prepare_seen_slots(&mut self) -> Result<(), StablePoolError> { @@ -296,56 +280,19 @@ impl StableSlotPool { identity: u32, slot: u32, ) -> Result<(), StablePoolError> { - let index = self.identity_insert_position(identity)?; - if self.identity_epochs[index] == self.identity_epoch { - return Err(StablePoolError::DuplicateIdentity); - } - self.identity_epochs[index] = self.identity_epoch; - self.identity_keys[index] = identity; - self.identity_slots[index] = slot; - Ok(()) + self.identity_index + .insert(identity, slot) + .map_err(identity_index_error) } fn insert_new_identity(&mut self, identity: u32, slot: u32) -> Result<(), StablePoolError> { - let index = self.identity_insert_position(identity)?; - if self.identity_epochs[index] == self.identity_epoch { - return Err(StablePoolError::DuplicateIdentity); - } - self.identity_epochs[index] = self.identity_epoch; - self.identity_keys[index] = identity; - self.identity_slots[index] = slot; - Ok(()) + self.identity_index + .insert(identity, slot) + .map_err(identity_index_error) } fn find_identity(&self, identity: u32) -> Option { - let mask = self.identity_keys.len() - 1; - let mut index = hash(identity) & mask; - loop { - if self.identity_epochs[index] != self.identity_epoch { - return None; - } - if self.identity_keys[index] == identity { - return Some(self.identity_slots[index]); - } - index = (index + 1) & mask; - } - } - - fn identity_insert_position(&self, identity: u32) -> Result { - let mask = self - .identity_keys - .len() - .checked_sub(1) - .ok_or(StablePoolError::ArithmeticOverflow)?; - let mut index = hash(identity) & mask; - loop { - if self.identity_epochs[index] != self.identity_epoch - || self.identity_keys[index] == identity - { - return Ok(index); - } - index = (index + 1) & mask; - } + self.identity_index.get(identity) } fn allocate_slot(&mut self) -> Result { @@ -392,8 +339,12 @@ fn next_epoch(values: &mut [u32], current: u32) -> u32 { } } -fn hash(identity: u32) -> usize { - identity.wrapping_mul(0x9e37_79b1) as usize +fn identity_index_error(error: IdentityIndexError) -> StablePoolError { + match error { + IdentityIndexError::AllocationFailed => StablePoolError::AllocationFailed, + IdentityIndexError::ArithmeticOverflow => StablePoolError::ArithmeticOverflow, + IdentityIndexError::DuplicateIdentity => StablePoolError::DuplicateIdentity, + } } fn reserve(values: &mut Vec, additional: usize) -> Result<(), StablePoolError> { From a877a7c57b9c24cbfdb7ea0d5546c3a2be625232 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 16:27:30 -0400 Subject: [PATCH 042/128] feat(text): retain stable glyph identities --- docs/packages/text.md | 6 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 7 ++ .../rust/shaper/src/engine/cluster_state.rs | 113 ++++++++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 20 ++++ 5 files changed, 145 insertions(+), 2 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index dc68d028..ed24bad4 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:c4dfe1a2d98c2cb4ec0c9695c1db0b7083681fbf49feeda101dd4303136999df' +source_digest: 'sha256:3940f4a4ee7da518d5da84e7943f6a4d025d3b6fe3790b52168fbcd4c112f7e0' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -172,7 +172,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-08T20:20:37Z' + at: '2026-08-08T20:24:50Z' --- # Package reference: `@pmndrs/text` @@ -687,6 +687,8 @@ Cluster construction now emits a flat logical-cluster-to-shaped-glyph adjacency The stable GPU slot pool's exact open-addressed identity lookup is now one reusable epoch-cleared component for both plan storage and the upcoming cluster/glyph reconciliation. Hashes select probe positions only; full `u32` equality decides every match. A collision fixture proves distinct keys remain distinct, duplicate insertion is rejected, and a same-capacity prepare clears logically without reallocating. The refactor alone measures 1,043,094 raw, 394,035 gzip, and 307,259 Brotli bytes; the compressed regression is retained because removing a second hot-path identity-table implementation is the stronger invariant. +Glyph identities now reconcile transactionally through that shared index. A cluster retaining its stable text identity keeps each surviving glyph ordinal's monotonic ID; a new cluster or additional ordinal receives a new ID, and abort discards the pending allocator cursor. An exact insertion-and-growth fixture maps committed glyph IDs `[1,2,3]` to `[4,1,3,5]`, then repeats from the same pre-update state with identical IDs and unchanged scratch capacities. The production session commits the glyph allocator with its cluster A/B swap. Optimized size is 1,044,797 raw, 395,222 gzip, and 307,795 Brotli bytes. Exact positioned-content revisions and nonempty plan output remain open. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 028c0183..cca024b8 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -260,6 +260,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-193 | Stable render identity starts with transactional UTF-16 unit IDs, not mutable offsets or collision-prone content hashes. A/B unit-ID arrays undergo the same ordered replacements as text; only inserted units receive monotonic nonzero IDs, and abort restores both committed IDs and the allocator cursor so retry is deterministic. Each grapheme inherits its first unit's ID. An exact fixture preserves `[1,2,3,4]` through abort, then grows an edit to `[1,5,6,3,4,7]`, proving that the shifted `c/d` suffix retains IDs `3/4`; a later first-unit replacement yields `[8,5,6,3,4,7]`. Optimized Wasm changes from 1,039,404 / 392,671 / 303,705 to 1,041,582 / 393,214 / 304,815 raw/gzip/Brotli bytes. Glyph-range allocation, per-glyph revision, positioning, and nonempty plan output remain open. | Accepted | | D-194 | Logical clusters retain flat shaped-glyph adjacency built by count, prefix sum, and fill. This preserves each shaped run's glyph order while allowing line positioning to traverse cluster slices directly, including RTL output; no per-glyph object, search, or map enters the hot path. An exact fixture maps shaped clusters `[2,1,0]` to logical glyph-index slices `[2]`, `[1]`, `[0]`, and rebuilding preserves every adjacency-array capacity. Optimized Wasm changes from 1,041,582 / 393,214 / 304,815 to 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes. Stable glyph allocation and positioning remain open. | Accepted | | D-195 | The stable plan pool and glyph reconciliation share one retained exact identity index. Its open-address hash chooses only a probe position; full-key equality decides matches, duplicate keys fail, and epoch clearing makes same-capacity prepare allocation-free. A collision fixture proves these properties. The isolated refactor changes optimized Wasm from 1,043,289 / 394,074 / 304,902 to 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes. The +2,357 Brotli regression is accepted to avoid a second correctness implementation and will be remeasured once glyph reconciliation makes the shared consumer reachable. | Accepted | +| D-196 | Per-glyph stable IDs reconcile transactionally by stable cluster ID and glyph ordinal. Existing ordinals retain monotonic IDs; inserted clusters and added ordinals allocate new IDs; cluster abort also aborts the allocator cursor. A fixture maps prior cluster IDs `[[1,2],[3]]` to `[[4],[1],[3,5]]` and reproduces the same result on retry without capacity growth. Positioning, not shaping, will compare exact final content and own `content_revision`. Optimized Wasm changes from 1,043,094 / 394,035 / 307,259 to 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 9206c859..88b5d6b8 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -963,6 +963,13 @@ identity-table implementation. Its isolated size is 1,043,094 / 394,035 / 307,25 +2,357); the Brotli regression is accepted for the single correctness implementation and will be remeasured with its glyph-reconciliation consumer reachable. +Glyph identity reconciliation now consumes that index inside the cluster transaction. Existing cluster identities reuse +the IDs of surviving glyph ordinals; new clusters and added ordinals consume monotonic nonzero IDs. The allocator cursor +commits only with the pending cluster arena, so abort/retry is exact. A fixture transforms prior per-cluster glyph IDs +`[[1,2],[3]]` into `[[4],[1],[3,5]]` after inserting a cluster and growing the final cluster, then reproduces the result +without growing scratch. Optimized Wasm is 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes (+1,703 / +1,187 / +536). +Final positioned-content comparison will own `content_revision`; shaping identity alone does not overclaim GPU equality. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index 1a65ce3e..8c4e21d9 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -4,6 +4,7 @@ use crate::{FontMetrics, unicode::UnicodeAnalysis}; use super::{ EngineError, + identity_index::{IdentityIndex, IdentityIndexError}, shaping_state::{ShapeArena, ShapingRun}, style_state::StyleSegment, }; @@ -29,6 +30,7 @@ pub(crate) struct ClusterArena { pub glyph_starts: Vec, pub glyph_counts: Vec, pub glyph_indices: Vec, + pub glyph_stable_ids: Vec, pub index_at: Vec, pub(super) shaped: Vec, pub(super) unsafe_before: Vec, @@ -56,6 +58,10 @@ impl ClusterArena { reserve(&mut self.glyph_starts, capacity)?; reserve(&mut self.glyph_counts, capacity)?; reserve(&mut self.glyph_indices, capacity.saturating_mul(2))?; + reserve( + &mut self.glyph_stable_ids, + capacity.saturating_mul(2), + )?; reserve(&mut self.index_at, capacity.saturating_add(1))?; reserve(&mut self.shaped, capacity)?; reserve(&mut self.unsafe_before, capacity) @@ -130,6 +136,67 @@ impl ClusterArena { Ok(()) } + pub(crate) fn assign_stable_glyph_ids( + &mut self, + previous: &Self, + index: &mut IdentityIndex, + next_id: &mut u32, + ) -> Result<(), EngineError> { + index + .prepare(previous.stable_ids.len()) + .map_err(identity_index_error)?; + for (cluster, &stable_id) in previous.stable_ids.iter().enumerate() { + index + .insert( + stable_id, + u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + reserve(&mut self.glyph_stable_ids, self.glyph_indices.len())?; + self.glyph_stable_ids.resize(self.glyph_indices.len(), 0); + *next_id = (*next_id).max(1); + for cluster in 0..self.stable_ids.len() { + let new_start = usize::try_from(self.glyph_starts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let new_count = usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let previous_cluster = index + .get(self.stable_ids[cluster]) + .and_then(|value| usize::try_from(value).ok()); + let previous_start = previous_cluster + .and_then(|cluster| previous.glyph_starts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + let previous_count = previous_cluster + .and_then(|cluster| previous.glyph_counts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + for ordinal in 0..new_count { + let stable_id = if ordinal < previous_count { + *previous + .glyph_stable_ids + .get(previous_start + ordinal) + .filter(|id| **id != 0) + .ok_or(EngineError::InvalidRequest)? + } else { + let allocated = *next_id; + *next_id = next_id + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + allocated + }; + *self + .glyph_stable_ids + .get_mut(new_start + ordinal) + .ok_or(EngineError::InvalidRequest)? = stable_id; + } + } + Ok(()) + } + pub(crate) fn clear(&mut self) { self.starts.clear(); self.ends.clear(); @@ -142,6 +209,7 @@ impl ClusterArena { self.glyph_starts.clear(); self.glyph_counts.clear(); self.glyph_indices.clear(); + self.glyph_stable_ids.clear(); self.index_at.clear(); self.shaped.clear(); self.unsafe_before.clear(); @@ -335,6 +403,15 @@ fn reserve(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { Ok(()) } +fn identity_index_error(error: IdentityIndexError) -> EngineError { + match error { + IdentityIndexError::AllocationFailed | IdentityIndexError::ArithmeticOverflow => { + EngineError::ResultTooLarge + } + IdentityIndexError::DuplicateIdentity => EngineError::InvalidRequest, + } +} + #[cfg(test)] mod tests { use super::*; @@ -462,4 +539,40 @@ mod tests { assert_eq!(clusters.flags[1], CLUSTER_SAFE_BEFORE); assert_eq!(clusters.flags[2], 0); } + + #[test] + fn stable_glyph_ids_follow_clusters_and_reuse_ordinals_transactionally() { + let previous = ClusterArena { + stable_ids: vec![10, 20], + glyph_starts: vec![0, 2], + glyph_counts: vec![2, 1], + glyph_indices: vec![0, 1, 2], + glyph_stable_ids: vec![1, 2, 3], + ..ClusterArena::default() + }; + let mut pending = ClusterArena { + stable_ids: vec![30, 10, 20], + glyph_starts: vec![0, 1, 2], + glyph_counts: vec![1, 1, 2], + glyph_indices: vec![0, 1, 2, 3], + ..ClusterArena::default() + }; + let mut index = IdentityIndex::default(); + let mut next_id = 4; + pending + .assign_stable_glyph_ids(&previous, &mut index, &mut next_id) + .unwrap(); + assert_eq!(pending.glyph_stable_ids, [4, 1, 3, 5]); + assert_eq!(next_id, 6); + + let capacities = index.capacities(); + pending.glyph_stable_ids.clear(); + next_id = 4; + pending + .assign_stable_glyph_ids(&previous, &mut index, &mut next_id) + .unwrap(); + assert_eq!(pending.glyph_stable_ids, [4, 1, 3, 5]); + assert_eq!(next_id, 6); + assert_eq!(index.capacities(), capacities); + } } diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index caeafa0d..d12ffe0d 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -10,6 +10,7 @@ use super::{ cluster_state::{ClusterArena, ClusterBuildInput}, flow_composition::FlowLayoutArena, flow_geometry::FlowGeometryArena, + identity_index::IdentityIndex, font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, policy::{CapabilitySetId, ValidatedPolicy}, @@ -100,6 +101,9 @@ struct EngineSession { pending_shape: ShapeArena, clusters: ClusterArena, pending_clusters: ClusterArena, + glyph_identity_index: IdentityIndex, + next_glyph_id: u32, + pending_next_glyph_id: u32, geometry: FlowGeometryArena, pending_geometry: FlowGeometryArena, flow_layout: FlowLayoutArena, @@ -357,6 +361,10 @@ impl TextEngine { session.pending_shape.reserve(glyph_capacity)?; session.clusters.reserve(capacity)?; session.pending_clusters.reserve(capacity)?; + session + .glyph_identity_index + .prepare(capacity) + .map_err(|_| EngineError::ResultTooLarge)?; reserve_vec(&mut session.fallback_spans, capacity)?; reserve_vec(&mut session.pending_fallback_spans, capacity)?; reserve_vec(&mut session.fallback_span_scratch, capacity)?; @@ -1146,6 +1154,7 @@ impl EngineSession { }; if runs.is_empty() { self.pending_clusters.clear(); + self.pending_next_glyph_id = self.next_glyph_id.max(1); self.clusters_prepared = true; return Ok(()); } @@ -1160,18 +1169,29 @@ impl EngineSession { }, |handle| shaper.font_metrics(handle), )?; + self.pending_next_glyph_id = self.next_glyph_id.max(1); + if let Err(error) = self.pending_clusters.assign_stable_glyph_ids( + &self.clusters, + &mut self.glyph_identity_index, + &mut self.pending_next_glyph_id, + ) { + self.abort_clusters(); + return Err(error); + } self.clusters_prepared = true; Ok(()) } fn abort_clusters(&mut self) { self.pending_clusters.clear(); + self.pending_next_glyph_id = 0; self.clusters_prepared = false; } fn commit_clusters(&mut self) { if self.clusters_prepared { core::mem::swap(&mut self.clusters, &mut self.pending_clusters); + self.next_glyph_id = self.pending_next_glyph_id; } self.abort_clusters(); } From 648158fce423e8b112b3c8fa63a5c9713367f34c Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 16:49:25 -0400 Subject: [PATCH 043/128] feat(text): emit positioned rust render plans --- docs/packages/text.md | 6 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 12 + packages/text/rust/shaper/src/abi_contract.rs | 19 +- .../shaper/src/engine/flow_composition.rs | 4 + packages/text/rust/shaper/src/engine/frame.rs | 10 + packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/positioning.rs | 771 ++++++++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 136 ++- packages/text/rust/shaper/src/lib.rs | 41 +- .../text/src/generated/text-shaper-abi.ts | 14 + .../render-plan-frame-abi.test.mjs | 14 + .../integration/shaper-registration.test.mjs | 72 +- 13 files changed, 1070 insertions(+), 31 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/positioning.rs diff --git a/docs/packages/text.md b/docs/packages/text.md index ed24bad4..0a3b7b6c 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:3940f4a4ee7da518d5da84e7943f6a4d025d3b6fe3790b52168fbcd4c112f7e0' +source_digest: 'sha256:d433667cb29362f65e26cd55240dce68699a350348e480b22a4a56ccccd7dea1' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -172,7 +172,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-08T20:24:50Z' + at: '2026-08-08T20:45:32Z' --- # Package reference: `@pmndrs/text` @@ -689,6 +689,8 @@ The stable GPU slot pool's exact open-addressed identity lookup is now one reusa Glyph identities now reconcile transactionally through that shared index. A cluster retaining its stable text identity keeps each surviving glyph ordinal's monotonic ID; a new cluster or additional ordinal receives a new ID, and abort discards the pending allocator cursor. An exact insertion-and-growth fixture maps committed glyph IDs `[1,2,3]` to `[4,1,3,5]`, then repeats from the same pre-update state with identical IDs and unchanged scratch capacities. The production session commits the glyph allocator with its cluster A/B swap. Optimized size is 1,044,797 raw, 395,222 gzip, and 307,795 Brotli bytes. Exact positioned-content revisions and nonempty plan output remain open. +Horizontal positioning now writes retained `LayoutGlyph` records and six F32/four U32 canonical semantic lanes entirely inside the frame transaction. UAX #9 L1 line resets feed allocation-reusing L2 cluster reordering; editorial slots apply start/center/end alignment or bounded space justification independently. Glyph origins use HarfRust offsets and actual selected-fallback metrics, accumulate in `f64`, include baseline shift, and narrow once. Baked font extents produce primitive bounds; absent extents still advance layout but emit no render instance. Exact float-bit and integer comparison assigns transactional content revisions through the shared identity index. A unit fixture preserves revisions `[1,2]` for a byte-identical rebuild and changes them to `[3,4]` after a one-pixel slot shift. A compiled Inter update publishes nonzero resource, buffer, patch, primitive, and draw tables through `text_update`; its identical warm successor preserves `memory.buffer` and emits zero patches. Optimized Wasm is 1,057,210 raw, 400,071 gzip, and 311,492 Brotli bytes. Complete 25,515-glyph latency is not yet measured, and vertical positioning, narrowed boundary shaping, truncation, decorations, and public renderer consumption remain open. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index cca024b8..3bb5a34e 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -261,6 +261,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-194 | Logical clusters retain flat shaped-glyph adjacency built by count, prefix sum, and fill. This preserves each shaped run's glyph order while allowing line positioning to traverse cluster slices directly, including RTL output; no per-glyph object, search, or map enters the hot path. An exact fixture maps shaped clusters `[2,1,0]` to logical glyph-index slices `[2]`, `[1]`, `[0]`, and rebuilding preserves every adjacency-array capacity. Optimized Wasm changes from 1,041,582 / 393,214 / 304,815 to 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes. Stable glyph allocation and positioning remain open. | Accepted | | D-195 | The stable plan pool and glyph reconciliation share one retained exact identity index. Its open-address hash chooses only a probe position; full-key equality decides matches, duplicate keys fail, and epoch clearing makes same-capacity prepare allocation-free. A collision fixture proves these properties. The isolated refactor changes optimized Wasm from 1,043,289 / 394,074 / 304,902 to 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes. The +2,357 Brotli regression is accepted to avoid a second correctness implementation and will be remeasured once glyph reconciliation makes the shared consumer reachable. | Accepted | | D-196 | Per-glyph stable IDs reconcile transactionally by stable cluster ID and glyph ordinal. Existing ordinals retain monotonic IDs; inserted clusters and added ordinals allocate new IDs; cluster abort also aborts the allocator cursor. A fixture maps prior cluster IDs `[[1,2],[3]]` to `[[4],[1],[3,5]]` and reproduces the same result on retry without capacity growth. Positioning, not shaping, will compare exact final content and own `content_revision`. Optimized Wasm changes from 1,043,094 / 394,035 / 307,259 to 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes. | Accepted | +| D-197 | Horizontal positioning is retained Rust state and directly feeds policy gather. UAX #9 L1/L2 visual order, slot-local alignment/justification, actual fallback metrics, HarfRust offsets, baseline shift, baked extents, and positive-down bounds execute with `f64` accumulation and one narrowing. Six F32 and four U32 semantic SoA lanes remain policy-readable. Exact final bits assign transactional content revisions; a one-pixel fixture proves no-op revision reuse and changed-content advancement. Compiled real-Inter `text_update` publishes nonempty resource/buffer/patch/primitive/draw tables, while an identical warm frame preserves Wasm memory identity and emits zero patches. Optimized Wasm changes from 1,044,797 / 395,222 / 307,795 to 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes. Complete-path 25,515-glyph timing remains unmeasured. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 88b5d6b8..81d0088c 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -970,6 +970,18 @@ commits only with the pending cluster arena, so abort/retry is exact. A fixture without growing scratch. Optimized Wasm is 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes (+1,703 / +1,187 / +536). Final positioned-content comparison will own `content_revision`; shaping identity alone does not overclaim GPU equality. +Horizontal positioning now completes the first nonempty production frame path. Reusable line-level scratch applies UAX +#9 L1 resets before L2 reorders logical cluster slices into visual order. Each editorial slot positions independently, +including direction-aware alignment and non-final-line space justification. HarfRust offsets and actual fallback-font +metrics accumulate in `f64`; baked glyph extents become positive-down primitive bounds after one `f32` narrowing, while +non-rendering glyphs advance the cursor without producing instances. Six F32 semantic lanes carry bounds, font size, +and raster ratio; four U32 lanes carry foreground, cluster, region, and flow-thread identity. Exact float bits plus all +integer and semantic fields determine a monotonic transactional `content_revision`. A unit fixture retains revisions +`[1,2]` across an identical rebuild and advances to `[3,4]` after shifting the slot one pixel. A compiled real-Inter +`text_update` publishes nonzero resource/buffer/patch/primitive/draw tables; the identical next call keeps the same Wasm +buffer and emits zero patches. Optimized Wasm is 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes (+12,413 / +4,849 / ++3,697). This proves plan reachability and minimal no-op updates, not the still-unmeasured 25,515-glyph latency target. + ## Performance contract Text does not own an 8.33 ms frame. The hard warm-update ceiling is p95 < 4.0 ms from mutation submission through a diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 59487d64..bc30d2f9 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -11,7 +11,10 @@ use crate::engine::frame::{ DECORATION_UNDERLINE, DECORATION_WAVY, DEFAULT_SESSION_TEXT_CAPACITY, EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, - OVERFLOW_VISIBLE, RESULT_FLAG_CHECKPOINT, SHAPE_POLYGON, SHAPE_RECTANGLE, + OVERFLOW_VISIBLE, RESULT_FLAG_CHECKPOINT, SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_START, + SEMANTIC_F32_FONT_SIZE, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, + SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, + SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, @@ -2521,6 +2524,20 @@ pub fn json() -> String { }, "engine": { "defaultSessionTextCapacity": DEFAULT_SESSION_TEXT_CAPACITY, + "semanticF32Fields": { + "inlineStart": SEMANTIC_F32_INLINE_START, + "blockStart": SEMANTIC_F32_BLOCK_START, + "inlineExtent": SEMANTIC_F32_INLINE_EXTENT, + "blockExtent": SEMANTIC_F32_BLOCK_EXTENT, + "fontSize": SEMANTIC_F32_FONT_SIZE, + "rasterPixelRatio": SEMANTIC_F32_RASTER_PIXEL_RATIO + }, + "semanticU32Fields": { + "foregroundRgba": SEMANTIC_U32_FOREGROUND_RGBA, + "clusterId": SEMANTIC_U32_CLUSTER_ID, + "regionId": SEMANTIC_U32_REGION_ID, + "flowThreadId": SEMANTIC_U32_FLOW_THREAD_ID + }, "textMutationOpcodes": { "replaceUtf16": TEXT_MUTATION_REPLACE_UTF16 }, diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index 43acb8fa..ad37efed 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -17,6 +17,7 @@ pub(crate) struct FlowLine { pub region_id: u32, pub fragment_start: u32, pub fragment_count: u16, + pub align: u8, pub block_start: f64, pub baseline: f64, pub height: f64, @@ -139,6 +140,7 @@ impl FlowLayoutArena { block_end, estimate, constraint.wrap, + constraint.align, max_slots_per_band, metrics_for, first_font_for_stack, @@ -167,6 +169,7 @@ impl FlowLayoutArena { region_block_end: f64, initial_extents: LineExtents, wrap: u8, + align: u8, max_slots: usize, metrics_for: impl Fn(u32) -> Option + Copy, first_font_for_stack: impl Fn(u32) -> Option + Copy, @@ -238,6 +241,7 @@ impl FlowLayoutArena { .map_err(|_| EngineError::ResultTooLarge)?, fragment_count: u16::try_from(fragment_count) .map_err(|_| EngineError::ResultTooLarge)?, + align, block_start, baseline: extents.above, height: extents.height(), diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index 3f3872e5..803cc3e8 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -63,6 +63,16 @@ pub(crate) const BASELINE_TEXT_TOP: u8 = 2; pub(crate) const BASELINE_MIDDLE: u8 = 3; pub(crate) const BASELINE_TEXT_BOTTOM: u8 = 4; pub(crate) const DEFAULT_SESSION_TEXT_CAPACITY: u32 = 1024; +pub(crate) const SEMANTIC_F32_INLINE_START: u8 = 0; +pub(crate) const SEMANTIC_F32_BLOCK_START: u8 = 1; +pub(crate) const SEMANTIC_F32_INLINE_EXTENT: u8 = 2; +pub(crate) const SEMANTIC_F32_BLOCK_EXTENT: u8 = 3; +pub(crate) const SEMANTIC_F32_FONT_SIZE: u8 = 4; +pub(crate) const SEMANTIC_F32_RASTER_PIXEL_RATIO: u8 = 5; +pub(crate) const SEMANTIC_U32_FOREGROUND_RGBA: u8 = 0; +pub(crate) const SEMANTIC_U32_CLUSTER_ID: u8 = 1; +pub(crate) const SEMANTIC_U32_REGION_ID: u8 = 2; +pub(crate) const SEMANTIC_U32_FLOW_THREAD_ID: u8 = 3; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct UpdateRequest<'a> { diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index e46da2b6..6b6b4f0a 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -31,6 +31,7 @@ pub mod plan_input; mod plan_packing; pub mod policy; pub mod policy_gather; +mod positioning; pub mod render_plan; pub mod render_plan_compiler; pub(crate) mod render_plan_wire; diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs new file mode 100644 index 00000000..b06bbd3f --- /dev/null +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -0,0 +1,771 @@ +//! Retained horizontal glyph positioning and exact content revision assignment. + +use alloc::vec::Vec; + +use crate::{FontGlyphExtents, FontMetrics, bidi::BidiAnalysis}; + +use super::{ + EngineError, + cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, + flow_composition::{FlowFragment, FlowLayoutArena, FlowLine}, + frame::{ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START}, + identity_index::{IdentityIndex, IdentityIndexError}, + policy_gather::LayoutGlyph, + shaping_state::{ShapeArena, ShapingRun}, + style_state::StyleSegment, +}; + +pub(crate) const SEMANTIC_F32_FIELD_COUNT: usize = 6; +pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 4; + +const BIDI_BN: u8 = 9; +const BIDI_B: u8 = 10; +const BIDI_S: u8 = 11; +const BIDI_WS: u8 = 12; +const BIDI_LRE: u8 = 14; +const BIDI_LRO: u8 = 15; +const BIDI_RLE: u8 = 16; +const BIDI_RLO: u8 = 17; +const BIDI_PDF: u8 = 18; +const BIDI_LRI: u8 = 19; +const BIDI_RLI: u8 = 20; +const BIDI_FSI: u8 = 21; +const BIDI_PDI: u8 = 22; + +#[derive(Default)] +pub(crate) struct PositionedGlyphArena { + glyphs: Vec, + semantic_f32: [Vec; SEMANTIC_F32_FIELD_COUNT], + semantic_u32: [Vec; SEMANTIC_U32_FIELD_COUNT], + visual_clusters: Vec, + visual_levels: Vec, + line_levels: Vec, +} + +impl PositionedGlyphArena { + pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve(&mut self.glyphs, capacity)?; + for field in &mut self.semantic_f32 { + reserve(field, capacity)?; + } + for field in &mut self.semantic_u32 { + reserve(field, capacity)?; + } + reserve(&mut self.visual_clusters, capacity)?; + reserve(&mut self.visual_levels, capacity)?; + reserve(&mut self.line_levels, capacity) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn build( + &mut self, + previous: &Self, + flow: &FlowLayoutArena, + text: &[u16], + clusters: &ClusterArena, + runs: &[ShapingRun], + shape: &ShapeArena, + styles: &[StyleSegment], + bidi: &BidiAnalysis, + identity_index: &mut IdentityIndex, + next_content_revision: &mut u32, + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result<(), EngineError> { + self.clear(); + self.reserve(shape.glyph_ids.len())?; + for (line_index, line) in flow.lines.iter().copied().enumerate() { + let fragments = line_fragments(flow, line)?; + let Some(first) = fragments.first() else { + continue; + }; + let Some(last) = fragments.last() else { + continue; + }; + prepare_line_levels( + &mut self.line_levels, + bidi, + first.line.text_start, + last.line.text_end, + )?; + let final_line = flow + .lines + .get(line_index + 1) + .is_none_or(|next| next.flow_thread_id != line.flow_thread_id); + for fragment in fragments.iter().copied() { + self.position_fragment( + line, + fragment, + final_line, + text, + clusters, + runs, + shape, + styles, + bidi, + metrics_for, + extents_for, + )?; + } + } + self.assign_content_revisions(previous, identity_index, next_content_revision) + } + + pub(crate) fn clear(&mut self) { + self.glyphs.clear(); + for field in &mut self.semantic_f32 { + field.clear(); + } + for field in &mut self.semantic_u32 { + field.clear(); + } + self.visual_clusters.clear(); + self.visual_levels.clear(); + self.line_levels.clear(); + } + + pub(crate) fn glyphs(&self) -> &[LayoutGlyph] { + &self.glyphs + } + + pub(crate) fn semantic_f32(&self) -> [&[f32]; SEMANTIC_F32_FIELD_COUNT] { + core::array::from_fn(|index| self.semantic_f32[index].as_slice()) + } + + pub(crate) fn semantic_u32(&self) -> [&[u32]; SEMANTIC_U32_FIELD_COUNT] { + core::array::from_fn(|index| self.semantic_u32[index].as_slice()) + } + + #[allow(clippy::too_many_arguments)] + fn position_fragment( + &mut self, + line: FlowLine, + fragment: FlowFragment, + final_line: bool, + text: &[u16], + clusters: &ClusterArena, + runs: &[ShapingRun], + shape: &ShapeArena, + styles: &[StyleSegment], + bidi: &BidiAnalysis, + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result<(), EngineError> { + let cluster_start = usize::try_from(fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + let cluster_end = usize::try_from(fragment.line.cluster_end) + .map_err(|_| EngineError::InvalidRequest)?; + let visual_start = self.visual_clusters.len(); + for cluster in cluster_start..cluster_end { + self.visual_clusters + .push(u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?); + self.visual_levels.push(cluster_level( + cluster, + fragment.line.text_start, + clusters, + runs, + &self.line_levels, + )?); + } + reorder_l2( + &mut self.visual_clusters, + &mut self.visual_levels, + visual_start, + ); + + let available = (fragment.slot_end - fragment.slot_start - fragment.line.advance).max(0.0); + let paragraph_level = paragraph_level_at(bidi, fragment.line.text_start); + let justify_spaces = if line.align == ALIGN_JUSTIFY + && !fragment.line.hard_break + && !final_line + { + count_justification_spaces(text, clusters, cluster_start, cluster_end) + } else { + 0 + }; + let per_space = if justify_spaces == 0 { + 0.0 + } else { + available / f64::from(justify_spaces) + }; + let offset = if per_space == 0.0 { + alignment_offset(line.align, paragraph_level, available) + } else { + 0.0 + }; + let mut cursor = fragment.slot_start + offset; + let baseline = line.block_start + line.baseline; + for visual in visual_start..self.visual_clusters.len() { + let cluster = usize::try_from(self.visual_clusters[visual]) + .map_err(|_| EngineError::InvalidRequest)?; + if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { + continue; + } + let style_index = usize::try_from(clusters.style_indexes[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style; + let font_handle = clusters.font_handles[cluster]; + let metrics = metrics_for(font_handle).ok_or(EngineError::InvalidRequest)?; + if font_handle == 0 || metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); + let cluster_origin = cursor; + let glyph_start = usize::try_from(clusters.glyph_starts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_count = usize::try_from(clusters.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + for ordinal in 0..glyph_count { + let adjacency = glyph_start + ordinal; + let shaped = usize::try_from( + *clusters + .glyph_indices + .get(adjacency) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_id = u32::from( + *shape + .glyph_ids + .get(shaped) + .ok_or(EngineError::InvalidRequest)?, + ); + let x_advance = f64::from( + shape + .x_advances + .get(shaped) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) + .abs() + * scale; + let x_offset = f64::from( + shape + .x_offsets + .get(shaped) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + let y_offset = f64::from( + shape + .y_offsets + .get(shaped) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + if let Some(extents) = extents_for(font_handle, glyph_id) { + let origin_inline = cursor + x_offset; + let origin_block = baseline - y_offset - f64::from(style.baseline_shift); + let inline_start = origin_inline + f64::from(extents.x_min) * scale; + let block_start = origin_block - f64::from(extents.y_max) * scale; + let inline_extent = f64::from(extents.x_max - extents.x_min) * scale; + let block_extent = f64::from(extents.y_max - extents.y_min) * scale; + self.push_glyph( + LayoutGlyph { + stable_id: *clusters + .glyph_stable_ids + .get(adjacency) + .ok_or(EngineError::InvalidRequest)?, + content_revision: 0, + font_handle, + glyph_id, + semantic_id: clusters.stable_ids[cluster], + material_id: style.material_id, + clip_id: line.region_id, + depth_key: 0, + font_size: style.font_size, + raster_pixel_ratio: style.raster_pixel_ratio, + inline_start: finite_f32(inline_start)?, + block_start: finite_f32(block_start)?, + inline_extent: nonnegative_f32(inline_extent)?, + block_extent: nonnegative_f32(block_extent)?, + }, + style.foreground_rgba, + clusters.stable_ids[cluster], + line.region_id, + line.flow_thread_id, + ); + } + cursor += x_advance; + } + cursor = cluster_origin + clusters.advances[cluster]; + if per_space != 0.0 && cluster_is_space(text, clusters, cluster) { + cursor += per_space; + } + } + Ok(()) + } + + fn push_glyph( + &mut self, + glyph: LayoutGlyph, + foreground: u32, + cluster: u32, + region: u32, + flow_thread: u32, + ) { + self.glyphs.push(glyph); + let f32_values = [ + glyph.inline_start, + glyph.block_start, + glyph.inline_extent, + glyph.block_extent, + glyph.font_size, + glyph.raster_pixel_ratio, + ]; + for (field, value) in self.semantic_f32.iter_mut().zip(f32_values) { + field.push(value); + } + let u32_values = [foreground, cluster, region, flow_thread]; + for (field, value) in self.semantic_u32.iter_mut().zip(u32_values) { + field.push(value); + } + } + + fn assign_content_revisions( + &mut self, + previous: &Self, + index: &mut IdentityIndex, + next_revision: &mut u32, + ) -> Result<(), EngineError> { + index + .prepare(previous.glyphs.len()) + .map_err(identity_index_error)?; + for (slot, glyph) in previous.glyphs.iter().enumerate() { + index + .insert( + glyph.stable_id, + u32::try_from(slot).map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + *next_revision = (*next_revision).max(1); + for slot in 0..self.glyphs.len() { + let previous_slot = index + .get(self.glyphs[slot].stable_id) + .and_then(|value| usize::try_from(value).ok()); + let revision = if let Some(previous_slot) = previous_slot + .filter(|&previous_slot| self.same_content(slot, previous, previous_slot)) + { + previous.glyphs[previous_slot].content_revision + } else { + let revision = *next_revision; + *next_revision = next_revision + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + revision + }; + if revision == 0 { + return Err(EngineError::ResultTooLarge); + } + self.glyphs[slot].content_revision = revision; + } + Ok(()) + } + + fn same_content(&self, slot: usize, previous: &Self, previous_slot: usize) -> bool { + let next = self.glyphs[slot]; + let old = previous.glyphs[previous_slot]; + next.stable_id == old.stable_id + && next.font_handle == old.font_handle + && next.glyph_id == old.glyph_id + && next.semantic_id == old.semantic_id + && next.material_id == old.material_id + && next.clip_id == old.clip_id + && next.depth_key == old.depth_key + && next.font_size.to_bits() == old.font_size.to_bits() + && next.raster_pixel_ratio.to_bits() == old.raster_pixel_ratio.to_bits() + && next.inline_start.to_bits() == old.inline_start.to_bits() + && next.block_start.to_bits() == old.block_start.to_bits() + && next.inline_extent.to_bits() == old.inline_extent.to_bits() + && next.block_extent.to_bits() == old.block_extent.to_bits() + && (0..SEMANTIC_F32_FIELD_COUNT).all(|field| { + self.semantic_f32[field][slot].to_bits() + == previous.semantic_f32[field][previous_slot].to_bits() + }) + && (0..SEMANTIC_U32_FIELD_COUNT).all(|field| { + self.semantic_u32[field][slot] == previous.semantic_u32[field][previous_slot] + }) + } +} + +fn line_fragments( + flow: &FlowLayoutArena, + line: FlowLine, +) -> Result<&[FlowFragment], EngineError> { + let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + flow.fragments + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + +fn prepare_line_levels( + target: &mut Vec, + bidi: &BidiAnalysis, + start: u32, + end: u32, +) -> Result<(), EngineError> { + target.clear(); + let start = usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?; + let end = usize::try_from(end).map_err(|_| EngineError::InvalidRequest)?; + let levels = bidi + .levels + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + reserve(target, levels.len())?; + target.extend_from_slice(levels); + let paragraph = paragraph_level_at(bidi, u32::try_from(start).unwrap_or(u32::MAX)); + let classes = bidi + .classes + .get(start..end) + .ok_or(EngineError::InvalidRequest)?; + let mut reset_from = Some(0usize); + let mut reset_to = None; + let mut previous_level = paragraph; + for index in 0..target.len() { + match classes[index] { + BIDI_B | BIDI_S => { + reset_to = Some(index + 1); + reset_from.get_or_insert(index); + } + BIDI_WS | BIDI_FSI | BIDI_LRI | BIDI_RLI | BIDI_PDI => { + reset_from.get_or_insert(index); + } + BIDI_RLE | BIDI_LRE | BIDI_RLO | BIDI_LRO | BIDI_PDF | BIDI_BN => { + reset_from.get_or_insert(index); + target[index] = previous_level; + } + _ => reset_from = None, + } + if let (Some(from), Some(to)) = (reset_from, reset_to) { + target[from..to].fill(paragraph); + reset_from = None; + reset_to = None; + } + previous_level = target[index]; + } + if let Some(from) = reset_from { + target[from..].fill(paragraph); + } + Ok(()) +} + +fn cluster_level( + cluster: usize, + line_start: u32, + clusters: &ClusterArena, + runs: &[ShapingRun], + line_levels: &[u8], +) -> Result { + let source = usize::try_from(clusters.source_runs[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let run = runs.get(source).ok_or(EngineError::InvalidRequest)?; + let local = clusters.starts[cluster] + .checked_sub(line_start) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::InvalidRequest)?; + let resolved = line_levels.get(local).copied().unwrap_or(run.bidi_level); + if run.style.bidi_override { + Ok(if resolved & 1 == run.direction & 1 { + resolved + } else { + resolved.saturating_add(1) + }) + } else { + Ok(resolved) + } +} + +fn reorder_l2(indices: &mut [u32], levels: &mut [u8], start: usize) { + let range = &levels[start..]; + let maximum = range.iter().copied().max().unwrap_or(0); + let Some(lowest_odd) = range.iter().copied().filter(|level| level & 1 != 0).min() else { + return; + }; + for level in (lowest_odd..=maximum).rev() { + let mut run_start = start; + while run_start < levels.len() { + while run_start < levels.len() && levels[run_start] < level { + run_start += 1; + } + let mut run_end = run_start; + while run_end < levels.len() && levels[run_end] >= level { + run_end += 1; + } + indices[run_start..run_end].reverse(); + levels[run_start..run_end].reverse(); + run_start = run_end; + } + } +} + +fn paragraph_level_at(bidi: &BidiAnalysis, offset: u32) -> u8 { + bidi.paragraph_starts + .iter() + .zip(&bidi.paragraph_ends) + .zip(&bidi.paragraph_levels) + .find_map(|((&start, &end), &level)| (start <= offset && offset < end).then_some(level)) + .or_else(|| bidi.paragraph_levels.last().copied()) + .unwrap_or(0) +} + +fn alignment_offset(align: u8, paragraph_level: u8, available: f64) -> f64 { + match align { + ALIGN_CENTER => available * 0.5, + ALIGN_END if paragraph_level & 1 == 0 => available, + ALIGN_START if paragraph_level & 1 != 0 => available, + _ => 0.0, + } +} + +fn count_justification_spaces( + text: &[u16], + clusters: &ClusterArena, + start: usize, + mut end: usize, +) -> u32 { + while end > start && cluster_is_space(text, clusters, end - 1) { + end -= 1; + } + clusters.starts[start..end] + .iter() + .filter(|&&offset| text.get(offset as usize) == Some(&0x20)) + .count() + .try_into() + .unwrap_or(u32::MAX) +} + +fn cluster_is_space(text: &[u16], clusters: &ClusterArena, cluster: usize) -> bool { + clusters + .starts + .get(cluster) + .and_then(|offset| usize::try_from(*offset).ok()) + .and_then(|offset| text.get(offset)) + == Some(&0x20) +} + +fn finite_f32(value: f64) -> Result { + let value = value as f32; + value + .is_finite() + .then_some(value) + .ok_or(EngineError::InvalidRequest) +} + +fn nonnegative_f32(value: f64) -> Result { + let value = finite_f32(value)?; + (value >= 0.0) + .then_some(value) + .ok_or(EngineError::InvalidRequest) +} + +fn identity_index_error(error: IdentityIndexError) -> EngineError { + match error { + IdentityIndexError::AllocationFailed | IdentityIndexError::ArithmeticOverflow => { + EngineError::ResultTooLarge + } + IdentityIndexError::DuplicateIdentity => EngineError::InvalidRequest, + } +} + +fn reserve(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { + if values.capacity() < capacity { + values + .try_reserve_exact(capacity.saturating_sub(values.len())) + .map_err(|_| EngineError::ResultTooLarge)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + cluster_state::CLUSTER_SAFE_BEFORE, + line_composition::ComposedLine, + style_state::ResolvedStyle, + }; + use alloc::vec; + + #[test] + fn l2_reorders_exact_levels_without_allocating() { + let mut indices = Vec::with_capacity(8); + indices.extend([0, 1, 2, 3, 4]); + let mut levels = Vec::with_capacity(8); + levels.extend([0, 1, 1, 2, 0]); + let capacities = (indices.capacity(), levels.capacity()); + reorder_l2(&mut indices, &mut levels, 0); + assert_eq!(indices, [0, 3, 2, 1, 4]); + assert_eq!(levels, [0, 2, 1, 1, 0]); + assert_eq!((indices.capacity(), levels.capacity()), capacities); + } + + #[test] + fn positions_once_and_revisions_only_exact_content_changes() { + let text = vec![0x61, 0x62]; + let style = ResolvedStyle::test_typography(10.0, 1.0, 0.0); + let styles = [StyleSegment { + text_start: 0, + text_end: 2, + style, + }]; + let runs = [ShapingRun { + text_start: 0, + text_end: 2, + script: u32::from_be_bytes(*b"Latn"), + direction: 0, + bidi_level: 0, + style, + }]; + let clusters = ClusterArena { + starts: vec![0, 1], + ends: vec![1, 2], + advances: vec![6.0, 6.0], + flags: vec![CLUSTER_SAFE_BEFORE; 2], + style_indexes: vec![0, 0], + source_runs: vec![0, 0], + font_handles: vec![1, 1], + stable_ids: vec![10, 20], + glyph_starts: vec![0, 1], + glyph_counts: vec![1, 1], + glyph_indices: vec![0, 1], + glyph_stable_ids: vec![100, 200], + index_at: vec![0, 1, 2], + ..ClusterArena::default() + }; + let shape = ShapeArena { + runs: vec![], + glyph_ids: vec![1, 2], + clusters: vec![0, 1], + x_advances: vec![500, 500], + y_advances: vec![0, 0], + x_offsets: vec![0, 0], + y_offsets: vec![0, 0], + glyph_flags: vec![0, 0], + }; + let bidi = BidiAnalysis { + levels: vec![0, 0], + classes: vec![0, 0], + paragraph_starts: vec![0], + paragraph_ends: vec![2], + paragraph_levels: vec![0], + runs: vec![], + }; + let mut flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 7, + region_id: 9, + fragment_start: 0, + fragment_count: 1, + align: ALIGN_CENTER, + block_start: 0.0, + baseline: 8.0, + height: 10.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 2, + text_start: 0, + text_end: 2, + advance: 12.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 20.0, + }], + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let extents = |_, _| { + Some(FontGlyphExtents { + x_min: 0, + y_min: 0, + x_max: 500, + y_max: 700, + }) + }; + let mut index = IdentityIndex::default(); + let mut active = PositionedGlyphArena::default(); + let mut next_revision = 1; + active + .build( + &PositionedGlyphArena::default(), + &flow, + &text, + &clusters, + &runs, + &shape, + &styles, + &bidi, + &mut index, + &mut next_revision, + metrics, + extents, + ) + .unwrap(); + assert_eq!(active.glyphs.len(), 2); + assert_eq!(active.glyphs[0].content_revision, 1); + assert_eq!(active.glyphs[1].content_revision, 2); + assert_eq!(active.glyphs[0].inline_start, 4.0); + assert_eq!(active.glyphs[1].inline_start, 10.0); + assert_eq!(active.glyphs[0].block_start, 1.0); + assert_eq!(active.semantic_u32[0], [u32::MAX, u32::MAX]); + assert_eq!(next_revision, 3); + + let mut pending = PositionedGlyphArena::default(); + pending + .build( + &active, + &flow, + &text, + &clusters, + &runs, + &shape, + &styles, + &bidi, + &mut index, + &mut next_revision, + metrics, + extents, + ) + .unwrap(); + assert_eq!(pending.glyphs[0].content_revision, 1); + assert_eq!(pending.glyphs[1].content_revision, 2); + assert_eq!(next_revision, 3); + + flow.fragments[0].slot_start = 1.0; + flow.fragments[0].slot_end = 21.0; + pending + .build( + &active, + &flow, + &text, + &clusters, + &runs, + &shape, + &styles, + &bidi, + &mut index, + &mut next_revision, + metrics, + extents, + ) + .unwrap(); + assert_eq!(pending.glyphs[0].content_revision, 3); + assert_eq!(pending.glyphs[1].content_revision, 4); + assert_eq!(next_revision, 5); + } +} diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index d12ffe0d..715754fa 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -17,6 +17,7 @@ use super::{ policy_gather::{ DEFAULT_GATHER_RECORD_CAPACITY, GatherError, LayoutPlanInput, PolicyGatherWorkspace, }, + positioning::PositionedGlyphArena, render_plan::RenderPlanView, render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, shaping_state::{ShapeArena, ShapingRunArena}, @@ -104,10 +105,14 @@ struct EngineSession { glyph_identity_index: IdentityIndex, next_glyph_id: u32, pending_next_glyph_id: u32, + next_content_revision: u32, + pending_next_content_revision: u32, geometry: FlowGeometryArena, pending_geometry: FlowGeometryArena, flow_layout: FlowLayoutArena, pending_flow_layout: FlowLayoutArena, + positioned: PositionedGlyphArena, + pending_positioned: PositionedGlyphArena, flow_slot_scratch: super::flow_geometry::InlineSlotArena, fallback_spans: Vec, pending_fallback_spans: Vec, @@ -127,6 +132,7 @@ struct EngineSession { pending_geometry_fingerprint: u64, geometry_prepared: bool, flow_layout_prepared: bool, + positioned_prepared: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -361,6 +367,8 @@ impl TextEngine { session.pending_shape.reserve(glyph_capacity)?; session.clusters.reserve(capacity)?; session.pending_clusters.reserve(capacity)?; + session.positioned.reserve(glyph_capacity)?; + session.pending_positioned.reserve(glyph_capacity)?; session .glyph_identity_index .prepare(capacity) @@ -548,32 +556,52 @@ impl TextEngine { session.abort_clusters(); return Err(error); } - if let Some(shaper) = shaper - && let Err(error) = session.prepare_flow_layout( + if let Some(shaper) = shaper { + if let Err(error) = session.prepare_flow_layout( shaper, font_stacks, request.limits.max_lines, request.limits.max_slots_per_band, - ) - { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); - session.abort_geometry(); - session.abort_flow_layout(); - return Err(error); + ) { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); + session.abort_shape(); + session.abort_clusters(); + session.abort_geometry(); + session.abort_flow_layout(); + return Err(error); + } + if let Err(error) = session.prepare_positioned(shaper) { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); + session.abort_shape(); + session.abort_clusters(); + session.abort_geometry(); + session.abort_flow_layout(); + session.abort_positioned(); + return Err(error); + } } + let positioned = if session.positioned_prepared { + &session.pending_positioned + } else { + &session.positioned + }; + let semantic_f32 = positioned.semantic_f32(); + let semantic_u32 = positioned.semantic_u32(); if let Err(error) = gather.gather( policy, CapabilitySetId(request.capability_set), LayoutPlanInput { - glyphs: &[], - semantic_f32: &[], - semantic_u32: &[], + glyphs: positioned.glyphs(), + semantic_f32: &semantic_f32, + semantic_u32: &semantic_u32, }, |handle| { font_bindings @@ -591,6 +619,7 @@ impl TextEngine { session.abort_clusters(); session.abort_geometry(); session.abort_flow_layout(); + session.abort_positioned(); return Err(gather_error(error)); } let gathered = gather.view(); @@ -611,6 +640,7 @@ impl TextEngine { session.abort_clusters(); session.abort_geometry(); session.abort_flow_layout(); + session.abort_positioned(); return Err(plan_error(error)); } Ok(PreparedUpdate { @@ -664,6 +694,7 @@ impl TextEngine { session.abort_clusters(); session.abort_geometry(); session.abort_flow_layout(); + session.abort_positioned(); Ok(()) } @@ -688,6 +719,7 @@ impl TextEngine { session.commit_clusters(); session.commit_geometry(); session.commit_flow_layout(); + session.commit_positioned(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, @@ -1282,6 +1314,76 @@ impl EngineSession { } self.abort_flow_layout(); } + + fn prepare_positioned(&mut self, shaper: &ShaperRegistry) -> Result<(), EngineError> { + self.abort_positioned(); + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + let shape = if self.shape_prepared { + &self.pending_shape + } else { + &self.shape + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let bidi = if self.bidi_prepared { + &self.pending_bidi + } else { + &self.bidi + }; + let flow = if self.flow_layout_prepared { + &self.pending_flow_layout + } else { + &self.flow_layout + }; + self.pending_next_content_revision = self.next_content_revision.max(1); + self.pending_positioned.build( + &self.positioned, + flow, + text, + clusters, + runs, + shape, + styles, + bidi, + &mut self.glyph_identity_index, + &mut self.pending_next_content_revision, + |handle| shaper.font_metrics(handle), + |handle, glyph| shaper.font_glyph_extents(handle, glyph), + )?; + self.positioned_prepared = true; + Ok(()) + } + + fn abort_positioned(&mut self) { + self.pending_positioned.clear(); + self.pending_next_content_revision = 0; + self.positioned_prepared = false; + } + + fn commit_positioned(&mut self) { + if self.positioned_prepared { + core::mem::swap(&mut self.positioned, &mut self.pending_positioned); + self.next_content_revision = self.pending_next_content_revision; + } + self.abort_positioned(); + } } fn apply_text_mutation( diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 9826318e..676ab84c 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -86,6 +86,14 @@ pub(crate) struct FontMetrics { pub line_gap: i16, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct FontGlyphExtents { + pub x_min: i32, + pub y_min: i32, + pub x_max: i32, + pub y_max: i32, +} + struct CachedPlan { key: PlanKey, plan: ShapePlan, @@ -268,6 +276,19 @@ impl ShaperRegistry { self.fonts.get(&handle).map(|font| font.metrics) } + pub(crate) fn font_glyph_extents( + &self, + handle: u32, + glyph: u32, + ) -> Option { + let font = self.fonts.get(&handle)?; + FlatExtents { + records: &font.extents, + availability: &font.availability, + } + .raw_extent_for_glyph(usize::try_from(glyph).ok()?) + } + pub fn dispose_font(&mut self, handle: u32) -> u32 { self.result.clear(); if self.fonts.remove(&handle).is_some() { @@ -738,6 +759,16 @@ impl FontFuncs for FlatExtents<'_> { impl FlatExtents<'_> { fn extent_for_glyph(&self, glyph: usize) -> Option { + let raw = self.raw_extent_for_glyph(glyph)?; + Some(GlyphExtents { + x_bearing: raw.x_min, + y_bearing: raw.y_max, + width: raw.x_max - raw.x_min, + height: raw.y_min - raw.y_max, + }) + } + + fn raw_extent_for_glyph(&self, glyph: usize) -> Option { if self .availability .get(glyph >> 3) @@ -752,11 +783,11 @@ impl FlatExtents<'_> { let y_min = i16::from_le_bytes([record[2], record[3]]) as i32; let x_max = i16::from_le_bytes([record[4], record[5]]) as i32; let y_max = i16::from_le_bytes([record[6], record[7]]) as i32; - Some(GlyphExtents { - x_bearing: x_min, - y_bearing: y_max, - width: x_max - x_min, - height: y_min - y_max, + Some(FontGlyphExtents { + x_min, + y_min, + x_max, + y_max, }) } } diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 769c6f59..22be779e 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -122,6 +122,14 @@ export const textShaperAbi = { "resource": 1, "slotRange": 3 }, + "semanticF32Fields": { + "blockExtent": 3, + "blockStart": 1, + "fontSize": 4, + "inlineExtent": 2, + "inlineStart": 0, + "rasterPixelRatio": 5 + }, "semanticKinds": { "caret": 5, "cluster": 4, @@ -131,6 +139,12 @@ export const textShaperAbi = { "run": 3, "selection": 6 }, + "semanticU32Fields": { + "clusterId": 1, + "flowThreadId": 3, + "foregroundRgba": 0, + "regionId": 2 + }, "styleFields": { "all": 8191, "baselineShift": 256, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 45e23190..73c5f3c9 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -80,6 +80,20 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.deepEqual(abi.engine.exclusionWrapSides, { both: 1, inlineEnd: 3, inlineStart: 2, largest: 4 }); assert.deepEqual(abi.engine.inlineObjectBaselines, { alphabetic: 1, middle: 3, textBottom: 4, textTop: 2 }); assert.equal(abi.engine.defaultSessionTextCapacity, 1024); + assert.deepEqual(abi.engine.semanticF32Fields, { + blockExtent: 3, + blockStart: 1, + fontSize: 4, + inlineExtent: 2, + inlineStart: 0, + rasterPixelRatio: 5, + }); + assert.deepEqual(abi.engine.semanticU32Fields, { + clusterId: 1, + flowThreadId: 3, + foregroundRgba: 0, + regionId: 2, + }); assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size, 0), abi.status.ok); assert.equal(fn.sessionCount(), 1); let requestPointer = fn.requestPointer(sessionId); diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 9b5378bb..3e7efefa 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -147,13 +147,14 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo const policy = copyToWasm(memory, fn.allocate, policyBytes); assert.equal(fn.registerPolicy(23, policy.pointer, policy.length), abi.status.ok); fn.deallocate(policy.pointer, policy.length); - assert.equal(fn.createSession(29, 512, abi.layouts.engineResult.size, 4), abi.status.ok); + assert.equal(fn.createSession(29, 2048, 64 * 1024, 4), abi.status.ok); const styleWarmBuffer = memory.buffer; const initialUpdate = engineStyleUpdateBytes(abi, { sessionId: 29, policyHandle: 23, fontStackHandle: 17, text: [0x61, 0x62, 0x63, 0x64], + geometry: true, }); assert.equal(fn.planCount(), 0); let requestPointer = fn.requestPointer(29); @@ -163,15 +164,37 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo let result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 1); + for (const field of ['resourceCount', 'bufferCount', 'patchCount', 'primitiveCount', 'drawCount']) { + assert.ok(result.getUint32(abi.layouts.engineResult[field], true) > 0, `${field} must be nonempty`); + } assert.equal(fn.planCount(), 1, 'text_update must shape retained runs through HarfRust'); - const removeRoot = engineStyleUpdateBytes(abi, { + const warmUpdate = engineStyleUpdateBytes(abi, { sessionId: 29, policyHandle: 23, fontStackHandle: 17, expectedEngineRevision: 1, consumedPlanRevision: 1, acknowledgedPublicationGeneration: 1, + textEnd: 4, + geometry: true, + }); + requestPointer = fn.requestPointer(29); + new Uint8Array(memory.buffer, requestPointer, warmUpdate.byteLength).set(warmUpdate); + resultPointer = fn.textUpdate(29, requestPointer, warmUpdate.byteLength); + assert.strictEqual(memory.buffer, styleWarmBuffer, 'the identical nonempty frame must stay allocation-free'); + result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); + assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 2); + assert.equal(result.getUint32(abi.layouts.engineResult.patchCount, true), 0); + + const removeRoot = engineStyleUpdateBytes(abi, { + sessionId: 29, + policyHandle: 23, + fontStackHandle: 17, + expectedEngineRevision: 2, + consumedPlanRevision: 2, + acknowledgedPublicationGeneration: 2, removeRoot: true, }); requestPointer = fn.requestPointer(29); @@ -180,7 +203,7 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo assert.strictEqual(memory.buffer, styleWarmBuffer, 'an invalid retained style update must not grow Wasm memory'); result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.invalidRequest); - assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 1); + assert.equal(result.getUint32(abi.layouts.engineResult.engineRevision, true), 2); assert.equal(fn.planCount(), 1, 'an aborted update must not perform another shape'); assert.equal(fn.disposeSession(29), abi.status.ok); assert.equal(fn.disposePolicy(23), abi.status.ok); @@ -295,7 +318,9 @@ function engineStyleUpdateBytes( consumedPlanRevision = 0, acknowledgedPublicationGeneration = 0, text = [], + textEnd = text.length, removeRoot = false, + geometry = false, }, ) { const request = abi.layouts.engineUpdateRequest; @@ -304,7 +329,13 @@ function engineStyleUpdateBytes( const textRecordOffset = text.length === 0 ? 0 : request.size; const styleRecordOffset = align(request.size + (text.length === 0 ? 0 : textRecord.size), styleRecord.alignment); const textPayloadOffset = styleRecordOffset + styleRecord.size; - const bytes = new Uint8Array(textPayloadOffset + text.length * 2); + const textPayloadEnd = textPayloadOffset + text.length * 2; + const constraint = abi.layouts.engineConstraint; + const region = abi.layouts.engineRegion; + const constraintOffset = geometry ? align(textPayloadEnd, constraint.alignment) : 0; + const regionOffset = geometry ? align(constraintOffset + constraint.size, region.alignment) : 0; + const byteLength = geometry ? regionOffset + region.size : textPayloadEnd; + const bytes = new Uint8Array(byteLength); const view = new DataView(bytes.buffer); view.setUint32(request.abiVersion, abi.version, true); view.setUint32(request.byteLength, bytes.byteLength, true); @@ -324,11 +355,17 @@ function engineStyleUpdateBytes( ]) { view.setUint32(request[field], field === 'maxClusters' ? 2 : 1, true); } - view.setUint32(request.maxOutputBytes, abi.layouts.engineResult.size, true); + view.setUint32(request.maxOutputBytes, 64 * 1024, true); view.setUint32(request.textMutationsOffset, textRecordOffset, true); view.setUint32(request.textMutationCount, text.length === 0 ? 0 : 1, true); view.setUint32(request.styleMutationsOffset, styleRecordOffset, true); view.setUint32(request.styleMutationCount, 1, true); + if (geometry) { + view.setUint32(request.constraintsOffset, constraintOffset, true); + view.setUint32(request.constraintCount, 1, true); + view.setUint32(request.regionsOffset, regionOffset, true); + view.setUint32(request.regionCount, 1, true); + } if (text.length > 0) { view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); @@ -353,12 +390,35 @@ function engineStyleUpdateBytes( abi.engine.styleFields.rasterPixelRatio, true, ); - view.setUint32(styleRecordOffset + styleRecord.textEnd, text.length, true); + view.setUint32(styleRecordOffset + styleRecord.textEnd, textEnd, true); view.setUint32(styleRecordOffset + styleRecord.fontStackHandle, fontStackHandle, true); view.setFloat32(styleRecordOffset + styleRecord.fontSize, 16, true); view.setFloat32(styleRecordOffset + styleRecord.lineHeight, 1.2, true); view.setFloat32(styleRecordOffset + styleRecord.rasterPixelRatio, 1, true); } + if (geometry) { + view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); + view.setFloat32(constraintOffset + constraint.width, 100, true); + view.setFloat32(constraintOffset + constraint.height, 100, true); + view.setFloat32(constraintOffset + constraint.viewportBlockEnd, 100, true); + view.setUint32(constraintOffset + constraint.maxLines, 1, true); + view.setUint16(constraintOffset + constraint.regionCount, 1, true); + view.setUint8(constraintOffset + constraint.widthMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.heightMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.wrap, abi.engine.wrapModes.word); + view.setUint8(constraintOffset + constraint.align, abi.engine.inlineAlignments.start); + view.setUint8(constraintOffset + constraint.overflow, abi.engine.overflowModes.clip); + view.setUint8(constraintOffset + constraint.blockAlign, abi.engine.blockAlignments.start); + + view.setUint32(regionOffset + region.id, 1, true); + view.setUint32(regionOffset + region.geometryRevision, 1, true); + view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); + view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); + for (const field of ['inlineEnd', 'blockEnd', 'clipInlineEnd', 'clipBlockEnd']) { + view.setFloat32(regionOffset + region[field], 100, true); + } + } return bytes; } From c347b440ce201c966f35f5a3136bc3a4eed25c2a Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 17:38:00 -0400 Subject: [PATCH 044/128] perf(text): retain exact frame invalidation --- docs/log.md | 11 + docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 11 + docs/roadmap/roadmap.md | 2 + .../rust/shaper/src/engine/cluster_state.rs | 18 +- .../shaper/src/engine/flow_composition.rs | 18 +- .../rust/shaper/src/engine/flow_geometry.rs | 2 +- .../rust/shaper/src/engine/identity_index.rs | 6 +- packages/text/rust/shaper/src/engine/mod.rs | 2 +- .../rust/shaper/src/engine/positioning.rs | 69 ++-- .../shaper/src/engine/render_plan_compiler.rs | 33 ++ .../rust/shaper/src/engine/semantic_wire.rs | 4 + packages/text/rust/shaper/src/engine/state.rs | 146 +++++---- .../rust/shaper/src/engine/style_state.rs | 117 +++++++ packages/text/rust/shaper/src/lib.rs | 6 +- .../scripts/benchmark-rust-layout-engine.mjs | 295 ++++++++++++++++++ packages/text/tests/support/engine-abi.d.mts | 31 ++ packages/text/tests/support/engine-abi.mjs | 115 +++++++ 19 files changed, 770 insertions(+), 121 deletions(-) create mode 100644 packages/text/scripts/benchmark-rust-layout-engine.mjs diff --git a/docs/log.md b/docs/log.md index 57ca9ea6..3b8b0da7 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,17 @@ ## 2026-08-08 +- **Measured exact retained Rust frame invalidation** — Style changes now invalidate bidi, shaping, metrics, and + positioning independently; exact rectangle geometry skips flow when safe; unchanged ordered-direct frames publish an + empty reuse transaction without scanning glyphs. A hard-break regression now skips the deliberately unshaped cluster + before visual-run lookup. Over 25,515 glyphs, eight warmups, and 31 samples, Rust cold/no-op/font-size/full-column + resize/suffix edit/localized edit measure 13.693/0.001/4.090/3.374/13.927/13.986 ms median and + 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are + 55.25/11.90/8.36/38.55 ms. The comparison remains provisional because the Rust lane writes one F32 policy lane rather + than Bitmap's complete five-buffer record. The optimized module is 1,060,175 / 400,500 / 316,984 + raw/gzip/Brotli bytes; the sequential 76.25 MiB process high-water mark is unresolved memory evidence, not a + per-session budget. All 192 package tests, six fuzz tests, 115 Rust unit tests, and Unicode 17 conformance pass. + - **Primary HarfRust shaping now runs inside `text_update`** — A borrowed run view lets legacy batching and the retained engine share the prewarmed UnicodeBuffer, UTF-16 context, and reusable feature scratch. Retained style payloads feed HarfRust without an owned request, and glyph SoA appends directly into a pre-reserved A/B session arena. A real-Inter diff --git a/docs/packages/text.md b/docs/packages/text.md index 0a3b7b6c..92af6547 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:d433667cb29362f65e26cd55240dce68699a350348e480b22a4a56ccccd7dea1' +source_digest: 'sha256:72ae0fb32e20551ace57d4c6eadb728666a375f3e6cab14d19503e1920b78291' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -691,6 +691,8 @@ Glyph identities now reconcile transactionally through that shared index. A clus Horizontal positioning now writes retained `LayoutGlyph` records and six F32/four U32 canonical semantic lanes entirely inside the frame transaction. UAX #9 L1 line resets feed allocation-reusing L2 cluster reordering; editorial slots apply start/center/end alignment or bounded space justification independently. Glyph origins use HarfRust offsets and actual selected-fallback metrics, accumulate in `f64`, include baseline shift, and narrow once. Baked font extents produce primitive bounds; absent extents still advance layout but emit no render instance. Exact float-bit and integer comparison assigns transactional content revisions through the shared identity index. A unit fixture preserves revisions `[1,2]` for a byte-identical rebuild and changes them to `[3,4]` after a one-pixel slot shift. A compiled Inter update publishes nonzero resource, buffer, patch, primitive, and draw tables through `text_update`; its identical warm successor preserves `memory.buffer` and emits zero patches. Optimized Wasm is 1,057,210 raw, 400,071 gzip, and 311,492 Brotli bytes. Complete 25,515-glyph latency is not yet measured, and vertical positioning, narrowed boundary shaping, truncation, decorations, and public renderer consumption remain open. +Exact retained invalidation now stops at the earliest affected Rust stage. Font-size changes reuse Unicode, bidi, and HarfRust output; exact rectangle geometry reuses flow when no inline object needs retained comparison; unchanged ordered-direct frames publish an empty plan transaction without walking glyphs. A terminal hard-break cluster is skipped before visual-run lookup, matching its deliberate absence from shaping runs. At 25,515 laid-out glyphs with eight discarded warmups and 31 samples, the complete request-copy plus `text_update` path measures 13.693/0.001/4.090/3.374/13.927/13.986 millisecond medians for cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit, with corresponding p95 values of 14.111/0.001/4.236/3.706/14.511/14.381 milliseconds. The unchanged TypeScript comparison measures 55.25/11.90/8.36/38.55 millisecond medians for cold/font-size/width/suffix-edit. These are checkpoint numbers, not final speedup claims: the Rust benchmark's current policy writes one F32 lane, while canonical Bitmap writes origins, sizes, UV origins, UV sizes, and colors. The next policy proof must run that exact five-buffer shape over real baked records. The optimized module is 1,060,175 raw / 400,500 gzip / 316,984 Brotli bytes. A 76.25 MiB process high-water mark accumulated across sequentially created and disposed stress sessions remains a memory-optimization finding, not an accepted 25K-session or default-session budget. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 3bb5a34e..10931cf9 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -262,6 +262,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-195 | The stable plan pool and glyph reconciliation share one retained exact identity index. Its open-address hash chooses only a probe position; full-key equality decides matches, duplicate keys fail, and epoch clearing makes same-capacity prepare allocation-free. A collision fixture proves these properties. The isolated refactor changes optimized Wasm from 1,043,289 / 394,074 / 304,902 to 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes. The +2,357 Brotli regression is accepted to avoid a second correctness implementation and will be remeasured once glyph reconciliation makes the shared consumer reachable. | Accepted | | D-196 | Per-glyph stable IDs reconcile transactionally by stable cluster ID and glyph ordinal. Existing ordinals retain monotonic IDs; inserted clusters and added ordinals allocate new IDs; cluster abort also aborts the allocator cursor. A fixture maps prior cluster IDs `[[1,2],[3]]` to `[[4],[1],[3,5]]` and reproduces the same result on retry without capacity growth. Positioning, not shaping, will compare exact final content and own `content_revision`. Optimized Wasm changes from 1,043,094 / 394,035 / 307,259 to 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes. | Accepted | | D-197 | Horizontal positioning is retained Rust state and directly feeds policy gather. UAX #9 L1/L2 visual order, slot-local alignment/justification, actual fallback metrics, HarfRust offsets, baseline shift, baked extents, and positive-down bounds execute with `f64` accumulation and one narrowing. Six F32 and four U32 semantic SoA lanes remain policy-readable. Exact final bits assign transactional content revisions; a one-pixel fixture proves no-op revision reuse and changed-content advancement. Compiled real-Inter `text_update` publishes nonempty resource/buffer/patch/primitive/draw tables, while an identical warm frame preserves Wasm memory identity and emits zero patches. Optimized Wasm changes from 1,044,797 / 395,222 / 307,795 to 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes. Complete-path 25,515-glyph timing remains unmeasured. | Accepted | +| D-198 | Retained frame invalidation compares exact committed and pending semantic state rather than treating every style or geometry transaction as a full pipeline change. Direction changes restart bidi; shaping inputs restart shaping; metric inputs rebuild measured clusters and flow; positioning/paint inputs rebuild positioned semantics; exact geometry equality with no inline objects skips flow; and an unchanged ordered-direct frame publishes an empty reuse transaction without scanning glyphs. The 25,515-glyph, 8-warmup/31-sample Node run measures Rust cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit at 13.693/0.001/4.090/3.374/13.927/13.986 ms median and 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are 55.25/11.90/8.36/38.55 ms. This proves the invalidation direction, not final renderer parity: the Rust lane still executes a one-F32 policy rather than canonical Bitmap's five buffers, so full Bitmap packing remains a required comparison before publishing speedup ratios. Optimized Wasm is 1,060,175 / 400,500 / 316,984 raw/gzip/Brotli bytes. The sequential benchmark process reaches 76.25 MiB after multiple disposed sessions; that is not a per-session requirement or an accepted memory target. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 81d0088c..57feefe0 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1130,6 +1130,17 @@ Each stage is a small Conventional Commit series with unchanged fixtures and an The first stack proves the Wasm boundary, policy, display-list/render-plan contract, and complete current Rust semantic pipeline before adding new publishing features. The single ABI is its final cutover, not its first commit. +Current checkpoint evidence: the retained Rust transaction reaches real Inter shaping, fallback, measured clusters, +horizontal editorial flow, positioning, policy gather, and a nonempty render plan. Exact invalidation lets unchanged +ordered-direct frames publish an empty reuse transaction and lets font-size changes reuse Unicode, bidi, and shaping. +On the established fully active 25,515-glyph stress case with eight warmups and 31 samples, request copy plus +`text_update` measures 13.693/0.001/4.090/3.374/13.927/13.986 ms median for +cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit. The unchanged TypeScript comparison measures +55.25/11.90/8.36/38.55 ms for cold/font-size/width/suffix-edit. These are not final renderer-parity ratios: the Rust lane +still packs one F32 policy output, while canonical Bitmap requires five physical buffers over real baked glyph records. +That full policy shape, memory right-sizing, incremental text edits, Three consumption, and removal of the duplicate +TypeScript path remain foundation-stack gates. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 4fbae35a..66ebe33b 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -153,6 +153,8 @@ These rows replace the former separate backlog. Each is intended to become one f | 11.13 | ⬜ | Prove the shaping and layout contract can represent a break-inserted hyphen glyph that has no source cluster, and fix the contract if it cannot. Language patterns, break selection, and justification quality controls remain later work. | M | 11.6 | | 11.14 | ⬜ | Add the professional typography the editorial showcase requires: `wordSpacing`, first-line indent, paragraph space before/after, and justification controls covering minimum/maximum word-space ratio, letter-space expansion, and last-line policy. | L | 11.12–11.13 | | 11.15 | ⬜ | Settle Three material authority, so applications supply their own `NodeMaterial` and gain lighting, shadows, and depth-composited effects without implementing a raster program. Resolve the open edges in the [material authority concept](../planning/three-material-authority.md) first; it is a recorded proposal, not an accepted design. | M | 11.6 | +| 11.16 | 🟡 | Replace duplicate TypeScript shaping, layout, packing, and dirty-plan work with one retained Rust/Wasm frame transaction, validated renderer policy, and incremental render plan; land the Rust, policy/plan, and Three adapter PRs as one coordinated stack after exact Bitmap/MSDF/Slug, benchmark-app, size, and browser parity. | XL | 11.6 | +| 11.17 | ⬜ | Complete the Rust engine's realtime publishing set over that proven path: spacing, decorations, interaction geometry, horizontal and vertical writing, one-call exclusions and sequential regions, bounded CJK tailoring, and optional color-emoji fallback, excluding every explicitly cut unbounded solver or second authored text stream. | XL | 11.16 | ## Milestone 0 — accept contracts and versions diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index 8c4e21d9..34f6827e 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -58,10 +58,7 @@ impl ClusterArena { reserve(&mut self.glyph_starts, capacity)?; reserve(&mut self.glyph_counts, capacity)?; reserve(&mut self.glyph_indices, capacity.saturating_mul(2))?; - reserve( - &mut self.glyph_stable_ids, - capacity.saturating_mul(2), - )?; + reserve(&mut self.glyph_stable_ids, capacity.saturating_mul(2))?; reserve(&mut self.index_at, capacity.saturating_add(1))?; reserve(&mut self.shaped, capacity)?; reserve(&mut self.unsafe_before, capacity) @@ -183,9 +180,7 @@ impl ClusterArena { .ok_or(EngineError::InvalidRequest)? } else { let allocated = *next_id; - *next_id = next_id - .checked_add(1) - .ok_or(EngineError::ResultTooLarge)?; + *next_id = next_id.checked_add(1).ok_or(EngineError::ResultTooLarge)?; allocated }; *self @@ -307,8 +302,8 @@ impl ClusterArena { return Err(EngineError::InvalidRequest); } for shaped_run in &shape.runs { - let start = usize::try_from(shaped_run.glyph_start) - .map_err(|_| EngineError::InvalidRequest)?; + let start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; let end = start .checked_add( usize::try_from(shaped_run.glyph_count) @@ -331,9 +326,8 @@ impl ClusterArena { .get_mut(destination) .ok_or(EngineError::InvalidRequest)? = u32::try_from(glyph).map_err(|_| EngineError::ResultTooLarge)?; - self.glyph_counts[cluster_index] = ordinal - .checked_add(1) - .ok_or(EngineError::ResultTooLarge)?; + self.glyph_counts[cluster_index] = + ordinal.checked_add(1).ok_or(EngineError::ResultTooLarge)?; } } for index in 0..self.starts.len() { diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index ad37efed..04615f2f 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -54,6 +54,18 @@ pub(crate) struct FlowLayoutArena { } impl FlowLayoutArena { + pub(crate) fn reserve( + &mut self, + line_capacity: usize, + max_slots_per_band: usize, + ) -> Result<(), EngineError> { + reserve(&mut self.lines, line_capacity)?; + reserve( + &mut self.fragments, + line_capacity.saturating_mul(max_slots_per_band), + ) + } + #[allow(clippy::too_many_arguments)] pub(crate) fn build( &mut self, @@ -70,11 +82,7 @@ impl FlowLayoutArena { if clusters.starts.is_empty() || geometry.constraints.is_empty() { return Ok(()); } - reserve(&mut self.lines, max_lines)?; - reserve( - &mut self.fragments, - max_lines.saturating_mul(max_slots_per_band), - )?; + self.reserve(max_lines, max_slots_per_band)?; for constraint in geometry.constraints.iter().copied() { let resume = cluster_for_offset(clusters, constraint.resume_cluster)?; let mut cursor = LineCursor::at_cluster(resume); diff --git a/packages/text/rust/shaper/src/engine/flow_geometry.rs b/packages/text/rust/shaper/src/engine/flow_geometry.rs index b9b548ee..92dd8a92 100644 --- a/packages/text/rust/shaper/src/engine/flow_geometry.rs +++ b/packages/text/rust/shaper/src/engine/flow_geometry.rs @@ -36,7 +36,7 @@ pub(crate) struct RetainedExclusion { pub vertex_start: u32, } -#[derive(Default)] +#[derive(Default, PartialEq)] pub(crate) struct FlowGeometryArena { pub constraints: Vec, pub regions: Vec, diff --git a/packages/text/rust/shaper/src/engine/identity_index.rs b/packages/text/rust/shaper/src/engine/identity_index.rs index e1cdbcde..542fa37f 100644 --- a/packages/text/rust/shaper/src/engine/identity_index.rs +++ b/packages/text/rust/shaper/src/engine/identity_index.rs @@ -39,11 +39,7 @@ impl IdentityIndex { Ok(()) } - pub(crate) fn insert( - &mut self, - identity: u32, - value: u32, - ) -> Result<(), IdentityIndexError> { + pub(crate) fn insert(&mut self, identity: u32, value: u32) -> Result<(), IdentityIndexError> { let index = self.insert_position(identity)?; if self.epochs[index] == self.epoch { return Err(IdentityIndexError::DuplicateIdentity); diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index 6b6b4f0a..cc902cf7 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -8,7 +8,6 @@ mod cluster_state; mod flow_composition; #[cfg_attr(not(test), allow(dead_code))] mod flow_geometry; -mod identity_index; pub mod font_binding; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod font_binding_wire; @@ -16,6 +15,7 @@ pub(crate) mod font_binding_wire; pub(crate) mod frame; #[cfg_attr(not(any(target_arch = "wasm32", test)), allow(dead_code))] pub(crate) mod frame_wire; +mod identity_index; #[cfg(feature = "kernel-lab")] #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod kernel_lab; diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index b06bbd3f..b45d84fa 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -153,10 +153,13 @@ impl PositionedGlyphArena { ) -> Result<(), EngineError> { let cluster_start = usize::try_from(fragment.line.cluster_start) .map_err(|_| EngineError::InvalidRequest)?; - let cluster_end = usize::try_from(fragment.line.cluster_end) - .map_err(|_| EngineError::InvalidRequest)?; + let cluster_end = + usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; let visual_start = self.visual_clusters.len(); for cluster in cluster_start..cluster_end { + if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { + continue; + } self.visual_clusters .push(u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?); self.visual_levels.push(cluster_level( @@ -175,14 +178,12 @@ impl PositionedGlyphArena { let available = (fragment.slot_end - fragment.slot_start - fragment.line.advance).max(0.0); let paragraph_level = paragraph_level_at(bidi, fragment.line.text_start); - let justify_spaces = if line.align == ALIGN_JUSTIFY - && !fragment.line.hard_break - && !final_line - { - count_justification_spaces(text, clusters, cluster_start, cluster_end) - } else { - 0 - }; + let justify_spaces = + if line.align == ALIGN_JUSTIFY && !fragment.line.hard_break && !final_line { + count_justification_spaces(text, clusters, cluster_start, cluster_end) + } else { + 0 + }; let per_space = if justify_spaces == 0 { 0.0 } else { @@ -392,10 +393,7 @@ impl PositionedGlyphArena { } } -fn line_fragments( - flow: &FlowLayoutArena, - line: FlowLine, -) -> Result<&[FlowFragment], EngineError> { +fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; let end = start .checked_add(usize::from(line.fragment_count)) @@ -463,8 +461,8 @@ fn cluster_level( runs: &[ShapingRun], line_levels: &[u8], ) -> Result { - let source = usize::try_from(clusters.source_runs[cluster]) - .map_err(|_| EngineError::InvalidRequest)?; + let source = + usize::try_from(clusters.source_runs[cluster]).map_err(|_| EngineError::InvalidRequest)?; let run = runs.get(source).ok_or(EngineError::InvalidRequest)?; let local = clusters.starts[cluster] .checked_sub(line_start) @@ -587,8 +585,7 @@ fn reserve(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { mod tests { use super::*; use crate::engine::{ - cluster_state::CLUSTER_SAFE_BEFORE, - line_composition::ComposedLine, + cluster_state::CLUSTER_SAFE_BEFORE, line_composition::ComposedLine, style_state::ResolvedStyle, }; use alloc::vec; @@ -608,11 +605,11 @@ mod tests { #[test] fn positions_once_and_revisions_only_exact_content_changes() { - let text = vec![0x61, 0x62]; + let text = vec![0x61, 0x62, 0x0a]; let style = ResolvedStyle::test_typography(10.0, 1.0, 0.0); let styles = [StyleSegment { text_start: 0, - text_end: 2, + text_end: 3, style, }]; let runs = [ShapingRun { @@ -624,19 +621,19 @@ mod tests { style, }]; let clusters = ClusterArena { - starts: vec![0, 1], - ends: vec![1, 2], - advances: vec![6.0, 6.0], - flags: vec![CLUSTER_SAFE_BEFORE; 2], - style_indexes: vec![0, 0], - source_runs: vec![0, 0], - font_handles: vec![1, 1], - stable_ids: vec![10, 20], - glyph_starts: vec![0, 1], - glyph_counts: vec![1, 1], + starts: vec![0, 1, 2], + ends: vec![1, 2, 3], + advances: vec![6.0, 6.0, 0.0], + flags: vec![CLUSTER_SAFE_BEFORE, CLUSTER_SAFE_BEFORE, CLUSTER_HARD_BREAK], + style_indexes: vec![0, 0, 0], + source_runs: vec![0, 0, u32::MAX], + font_handles: vec![1, 1, 0], + stable_ids: vec![10, 20, 30], + glyph_starts: vec![0, 1, 2], + glyph_counts: vec![1, 1, 0], glyph_indices: vec![0, 1], glyph_stable_ids: vec![100, 200], - index_at: vec![0, 1, 2], + index_at: vec![0, 1, 2, 3], ..ClusterArena::default() }; let shape = ShapeArena { @@ -650,10 +647,10 @@ mod tests { glyph_flags: vec![0, 0], }; let bidi = BidiAnalysis { - levels: vec![0, 0], - classes: vec![0, 0], + levels: vec![0, 0, BIDI_B], + classes: vec![0, 0, BIDI_B], paragraph_starts: vec![0], - paragraph_ends: vec![2], + paragraph_ends: vec![3], paragraph_levels: vec![0], runs: vec![], }; @@ -671,11 +668,11 @@ mod tests { fragments: vec![FlowFragment { line: ComposedLine { cluster_start: 0, - cluster_end: 2, + cluster_end: 3, text_start: 0, text_end: 2, advance: 12.0, - hard_break: false, + hard_break: true, }, slot_start: 0.0, slot_end: 20.0, diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index 3d236b1f..d2af7442 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -165,6 +165,15 @@ impl Default for RenderPlanCompiler { } impl RenderPlanCompiler { + pub(crate) fn prepare_reuse(&mut self) -> Result<(), RenderPlanCompilerError> { + if self.prepared_strategy != PreparedStrategy::None { + return Err(RenderPlanCompilerError::AlreadyPrepared); + } + self.clear_merged_plan(); + self.prepared_strategy = PreparedStrategy::Empty; + Ok(()) + } + #[allow(clippy::too_many_arguments)] pub fn prepare( &mut self, @@ -676,6 +685,30 @@ mod tests { assert_eq!(plan.draws.len(), 1); } + #[test] + fn acknowledged_ordered_state_can_publish_an_empty_reuse_transaction() { + let policy = policy(); + let glyphs = [glyph(1, ORDERED, 0)]; + let x = [1.0]; + let mut compiler = RenderPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &x, true, 1, 0); + let buffer = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .buffers[0] + .id; + compiler.commit().unwrap(); + + compiler.prepare_reuse().unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert!(plan.buffers.is_empty()); + assert!(plan.patches.is_empty()); + compiler.commit().unwrap(); + assert!(compiler.buffer_bytes(buffer).is_some()); + } + #[test] fn mixed_frames_preserve_global_draw_order_and_disjoint_buffer_namespaces() { let policy = policy(); diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 57c3b260..8b48f3d8 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -198,6 +198,10 @@ impl GeometryBatch<'_> { self.constraints.len() / abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize } + pub(crate) fn inline_object_count(self) -> usize { + self.inline_objects.len() / abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize + } + pub(crate) fn constraint(self, index: usize) -> Option { let record = record_at(self.constraints, abi::ENGINE_CONSTRAINT_RECORD_SIZE, index)?; Some(FlowConstraint { diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 715754fa..b9aac9d5 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -10,10 +10,10 @@ use super::{ cluster_state::{ClusterArena, ClusterBuildInput}, flow_composition::FlowLayoutArena, flow_geometry::FlowGeometryArena, - identity_index::IdentityIndex, font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, - policy::{CapabilitySetId, ValidatedPolicy}, + identity_index::IdentityIndex, + policy::{ALLOCATION_ORDERED_DIRECT, CapabilitySetId, ValidatedPolicy}, policy_gather::{ DEFAULT_GATHER_RECORD_CAPACITY, GatherError, LayoutPlanInput, PolicyGatherWorkspace, }, @@ -23,6 +23,7 @@ use super::{ shaping_state::{ShapeArena, ShapingRunArena}, style_state::{ DEFAULT_STYLE_CAPACITY, MutationKey, ResolutionScope, ResolvedStyleArena, StyleArena, + StyleInvalidation, }, }; @@ -123,6 +124,7 @@ struct EngineSession { style_nesting_scratch: Vec, style_resolution_scratch: Vec, styles_prepared: bool, + style_invalidation: StyleInvalidation, unicode_prepared: bool, bidi_prepared: bool, shaping_runs_prepared: bool, @@ -367,11 +369,13 @@ impl TextEngine { session.pending_shape.reserve(glyph_capacity)?; session.clusters.reserve(capacity)?; session.pending_clusters.reserve(capacity)?; + session.flow_layout.reserve(capacity, 1)?; + session.pending_flow_layout.reserve(capacity, 1)?; session.positioned.reserve(glyph_capacity)?; session.pending_positioned.reserve(glyph_capacity)?; session .glyph_identity_index - .prepare(capacity) + .prepare(glyph_capacity) .map_err(|_| EngineError::ResultTooLarge)?; reserve_vec(&mut session.fallback_spans, capacity)?; reserve_vec(&mut session.pending_fallback_spans, capacity)?; @@ -556,13 +560,19 @@ impl TextEngine { session.abort_clusters(); return Err(error); } + let flow_changed = session.clusters_prepared + || session.geometry_prepared + || session.style_invalidation.metrics; + let positioned_changed = flow_changed || session.style_invalidation.positioning; if let Some(shaper) = shaper { - if let Err(error) = session.prepare_flow_layout( - shaper, - font_stacks, - request.limits.max_lines, - request.limits.max_slots_per_band, - ) { + if flow_changed + && let Err(error) = session.prepare_flow_layout( + shaper, + font_stacks, + request.limits.max_lines, + request.limits.max_slots_per_band, + ) + { session.abort_text(); session.abort_styles(); session.abort_unicode(); @@ -574,7 +584,7 @@ impl TextEngine { session.abort_flow_layout(); return Err(error); } - if let Err(error) = session.prepare_positioned(shaper) { + if positioned_changed && let Err(error) = session.prepare_positioned(shaper) { session.abort_text(); session.abort_styles(); session.abort_unicode(); @@ -588,49 +598,60 @@ impl TextEngine { return Err(error); } } - let positioned = if session.positioned_prepared { - &session.pending_positioned + let reuse_ordered_plan = !checkpoint + && !positioned_changed + && policy + .programs() + .iter() + .all(|program| program.allocation_strategy == ALLOCATION_ORDERED_DIRECT); + let plan_result = if reuse_ordered_plan { + session.plan.prepare_reuse() } else { - &session.positioned + let positioned = if session.positioned_prepared { + &session.pending_positioned + } else { + &session.positioned + }; + let semantic_f32 = positioned.semantic_f32(); + let semantic_u32 = positioned.semantic_u32(); + if let Err(error) = gather.gather( + policy, + CapabilitySetId(request.capability_set), + LayoutPlanInput { + glyphs: positioned.glyphs(), + semantic_f32: &semantic_f32, + semantic_u32: &semantic_u32, + }, + |handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| &binding.binding) + }, + ) { + session.abort_text(); + session.abort_styles(); + session.abort_unicode(); + session.abort_bidi(); + session.abort_shaping_runs(); + session.abort_shape(); + session.abort_clusters(); + session.abort_geometry(); + session.abort_flow_layout(); + session.abort_positioned(); + return Err(gather_error(error)); + } + let gathered = gather.view(); + session.plan.prepare( + policy, + CapabilitySetId(request.capability_set), + gathered.plan_input(), + checkpoint, + publication_generation, + request.acknowledged_publication_generation, + ) }; - let semantic_f32 = positioned.semantic_f32(); - let semantic_u32 = positioned.semantic_u32(); - if let Err(error) = gather.gather( - policy, - CapabilitySetId(request.capability_set), - LayoutPlanInput { - glyphs: positioned.glyphs(), - semantic_f32: &semantic_f32, - semantic_u32: &semantic_u32, - }, - |handle| { - font_bindings - .iter() - .find(|binding| binding.handle == handle) - .map(|binding| &binding.binding) - }, - ) { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); - session.abort_geometry(); - session.abort_flow_layout(); - session.abort_positioned(); - return Err(gather_error(error)); - } - let gathered = gather.view(); - if let Err(error) = session.plan.prepare( - policy, - CapabilitySetId(request.capability_set), - gathered.plan_input(), - checkpoint, - publication_generation, - request.acknowledged_publication_generation, - ) { + if let Err(error) = plan_result { session.abort_text(); session.abort_styles(); session.abort_unicode(); @@ -841,6 +862,11 @@ impl EngineSession { self.abort_styles(); return Err(error); } + self.style_invalidation = self.resolved_styles.invalidation_against( + &self.styles, + &self.pending_resolved_styles, + &self.pending_styles, + ); self.styles_prepared = true; Ok(()) } @@ -853,6 +879,7 @@ impl EngineSession { self.style_nesting_scratch.clear(); self.style_resolution_scratch.clear(); self.styles_prepared = false; + self.style_invalidation = StyleInvalidation::default(); } fn commit_styles(&mut self) { @@ -897,7 +924,7 @@ impl EngineSession { fn prepare_bidi(&mut self) -> Result<(), EngineError> { self.abort_bidi(); - if !self.text_prepared && !self.styles_prepared { + if !self.text_prepared && !self.style_invalidation.bidi { return Ok(()); } let text = if self.text_prepared { @@ -932,7 +959,7 @@ impl EngineSession { fn prepare_shaping_runs(&mut self) -> Result<(), EngineError> { self.abort_shaping_runs(); - if !self.text_prepared && !self.styles_prepared { + if !self.text_prepared && !self.style_invalidation.shaping && !self.bidi_prepared { return Ok(()); } let text = if self.text_prepared { @@ -1156,7 +1183,7 @@ impl EngineSession { fn prepare_clusters(&mut self, shaper: &ShaperRegistry) -> Result<(), EngineError> { self.abort_clusters(); - if !self.shape_prepared { + if !self.shape_prepared && !self.style_invalidation.metrics { return Ok(()); } let text = if self.text_prepared { @@ -1197,7 +1224,11 @@ impl EngineSession { unicode, styles, runs, - shape: &self.pending_shape, + shape: if self.shape_prepared { + &self.pending_shape + } else { + &self.shape + }, }, |handle| shaper.font_metrics(handle), )?; @@ -1243,6 +1274,11 @@ impl EngineSession { .map_err(|_| EngineError::InvalidRequest)?; self.pending_geometry.build(geometry)?; self.pending_geometry_fingerprint = geometry.fingerprint(); + if geometry.inline_object_count() == 0 && self.pending_geometry == self.geometry { + self.pending_geometry.clear(); + self.pending_geometry_fingerprint = 0; + return Ok(()); + } self.geometry_prepared = true; Ok(()) } diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs index d71a1b68..f3d2de4e 100644 --- a/packages/text/rust/shaper/src/engine/style_state.rs +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -104,6 +104,14 @@ pub(crate) struct ResolutionScope { style: ResolvedStyle, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) struct StyleInvalidation { + pub bidi: bool, + pub shaping: bool, + pub metrics: bool, + pub positioning: bool, +} + #[derive(Default)] pub(crate) struct ResolvedStyleArena { segments: Vec, @@ -161,6 +169,58 @@ impl ResolvedStyleArena { pub(crate) fn segments(&self) -> &[StyleSegment] { &self.segments } + + pub(crate) fn invalidation_against( + &self, + previous_storage: &StyleArena, + next: &Self, + next_storage: &StyleArena, + ) -> StyleInvalidation { + if self.segments.len() != next.segments.len() { + return StyleInvalidation { + bidi: true, + shaping: true, + metrics: true, + positioning: true, + }; + } + let mut invalidation = StyleInvalidation::default(); + for (previous, next) in self.segments.iter().zip(&next.segments) { + if previous.text_start != next.text_start || previous.text_end != next.text_end { + return StyleInvalidation { + bidi: true, + shaping: true, + metrics: true, + positioning: true, + }; + } + let old = previous.style; + let new = next.style; + invalidation.bidi |= + old.direction != new.direction || old.bidi_override != new.bidi_override; + invalidation.shaping |= invalidation.bidi + || old.font_stack_handle != new.font_stack_handle + || previous_storage.resolved_language(old) != next_storage.resolved_language(new) + || previous_storage.resolved_features(old) != next_storage.resolved_features(new); + invalidation.metrics |= invalidation.shaping + || old.font_size.to_bits() != new.font_size.to_bits() + || old.line_height.to_bits() != new.line_height.to_bits() + || old.has_line_height != new.has_line_height + || old.letter_spacing.to_bits() != new.letter_spacing.to_bits() + || old.word_spacing.to_bits() != new.word_spacing.to_bits() + || old.baseline_shift.to_bits() != new.baseline_shift.to_bits(); + invalidation.positioning |= invalidation.metrics + || old.material_id != new.material_id + || old.raster_pixel_ratio.to_bits() != new.raster_pixel_ratio.to_bits() + || old.foreground_rgba != new.foreground_rgba + || old.decoration_rgba != new.decoration_rgba + || old.decoration_flags != new.decoration_flags + || old.decoration_style != new.decoration_style + || old.decoration_thickness.to_bits() != new.decoration_thickness.to_bits() + || old.decoration_offset.to_bits() != new.decoration_offset.to_bits(); + } + invalidation + } } impl StyleArena { @@ -739,6 +799,63 @@ mod tests { assert_eq!(resolved.segments()[3].style.material_id, 0); } + #[test] + fn invalidation_stops_at_the_first_affected_retained_stage() { + let storage = StyleArena::default(); + let previous = resolved(ResolvedStyle::default()); + + assert_eq!( + previous.invalidation_against(&storage, &previous, &storage), + StyleInvalidation::default(), + ); + + let mut font_size = ResolvedStyle::default(); + font_size.font_size = 24.0; + assert_eq!( + previous.invalidation_against(&storage, &resolved(font_size), &storage), + StyleInvalidation { + bidi: false, + shaping: false, + metrics: true, + positioning: true, + }, + ); + + let mut foreground = ResolvedStyle::default(); + foreground.foreground_rgba = 0xff00_00ff; + assert_eq!( + previous.invalidation_against(&storage, &resolved(foreground), &storage), + StyleInvalidation { + bidi: false, + shaping: false, + metrics: false, + positioning: true, + }, + ); + + let mut direction = ResolvedStyle::default(); + direction.direction = 2; + assert_eq!( + previous.invalidation_against(&storage, &resolved(direction), &storage), + StyleInvalidation { + bidi: true, + shaping: true, + metrics: true, + positioning: true, + }, + ); + } + + fn resolved(style: ResolvedStyle) -> ResolvedStyleArena { + ResolvedStyleArena { + segments: vec![StyleSegment { + text_start: 0, + text_end: 8, + style, + }], + } + } + fn style( style_id: u32, cascade_order: u32, diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 676ab84c..92828619 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -276,11 +276,7 @@ impl ShaperRegistry { self.fonts.get(&handle).map(|font| font.metrics) } - pub(crate) fn font_glyph_extents( - &self, - handle: u32, - glyph: u32, - ) -> Option { + pub(crate) fn font_glyph_extents(&self, handle: u32, glyph: u32) -> Option { let font = self.fonts.get(&handle)?; FlatExtents { records: &font.extents, diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs new file mode 100644 index 00000000..e3d16193 --- /dev/null +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -0,0 +1,295 @@ +/* @workflow { + "name": "text:rust-layout-benchmark", + "summary": "Measures the complete retained Rust text_update path with real font data and render-plan publication.", + "requirements": "Built @pmndrs/text and @pmndrs/text-font-baker packages. Accepts --glyphs, --reps, and --warmup.", + "writes": "stdout only" +} */ +import { readFile } from 'node:fs/promises'; + +import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; + +import { paragraphTextForGlyphs } from './support/paragraph-benchmark-fixture.mts'; +import { + copyIntoAllocation, + engineFrameUpdateBytes, + fontBindingBytes, + renderPolicyBytes, +} from '../tests/support/engine-abi.mjs'; + +const options = parseArguments(process.argv.slice(2)); +const sessionId = 1; +const policyHandle = 1; +const fontHandle = 1; +const fontStackHandle = 1; +const outputCapacity = 4 * 1024 * 1024; +const regionHeight = options.height; + +const [wasm, abi, artifact] = await Promise.all([ + readFile(new URL('../dist/text_shaper.wasm', import.meta.url)), + readFile(new URL('../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8').then(JSON.parse), + readFile(new URL('../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url)), +]); +const validated = await validateFontArtifact(artifact); +const instance = await WebAssembly.instantiate(await WebAssembly.compile(wasm), {}); +const memory = instance.exports[abi.memory]; +const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), +); +const memoryAtInstantiation = memory.buffer.byteLength; +requireStatus(fn.initialize(), 'initialize'); +const memoryAfterInitialize = memory.buffer.byteLength; +registerFont(); +registerBinding(); +registerStack(); +registerPolicy(); +const memoryAfterRegistration = memory.buffer.byteLength; + +const text = paragraphTextForGlyphs(options.glyphs); +const utf16 = stringToUtf16(text); +const limits = { + maxClusters: utf16.length + 1, + maxLines: utf16.length + 1, + maxOutputBytes: outputCapacity, +}; +const baseGeometry = { width: 600, height: regionHeight, maxLines: utf16.length + 1, revision: 1 }; +const baseStyle = { textEnd: utf16.length, fontSize: 24, lineHeight: 1.2, rasterPixelRatio: 1 }; +const initial = updateBytes({ + textMutation: { start: 0, deleteCount: 0, insert: utf16 }, + style: baseStyle, + geometry: baseGeometry, +}); +let sessionMemory; + +console.log( + `memory bytes: instantiate=${memoryAtInstantiation}, initialize=${memoryAfterInitialize}, registered=${memoryAfterRegistration}`, +); + +const reports = []; +reports.push(measureCold()); +for (const name of ['no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit']) { + reports.push(measureWarm(name)); +} +printReport(reports); + +function measureCold() { + const samples = []; + let glyphs = 0; + for (let index = 0; index < options.warmup + options.repetitions; index += 1) { + createSession(initial.byteLength); + const result = execute(initial, true); + glyphs = result.primitiveCount; + if (index >= options.warmup) samples.push(result.durationMs); + requireStatus(fn.disposeSession(sessionId), 'dispose cold session'); + } + return summarize('cold', glyphs, samples); +} + +function measureWarm(name) { + createSession(initial.byteLength); + let state = execute(initial, true); + const livePrimitiveCount = state.primitiveCount; + const localizedText = [...utf16]; + let suffixLength = utf16.length; + const samples = []; + for (let index = 0; index < options.warmup + options.repetitions; index += 1) { + const revision = index + 2; + const common = { + expectedEngineRevision: state.engineRevision, + consumedPlanRevision: state.planRevision, + acknowledgedPublicationGeneration: state.publicationGeneration, + }; + let bytes; + if (name === 'font-size') { + bytes = updateBytes({ + ...common, + style: { ...baseStyle, fontSize: 12 + index * 0.5 }, + geometry: baseGeometry, + }); + } else if (name === 'column-resize') { + bytes = updateBytes({ + ...common, + geometry: { ...baseGeometry, width: 420 + index * 7, revision }, + }); + } else if (name === 'suffix-edit') { + const nextLength = utf16.length - index; + const deleteCount = suffixLength - nextLength; + bytes = + deleteCount === 0 + ? updateBytes({ ...common, geometry: baseGeometry }) + : updateBytes({ + ...common, + textMutation: { start: nextLength, deleteCount, insert: [] }, + style: { ...baseStyle, textEnd: nextLength }, + geometry: baseGeometry, + }); + suffixLength = nextLength; + } else if (name === 'localized-edit') { + const start = Math.floor(utf16.length / 2) + index; + const replacement = localizedText[start] === 0x61 ? 0x62 : 0x61; + localizedText[start] = replacement; + bytes = updateBytes({ + ...common, + textMutation: { start, deleteCount: 1, insert: [replacement] }, + geometry: baseGeometry, + }); + } else { + bytes = updateBytes({ ...common, geometry: baseGeometry }); + } + state = execute(bytes, index < options.warmup, `${name}[${index}]`); + if (index >= options.warmup) samples.push(state.durationMs); + } + requireStatus(fn.disposeSession(sessionId), `dispose ${name} session`); + return summarize(name, livePrimitiveCount, samples); +} + +function createSession(requestCapacity) { + const beforeBytes = memory.buffer.byteLength; + requireStatus( + fn.createSession(sessionId, requestCapacity, outputCapacity, utf16.length + 1), + 'create benchmark session', + ); + if (sessionMemory === undefined) { + sessionMemory = { beforeBytes, afterBytes: memory.buffer.byteLength }; + } +} + +function execute(bytes, allowGrowth = false, operation = 'text_update') { + const requestPointer = fn.requestPointer(sessionId); + if (requestPointer === 0 || fn.requestCapacity(sessionId) < bytes.byteLength) { + throw new Error('benchmark request exceeds its pre-reserved arena'); + } + const buffer = memory.buffer; + const bufferBytes = buffer.byteLength; + const started = performance.now(); + new Uint8Array(buffer, requestPointer, bytes.byteLength).set(bytes); + const resultPointer = fn.textUpdate(sessionId, requestPointer, bytes.byteLength); + const durationMs = performance.now() - started; + if (memory.buffer !== buffer && !allowGrowth) { + throw new Error(`measured text_update grew Wasm memory from ${bufferBytes} to ${memory.buffer.byteLength} bytes`); + } + if (resultPointer === 0) throw new Error('text_update returned a null result'); + const layout = abi.layouts.engineResult; + const result = new DataView(memory.buffer, resultPointer, layout.size); + requireStatus(result.getUint32(layout.status, true), operation); + return { + durationMs, + engineRevision: result.getUint32(layout.engineRevision, true), + planRevision: result.getUint32(layout.planRevision, true), + publicationGeneration: result.getUint32(layout.publicationGeneration, true), + primitiveCount: result.getUint32(layout.primitiveCount, true), + patchCount: result.getUint32(layout.patchCount, true), + }; +} + +function updateBytes(fields) { + return engineFrameUpdateBytes(abi, { + sessionId, + policyHandle, + fontStackHandle, + limits, + ...fields, + }); +} + +function registerFont() { + const allocations = [validated.shapingSfnt, validated.glyphExtents, validated.glyphExtentsAvailability].map( + (bytes) => ({ + pointer: copyIntoAllocation(memory, fn.allocate, bytes), + length: bytes.byteLength, + }), + ); + requireStatus( + fn.registerFont( + fontHandle, + allocations[0].pointer, + allocations[0].length, + allocations[1].pointer, + allocations[1].length, + allocations[2].pointer, + allocations[2].length, + ), + 'register font', + ); + for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); +} + +function registerBinding() { + const glyphCount = validated.glyphExtents.byteLength / 8; + const bytes = fontBindingBytes(abi, { + techniqueId: 1, + glyphCount, + strikes: [0], + resources: [{ id: 1, generation: 1, kind: 1, reference: 1 }], + resourceIndices: new Array(glyphCount).fill(0), + glyphF32: [new Array(glyphCount).fill(1)], + }); + const pointer = copyIntoAllocation(memory, fn.allocate, bytes); + requireStatus(fn.registerFontBinding(fontHandle, pointer, bytes.byteLength), 'register font binding'); + fn.deallocate(pointer, bytes.byteLength); +} + +function registerStack() { + const bytes = Uint8Array.of(fontHandle, 0, 0, 0); + const pointer = copyIntoAllocation(memory, fn.allocate, bytes); + requireStatus(fn.registerFontStack(fontStackHandle, pointer, 1), 'register font stack'); + fn.deallocate(pointer, bytes.byteLength); +} + +function registerPolicy() { + const bytes = renderPolicyBytes(abi); + const pointer = copyIntoAllocation(memory, fn.allocate, bytes); + requireStatus(fn.registerPolicy(policyHandle, pointer, bytes.byteLength), 'register render policy'); + fn.deallocate(pointer, bytes.byteLength); +} + +function summarize(name, glyphs, samples) { + const sorted = samples.toSorted((left, right) => left - right); + const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + const variance = sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / sorted.length; + return { + name, + glyphs, + medianMs: sorted[Math.floor(sorted.length / 2)], + p95Ms: sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))], + minMs: sorted[0], + rsdPercent: (Math.sqrt(variance) / mean) * 100, + }; +} + +function printReport(caseReports) { + console.log( + `\ncomplete Rust text_update · ${caseReports[0]?.glyphs ?? 0} laid-out glyphs (${options.glyphs} fixture target) · ${options.warmup} warmup · ${options.repetitions} measured`, + ); + console.log( + `${'case'.padEnd(16)}${'glyphs'.padStart(9)}${'median'.padStart(11)}${'p95'.padStart(11)}${'min'.padStart(11)}${'rsd'.padStart(9)}`, + ); + for (const report of caseReports) { + console.log( + `${report.name.padEnd(16)}${String(report.glyphs).padStart(9)}${`${report.medianMs.toFixed(3)}ms`.padStart(11)}${`${report.p95Ms.toFixed(3)}ms`.padStart(11)}${`${report.minMs.toFixed(3)}ms`.padStart(11)}${`${report.rsdPercent.toFixed(1)}%`.padStart(9)}`, + ); + } + console.log('column-resize is the existing layout-width case: one fully active column is reflowed end to end.'); + console.log('suffix-edit matches the TypeScript text benchmark; localized-edit is an additional one-code-unit edit.'); + console.log(`Wasm memory after retained high-water mark: ${(memory.buffer.byteLength / 1024 / 1024).toFixed(2)} MiB`); +} + +function stringToUtf16(value) { + return Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); +} + +function requireStatus(status, operation) { + if (status !== abi.status.ok) throw new Error(`${operation} failed with status ${status}`); +} + +function parseArguments(arguments_) { + const read = (name, fallback) => { + const index = arguments_.indexOf(name); + return index === -1 ? fallback : Number.parseInt(arguments_[index + 1], 10); + }; + return { + glyphs: read('--glyphs', 22_000), + height: read('--height', 100_000), + repetitions: read('--reps', 11), + warmup: read('--warmup', 5), + }; +} diff --git a/packages/text/tests/support/engine-abi.d.mts b/packages/text/tests/support/engine-abi.d.mts index bb3e3c57..fc3a7014 100644 --- a/packages/text/tests/support/engine-abi.d.mts +++ b/packages/text/tests/support/engine-abi.d.mts @@ -14,6 +14,37 @@ export interface EngineUpdateFields { export function renderPolicyBytes(abi: object): Uint8Array; export function kernelPolicyBytes(abi: object): Uint8Array; export function engineUpdateBytes(abi: object, fields: EngineUpdateFields): Uint8Array; +export interface EngineFrameUpdateFields { + readonly sessionId: number; + readonly policyHandle: number; + readonly fontStackHandle: number; + readonly expectedEngineRevision?: number; + readonly consumedPlanRevision?: number; + readonly acknowledgedPublicationGeneration?: number; + readonly textMutation?: { + readonly start: number; + readonly deleteCount: number; + readonly insert: readonly number[]; + }; + readonly style?: { + readonly textEnd: number; + readonly fontSize: number; + readonly lineHeight: number; + readonly rasterPixelRatio: number; + }; + readonly geometry?: { + readonly width: number; + readonly height: number; + readonly maxLines: number; + readonly revision: number; + }; + readonly limits: { + readonly maxClusters: number; + readonly maxLines: number; + readonly maxOutputBytes: number; + }; +} +export function engineFrameUpdateBytes(abi: object, fields: EngineFrameUpdateFields): Uint8Array; export function copyIntoAllocation( memory: WebAssembly.Memory, allocate: (byteLength: number) => number, diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 37401d8c..d2a649a9 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -110,6 +110,121 @@ export function engineUpdateBytes( return bytes; } +export function engineFrameUpdateBytes( + abi, + { + sessionId, + policyHandle, + fontStackHandle, + expectedEngineRevision = 0, + consumedPlanRevision = 0, + acknowledgedPublicationGeneration = 0, + textMutation, + style, + geometry, + limits, + }, +) { + const request = abi.layouts.engineUpdateRequest; + const textRecord = abi.layouts.engineTextMutation; + const styleRecord = abi.layouts.engineStyleMutation; + const constraint = abi.layouts.engineConstraint; + const region = abi.layouts.engineRegion; + let cursor = request.size; + const textRecordOffset = textMutation === undefined ? 0 : cursor; + if (textMutation !== undefined) cursor += textRecord.size; + const styleRecordOffset = style === undefined ? 0 : align(cursor, styleRecord.alignment); + if (style !== undefined) cursor = styleRecordOffset + styleRecord.size; + const constraintOffset = geometry === undefined ? 0 : align(cursor, constraint.alignment); + if (geometry !== undefined) cursor = constraintOffset + constraint.size; + const regionOffset = geometry === undefined ? 0 : align(cursor, region.alignment); + if (geometry !== undefined) cursor = regionOffset + region.size; + const textPayloadOffset = textMutation === undefined || textMutation.insert.length === 0 ? 0 : align(cursor, 2); + const textPayloadLength = textMutation === undefined ? 0 : textMutation.insert.length * 2; + const bytes = new Uint8Array(textPayloadOffset === 0 ? cursor : textPayloadOffset + textPayloadLength); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, abi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.sessionId, sessionId, true); + view.setUint32(request.expectedEngineRevision, expectedEngineRevision, true); + view.setUint32(request.consumedPlanRevision, consumedPlanRevision, true); + view.setUint32(request.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); + view.setUint32(request.policyHandle, policyHandle, true); + view.setUint32(request.capabilitySet, 1, true); + view.setUint32(request.maxClusters, limits.maxClusters, true); + view.setUint32(request.maxLines, limits.maxLines, true); + view.setUint32(request.maxRegions, 1, true); + view.setUint32(request.maxExclusions, 1, true); + view.setUint32(request.maxInlineObjects, 1, true); + view.setUint32(request.maxSlotsPerBand, 1, true); + view.setUint32(request.maxOutputBytes, limits.maxOutputBytes, true); + view.setUint32(request.textMutationsOffset, textRecordOffset, true); + view.setUint32(request.textMutationCount, textMutation === undefined ? 0 : 1, true); + view.setUint32(request.styleMutationsOffset, styleRecordOffset, true); + view.setUint32(request.styleMutationCount, style === undefined ? 0 : 1, true); + view.setUint32(request.constraintsOffset, constraintOffset, true); + view.setUint32(request.constraintCount, geometry === undefined ? 0 : 1, true); + view.setUint32(request.regionsOffset, regionOffset, true); + view.setUint32(request.regionCount, geometry === undefined ? 0 : 1, true); + + if (textMutation !== undefined) { + view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(textRecordOffset + textRecord.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(textRecordOffset + textRecord.textStart, textMutation.start, true); + view.setUint32(textRecordOffset + textRecord.deleteCount, textMutation.deleteCount, true); + view.setUint32(textRecordOffset + textRecord.insertOffset, textPayloadOffset, true); + view.setUint32(textRecordOffset + textRecord.insertCount, textMutation.insert.length, true); + for (const [index, unit] of textMutation.insert.entries()) { + view.setUint16(textPayloadOffset + index * 2, unit, true); + } + } + + if (style !== undefined) { + view.setUint8(styleRecordOffset + styleRecord.opcode, abi.engine.styleMutationOpcodes.upsert); + view.setUint8(styleRecordOffset + styleRecord.flags, abi.engine.styleFlags.root); + view.setUint32(styleRecordOffset + styleRecord.styleId, 1, true); + view.setUint32( + styleRecordOffset + styleRecord.fieldMask, + abi.engine.styleFields.fontStack | + abi.engine.styleFields.fontSize | + abi.engine.styleFields.lineHeight | + abi.engine.styleFields.rasterPixelRatio, + true, + ); + view.setUint32(styleRecordOffset + styleRecord.textEnd, style.textEnd, true); + view.setUint32(styleRecordOffset + styleRecord.fontStackHandle, fontStackHandle, true); + view.setFloat32(styleRecordOffset + styleRecord.fontSize, style.fontSize, true); + view.setFloat32(styleRecordOffset + styleRecord.lineHeight, style.lineHeight, true); + view.setFloat32(styleRecordOffset + styleRecord.rasterPixelRatio, style.rasterPixelRatio, true); + } + + if (geometry !== undefined) { + view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); + view.setFloat32(constraintOffset + constraint.width, geometry.width, true); + view.setFloat32(constraintOffset + constraint.height, geometry.height, true); + view.setFloat32(constraintOffset + constraint.viewportBlockEnd, geometry.height, true); + view.setUint32(constraintOffset + constraint.maxLines, geometry.maxLines, true); + view.setUint16(constraintOffset + constraint.regionCount, 1, true); + view.setUint8(constraintOffset + constraint.widthMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.heightMode, abi.engine.axisModes.exact); + view.setUint8(constraintOffset + constraint.wrap, abi.engine.wrapModes.word); + view.setUint8(constraintOffset + constraint.align, abi.engine.inlineAlignments.start); + view.setUint8(constraintOffset + constraint.overflow, abi.engine.overflowModes.visible); + view.setUint8(constraintOffset + constraint.blockAlign, abi.engine.blockAlignments.start); + + view.setUint32(regionOffset + region.id, 1, true); + view.setUint32(regionOffset + region.geometryRevision, geometry.revision, true); + view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); + view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); + view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); + view.setFloat32(regionOffset + region.inlineEnd, geometry.width, true); + view.setFloat32(regionOffset + region.blockEnd, geometry.height, true); + view.setFloat32(regionOffset + region.clipInlineEnd, geometry.width, true); + view.setFloat32(regionOffset + region.clipBlockEnd, geometry.height, true); + } + return bytes; +} + export function copyIntoAllocation(memory, allocate, bytes) { const pointer = allocate(bytes.byteLength); if (pointer === 0) throw new Error('Wasm request allocation failed'); From 3ea4812344706c25cebf8540339bf9dedc5f4f72 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 18:13:41 -0400 Subject: [PATCH 045/128] perf(text): prove canonical raster policy shapes --- docs/log.md | 11 + docs/packages/text.md | 6 +- docs/planning/decision-register.md | 223 ++++++------ docs/planning/rust-layout-engine.md | 21 +- packages/text/rust/shaper/src/abi_contract.rs | 25 +- packages/text/rust/shaper/src/engine/frame.rs | 5 + .../text/rust/shaper/src/engine/policy.rs | 67 +++- .../rust/shaper/src/engine/policy_gather.rs | 81 ++++- packages/text/rust/shaper/src/engine/state.rs | 1 + .../scripts/benchmark-rust-layout-engine.mjs | 81 +++-- .../support/render-technique-proof.mjs | 327 ++++++++++++++++++ .../text/src/generated/text-shaper-abi.ts | 5 + .../render-plan-frame-abi.test.mjs | 5 + packages/text/tests/support/engine-abi.mjs | 6 +- 14 files changed, 704 insertions(+), 160 deletions(-) create mode 100644 packages/text/scripts/support/render-technique-proof.mjs diff --git a/docs/log.md b/docs/log.md index 3b8b0da7..ca103abf 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,17 @@ ## 2026-08-08 +- **Proved all three canonical render-policy shapes** — The retained Rust frame now consumes validated real Inter + Bitmap, MTSDF, and Slug records, derives linear color channels and inverse font size during policy gather without + retained per-glyph arrays, omits the same absent raster records as the portable techniques, and emits the exact + first-party buffer schemas: 48 bytes per Bitmap instance and 112 bytes per MTSDF or Slug instance. SIMD output now + transposes SoA arithmetic lanes into tightly packed vec2/vec4 records before contiguous 128-bit stores. On the + unchanged 25,515-positioned-glyph stress text (21,805 renderable instances), five warmups and 11 samples measure + Bitmap/MTSDF/Slug font-size medians of 5.506/6.396/7.237 ms and full-column-resize medians of + 4.477/5.276/6.259 ms. These exceed the sub-4 ms gate and identify the next required invariant: policy validation must + derive physical-buffer dependencies so resize and font-size updates do not execute or publish static UV, color, + band, address, and count buffers. The optimized module is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes. + - **Measured exact retained Rust frame invalidation** — Style changes now invalidate bidi, shaping, metrics, and positioning independently; exact rectangle geometry skips flow when safe; unchanged ordered-direct frames publish an empty reuse transaction without scanning glyphs. A hard-break regression now skips the deliberately unshaped cluster diff --git a/docs/packages/text.md b/docs/packages/text.md index 92af6547..27ed3cb7 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:72ae0fb32e20551ace57d4c6eadb728666a375f3e6cab14d19503e1920b78291' +source_digest: 'sha256:e9d7020001d06a9ab3dcf421c63ade6f0046d22f73e44ac21ad781e6abdebd20' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -691,7 +691,9 @@ Glyph identities now reconcile transactionally through that shared index. A clus Horizontal positioning now writes retained `LayoutGlyph` records and six F32/four U32 canonical semantic lanes entirely inside the frame transaction. UAX #9 L1 line resets feed allocation-reusing L2 cluster reordering; editorial slots apply start/center/end alignment or bounded space justification independently. Glyph origins use HarfRust offsets and actual selected-fallback metrics, accumulate in `f64`, include baseline shift, and narrow once. Baked font extents produce primitive bounds; absent extents still advance layout but emit no render instance. Exact float-bit and integer comparison assigns transactional content revisions through the shared identity index. A unit fixture preserves revisions `[1,2]` for a byte-identical rebuild and changes them to `[3,4]` after a one-pixel slot shift. A compiled Inter update publishes nonzero resource, buffer, patch, primitive, and draw tables through `text_update`; its identical warm successor preserves `memory.buffer` and emits zero patches. Optimized Wasm is 1,057,210 raw, 400,071 gzip, and 311,492 Brotli bytes. Complete 25,515-glyph latency is not yet measured, and vertical positioning, narrowed boundary shaping, truncation, decorations, and public renderer consumption remain open. -Exact retained invalidation now stops at the earliest affected Rust stage. Font-size changes reuse Unicode, bidi, and HarfRust output; exact rectangle geometry reuses flow when no inline object needs retained comparison; unchanged ordered-direct frames publish an empty plan transaction without walking glyphs. A terminal hard-break cluster is skipped before visual-run lookup, matching its deliberate absence from shaping runs. At 25,515 laid-out glyphs with eight discarded warmups and 31 samples, the complete request-copy plus `text_update` path measures 13.693/0.001/4.090/3.374/13.927/13.986 millisecond medians for cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit, with corresponding p95 values of 14.111/0.001/4.236/3.706/14.511/14.381 milliseconds. The unchanged TypeScript comparison measures 55.25/11.90/8.36/38.55 millisecond medians for cold/font-size/width/suffix-edit. These are checkpoint numbers, not final speedup claims: the Rust benchmark's current policy writes one F32 lane, while canonical Bitmap writes origins, sizes, UV origins, UV sizes, and colors. The next policy proof must run that exact five-buffer shape over real baked records. The optimized module is 1,060,175 raw / 400,500 gzip / 316,984 Brotli bytes. A 76.25 MiB process high-water mark accumulated across sequentially created and disposed stress sessions remains a memory-optimization finding, not an accepted 25K-session or default-session budget. +Exact retained invalidation now stops at the earliest affected Rust stage. Font-size changes reuse Unicode, bidi, and HarfRust output; exact rectangle geometry reuses flow when no inline object needs retained comparison; unchanged ordered-direct frames publish an empty plan transaction without walking glyphs. A terminal hard-break cluster is skipped before visual-run lookup, matching its deliberate absence from shaping runs. At 25,515 laid-out glyphs with eight discarded warmups and 31 samples, the one-F32 diagnostic path measures 13.693/0.001/4.090/3.374/13.927/13.986 millisecond medians for cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit, with corresponding p95 values of 14.111/0.001/4.236/3.706/14.511/14.381 milliseconds. The unchanged TypeScript comparison measures 55.25/11.90/8.36/38.55 millisecond medians for cold/font-size/width/suffix-edit. + +The full-policy benchmark now validates real baked Inter artifacts and compiles all three first-party GPU shapes: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec4 buffers totaling 112 bytes, and five Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. Absent raster records are omitted exactly as `RasterTechnique.select` omits them, leaving 21,805 renderable instances from the unchanged 25,515-positioned-glyph stress text. Derived linear color channels and inverse font size materialize only in requested gather lanes; they add no retained per-glyph arrays. SIMD execution transposes four-record SoA arithmetic into tightly packed vec2/vec4 output and uses contiguous 128-bit stores. With five warmups and 11 samples, Bitmap/MTSDF/Slug font-size medians are 5.506/6.396/7.237 milliseconds and full-column-resize medians are 4.477/5.276/6.259 milliseconds. These are rejection evidence, not final speedup claims: the compiler still reruns and publishes static physical outputs when only geometry changed. Validated output dependencies and exact per-frame semantic change masks must make resize geometry-only and keep UV, color, Slug band/address/count, and other static buffers retained. The optimized module is 1,060,971 raw / 400,835 gzip / 317,139 Brotli bytes. A 96.69 MiB sequential-process high-water mark remains an unresolved memory finding, not an accepted session budget. The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 10931cf9..6a2edcbe 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -152,117 +152,118 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. ## Raster -| ID | Decision | Status | -| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-----------------: | -| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | -| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | -| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | -| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | -| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | -| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | -| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | -| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | -| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | -| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | -| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | -| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | -| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | -| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | -| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | -| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | -| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | -| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | -| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | -| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | -| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | -| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | -| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | -| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | -| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | -| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | -| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | -| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | -| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | -| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | -| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | -| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | -| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | -| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | -| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | -| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | -| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | -| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | -| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | -| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | -| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | -| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | -| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | -| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | -| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | -| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | -| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | -| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | -| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | -| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | -| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | -| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | -| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | -| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | -| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | -| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | -| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | -| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | -| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | -| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | -| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | -| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | -| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | -| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | -| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | -| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | -| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | -| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | -| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | -| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | -| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | -| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | -| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | -| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | -| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | -| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | -| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | -| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | -| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | -| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | -| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | -| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | -| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 88-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry stable identity separately from authored cascade order, stated-field presence, shaping, spacing, raster density, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty style sections and carries no shaping/layout timing claim. | Accepted | -| D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | -| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms retained active/pending UTF-16, Unicode, bidi, shaping-run, shaped-glyph, and fallback arrays to 1,024 text units by default, with shaped-glyph capacity at 2×; `createSession` and `reserveSession` accept an explicit text capacity and retain the resulting high-water mark. These arrays are session-owned because committed results must survive across multiple live sessions. The compiler-published `initialize()` export runs after Wasm instantiation and prewarms reusable module-global synchronous scratch: the 32,768-entry policy-gather arena, HarfRust's actual 32,768-codepoint buffer, and UTF-16 context scratch, covering the 25,515 target fixture without multiplying that largest reservation per session. Initialization settles 57 Wasm pages and is identity-stable when repeated. Policy registration cold-reserves its exact field lanes. Line and geometry-output workspaces remain open; legacy batch-result vectors are outside the final frame claim. Warm `text_update` may not lazily settle capacity within declared envelopes. | Accepted | -| D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | -| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | -| D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | -| D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | -| D-179 | Editorial flow geometry is a complete borrowed section of one `text_update`, never a host measurement callback. A nonempty geometry transaction contains at least one constraint and region and may contain bounded rectangle/polygon exclusions and text-anchored inline objects. Compiler-published enums define axis, wrap, inline/block alignment, overflow, writing mode, orientation, exclusion side, and baseline tags. Rust validates finite ordered bounds, polygon vertices, limits, unique identities, region/exclusion ranges, text offsets after pending mutations, reserved fields, and all table/payload overlap before state mutation. Sessions transactionally retain a semantic geometry fingerprint while layout will consume the borrowed records directly; pointer-only vertex offsets are excluded so equivalent repacking is deterministic. Compiled Wasm accepts a complete rectangle/exclusion/object request in one update and rejects a forged region reference without advancing the A/B publication. Optimized Wasm changes from 847,814 / 315,809 / 249,629 to 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. Styles remain rejected until their retained mutation model lands. | Accepted | -| D-180 | The retained style ABI uses an explicit authored `cascadeOrder` independent from stable `styleId`; ID allocation therefore cannot change equal-range precedence. Its `fieldMask` is stated-property presence, not a dirty hint, so absent values inherit and explicit zero-valued declarations remain representable. The 88-byte record also carries `rasterPixelRatio` for Rust-owned bitmap strike selection, although target density remains a root target property rather than a per-span typography feature. Compiler-published style, field, decoration-style, and decoration-flag vocabularies prevent host-local tag drift. This decision fixes the wire contract only: nonempty style sections remain rejected until validation and transactional retained storage land. | Accepted | -| D-181 | Nonempty style mutations are admitted only with their Rust consumer. Decoding borrows canonical fixed records and monotonically packed offset payloads without allocation. Each session pre-reserves two flat 64-style/512-language-byte/128-feature arenas and reusable mutation/order/nesting scratch. Mutations collapse by stable ID and merge with committed ID-sorted state into a compact inactive arena; no per-style vector or stale replaced payload survives. Rust validates stated versus absent bytes, numeric domains, language/tags, UTF-16 feature/range boundaries, binary-searched font-stack reachability, one complete root, unambiguous equal-range cascade order, proper nesting, and all request-section aliasing before plan preparation. Payload admission is linear and retained validation is O(n log n), with one reusable-scratch sort. Commit swaps arenas and abort preserves committed state. A compiled-Wasm real-font transaction commits text plus root style and rejects root removal without revision advance or post-creation memory growth. Optimized Wasm changes from 856,831 / 319,003 / 252,236 to 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes. Layout consumption and latency remain open. | Accepted | -| D-182 | Rust resolves retained stated styles into a derived A/B segment arena before shaping. One containment sweep stores the fully resolved parent in pre-reserved scope scratch, applies each stated field once, restores parent values on close, and coalesces adjacent semantically equal segments. Stable identity is irrelevant to precedence; containment and explicit authored order govern equal ranges. Language and feature results reference retained compact payloads rather than copying per segment. Root font stack, logical size, and target density are required; line height may remain absent to select natural font metrics. A nested/equal-range proof emits five exact maximal segments and verifies inherited shaping, spacing, paint, material, language, and features. Optimized Wasm changes from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 raw/gzip/Brotli bytes. Unicode/run intersection, shaping, layout, and nonempty plan output remain open. | Accepted | -| D-183 | Unicode analysis is derived session state inside the Rust frame transaction. The existing pinned Unicode 17 generator emits TypeScript tables and compact Rust Script/Script_Extensions partitions from one source; the Rust partition omits derivable starts. `unicode-segmentation` 1.13.3 runs under `no_std`, validates retained UTF-16 through a reusable UTF-8 scratch string, returns extended-grapheme boundaries to the public UTF-16 coordinate space, and feeds allocation-reusing contextual script itemization. Active and pending analysis arenas reserve with session text capacity, swap only on commit, abort with a failed frame, and are untouched when text is unchanged. Rust unit tests cover Emoji ZWJ, Indic/Kana scripts, shared marks, malformed surrogates, rollback, and capacity reuse; host/SIMD Clippy and compiled-Wasm lifecycle tests pass. Optimized Wasm changes from 895,593 / 335,396 / 264,355 to 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | -| D-184 | Bidi analysis and shaping-run itemization are derived A/B session state. `unicode-bidi` remains the UAX #9 algorithm and fills reusable level, class, paragraph, and equal-level-run arrays; text and root base-direction changes re-run it, while unchanged text/style skips it. Root direction selects the paragraph base level. A non-root stated LTR/RTL direction is retained as a distinct derived override, inherited through its scope, and forces only the intersected run's level parity. One forward interval sweep intersects maximal resolved styles, contextual script items, and equal-level bidi runs, excludes mandatory hard-break controls, and coalesces equal adjacent shaping records. Session text capacity pre-reserves active/pending bidi and run arrays. Unit tests cover output reuse, mixed Latin/Hebrew levels, root-only direction updates, nested override parity, hard-break exclusion, and transaction rollback; host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 964,019 / 360,765 / 288,742 to 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes. HarfRust fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | -| D-185 | Frame shaping consumes retained runs through a borrowed HarfRust input rather than constructing the legacy owned batch request. Legacy exports and `text_update` share the module-global prewarmed UnicodeBuffer, UTF-16 context scratch, and reusable 128-feature vector. Frame language/features borrow the active retained style arena. Each shaped run appends its font/source identity and glyph ID, UTF-16 cluster, advances, offsets, and flags directly into a pre-reserved A/B session SoA arena that swaps only on commit. The production Wasm update borrows the one existing `ShaperRegistry`; it does not duplicate font bytes or serialize shaped output through the old ABI. A compiled real-Inter test proves shape-plan cache count changes 0→1 only after `text_update` and remains unchanged after an aborted frame. Rust unit tests, host/SIMD Clippy, and focused compiled-Wasm tests pass. Optimized Wasm changes from 968,086 / 362,664 / 286,438 to 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes. This checkpoint shapes only each stack's primary font; ordered fallback, layout, nonempty plan output, and complete-path timing remain open. | Accepted | -| D-186 | Ordered fallback is resolved inside the same Rust frame transaction from actual HarfRust `.notdef` output, never from `cmap`, raster coverage, or a host callback. Reusable flat spans name source run, UTF-16 range, stack index, and concrete font; reusable cluster records collapse multi-glyph clusters with missing status ORed across glyph zero. Records sort by source run/logical cluster to normalize RTL output, then one linear merge advances only missing ranges. Font index increases monotonically, bounding passes by stack depth. Final spans and shaped SoA commit together and abort together. A compiled Inter→Noto Devanagari `text_update` constructs exactly two HarfRust plans, causally proving primary and fallback shaping. Host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 973,367 / 364,517 / 287,942 to 982,356 / 368,183 / 290,439 raw/gzip/Brotli bytes. Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. | Accepted | -| D-187 | Unicode 17 line-break opportunities are retained Rust Unicode state, not host input. The pinned `@cto.af/linebreak` 4.0.3 property trie is generated into a compact scalar partition containing resolved line-break class and only the punctuation, East Asian, and unassigned-extended-pictographic flags its ordered UAX #14 rules consume. A specialized `no_std` Rust evaluator reuses flat scalar/break arrays, returns UTF-16 offsets, retains the upstream MIT notice, and commits/aborts with grapheme/script analysis. All 19,338 unchanged official `LineBreakTest` cases pass, plus focused required-break tests; host/SIMD Clippy passes. Optimized Wasm changes from 982,356 / 368,183 / 290,439 to 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli bytes. Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. | Accepted | -| D-188 | Measured clusters are retained A/B Rust SoA state derived from final fallback-shaped glyphs and Unicode analysis: grapheme UTF-16 starts/ends, ordered `f64` advances, compact safe/allowed/required/hard-break flags, resolved-style index, source-run/font identity, and one offset-to-cluster index. Glyph positions remain `i32` design units through shaping and are scaled once with cached per-font UPEM; letter spacing, word spacing, and zero-width hard breaks join that ordered accumulation. Optional UAX #14 breaks are admitted only when the next grapheme is a HarfRust-safe boundary. A synthetic kernel proof fixes exact `[9, 7, 9, 0]` advances for design widths plus letter/word spacing and proves unsafe suppression; compiled real-font primary/fallback updates reach the pass. Optimized Wasm changes from 1,009,460 / 377,053 / 295,875 to 1,014,577 / 379,510 / 295,708 raw/gzip/Brotli bytes. The Brotli decrease is compressor interaction, not a speed claim. Composition, nonempty plan output, and complete-path timing remain open. | Accepted | -| D-189 | The allocation-free Rust `layout_next_line` kernel consumes retained clusters through a grapheme cursor and one caller-supplied `f64` width. Word mode prefers safe UAX #14 opportunities and falls back to HarfRust-safe boundaries; character mode accepts every safe grapheme boundary; no-wrap ignores width but preserves required breaks; over-wide first clusters always make progress; and a terminal hard break emits the canonical trailing empty line. Focused tests prove these modes and cursor termination. The kernel is not yet connected to region-band resolution or frame output, so this checkpoint makes no end-to-end timing or production Wasm-size claim. | Accepted | -| D-190 | Validated flow snapshots become retained A/B Rust state during the production update transaction. Constraints, ordered regions, exclusions, and polygon vertices are copied into reusable session arrays; vertex offsets are rebased, so no request pointer survives `text_update`. The rectangle fast path subtracts intersecting exclusions through two reusable sorted slot vectors, applies each declared wrap side, and enforces `max_slots_per_band`. An exact fixture maps region `0..100` minus exclusion `20..40` to slots `[0..20, 40..100]`; a polygon fixture proves three vertices survive as owned values. Optimized Wasm changes from 1,014,577 / 379,510 / 295,708 to 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes. Polygon band intersection, line-cursor connection, nonempty plan output, and complete-path timing remain open. | Accepted | -| D-191 | Bounded simple polygons resolve through retained allocation-reusing sweep scratch. Region bands intersect normalized cross-sections at every in-band vertex boundary and within each linear-edge slab; polygon exclusions conservatively project vertices and band-edge intersections over the margin-expanded block range before applying the declared wrap side. Horizontal boundary segments participate before interval normalization. Exact fixtures cover a serialized triangle, a concave U region yielding `[0..40, 60..100]`, a diamond exclusion yielding `[0..20, 60..100]`, and explicit `ResultTooLarge` when two normalized slots exceed a one-slot envelope. The kernel is not yet called by frame line placement, so no production Wasm-size or complete-path timing claim is made. | Accepted | -| D-192 | Production horizontal frame layout now consumes retained clusters and flow geometry into transactional line/fragment A/B arrays. A session-global slot workspace remains allocation-free after its declared high water mark. A band composes every available slot on one baseline, derives ascent/descent/line-height from each cluster's actual fallback font and resolved style in `f64`, and retries at most once with the maximum metrics found by the first widest pass. Enlarging the band conservatively intersects region slots and expands exclusion projection, so the retry consumes a subset rather than exposing later styles. Exact tests produce two fragments around a hole, raise a 10 px estimate to a 20 px line/16 px baseline, and overflow sequentially through region IDs `[1, 2, 2, 2]` without balancing. Rebuilt frame ABI and real-font/fallback integration tests pass. Optimized Wasm changes from 1,016,720 / 384,593 / 297,049 to 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes. Vertical flow, final positioning, boundary reshaping, nonempty plan output, and complete-path timing remain open. | Accepted | -| D-193 | Stable render identity starts with transactional UTF-16 unit IDs, not mutable offsets or collision-prone content hashes. A/B unit-ID arrays undergo the same ordered replacements as text; only inserted units receive monotonic nonzero IDs, and abort restores both committed IDs and the allocator cursor so retry is deterministic. Each grapheme inherits its first unit's ID. An exact fixture preserves `[1,2,3,4]` through abort, then grows an edit to `[1,5,6,3,4,7]`, proving that the shifted `c/d` suffix retains IDs `3/4`; a later first-unit replacement yields `[8,5,6,3,4,7]`. Optimized Wasm changes from 1,039,404 / 392,671 / 303,705 to 1,041,582 / 393,214 / 304,815 raw/gzip/Brotli bytes. Glyph-range allocation, per-glyph revision, positioning, and nonempty plan output remain open. | Accepted | -| D-194 | Logical clusters retain flat shaped-glyph adjacency built by count, prefix sum, and fill. This preserves each shaped run's glyph order while allowing line positioning to traverse cluster slices directly, including RTL output; no per-glyph object, search, or map enters the hot path. An exact fixture maps shaped clusters `[2,1,0]` to logical glyph-index slices `[2]`, `[1]`, `[0]`, and rebuilding preserves every adjacency-array capacity. Optimized Wasm changes from 1,041,582 / 393,214 / 304,815 to 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes. Stable glyph allocation and positioning remain open. | Accepted | -| D-195 | The stable plan pool and glyph reconciliation share one retained exact identity index. Its open-address hash chooses only a probe position; full-key equality decides matches, duplicate keys fail, and epoch clearing makes same-capacity prepare allocation-free. A collision fixture proves these properties. The isolated refactor changes optimized Wasm from 1,043,289 / 394,074 / 304,902 to 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes. The +2,357 Brotli regression is accepted to avoid a second correctness implementation and will be remeasured once glyph reconciliation makes the shared consumer reachable. | Accepted | -| D-196 | Per-glyph stable IDs reconcile transactionally by stable cluster ID and glyph ordinal. Existing ordinals retain monotonic IDs; inserted clusters and added ordinals allocate new IDs; cluster abort also aborts the allocator cursor. A fixture maps prior cluster IDs `[[1,2],[3]]` to `[[4],[1],[3,5]]` and reproduces the same result on retry without capacity growth. Positioning, not shaping, will compare exact final content and own `content_revision`. Optimized Wasm changes from 1,043,094 / 394,035 / 307,259 to 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes. | Accepted | -| D-197 | Horizontal positioning is retained Rust state and directly feeds policy gather. UAX #9 L1/L2 visual order, slot-local alignment/justification, actual fallback metrics, HarfRust offsets, baseline shift, baked extents, and positive-down bounds execute with `f64` accumulation and one narrowing. Six F32 and four U32 semantic SoA lanes remain policy-readable. Exact final bits assign transactional content revisions; a one-pixel fixture proves no-op revision reuse and changed-content advancement. Compiled real-Inter `text_update` publishes nonempty resource/buffer/patch/primitive/draw tables, while an identical warm frame preserves Wasm memory identity and emits zero patches. Optimized Wasm changes from 1,044,797 / 395,222 / 307,795 to 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes. Complete-path 25,515-glyph timing remains unmeasured. | Accepted | -| D-198 | Retained frame invalidation compares exact committed and pending semantic state rather than treating every style or geometry transaction as a full pipeline change. Direction changes restart bidi; shaping inputs restart shaping; metric inputs rebuild measured clusters and flow; positioning/paint inputs rebuild positioned semantics; exact geometry equality with no inline objects skips flow; and an unchanged ordered-direct frame publishes an empty reuse transaction without scanning glyphs. The 25,515-glyph, 8-warmup/31-sample Node run measures Rust cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit at 13.693/0.001/4.090/3.374/13.927/13.986 ms median and 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are 55.25/11.90/8.36/38.55 ms. This proves the invalidation direction, not final renderer parity: the Rust lane still executes a one-F32 policy rather than canonical Bitmap's five buffers, so full Bitmap packing remains a required comparison before publishing speedup ratios. Optimized Wasm is 1,060,175 / 400,500 / 316,984 raw/gzip/Brotli bytes. The sequential benchmark process reaches 76.25 MiB after multiple disposed sessions; that is not a per-session requirement or an accepted memory target. | Accepted | +| ID | Decision | Status | +| ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :-----------------: | +| D-050 | The merged, unreleased v0 implementation contains Bitmap, MTSDF, and Slug; Bitmap alone was only the first integration proof. The target v1 must preserve all three behind the renderer-neutral contract. | Accepted | +| D-051 | Rasters never duplicate advances, kerning, or shaping behavior. | Accepted | +| D-052 | Direct-to-GPU means no reconstruction/repacking, not zero upload. | Accepted | +| D-053 | The MSDF raster uses linear MTSDF RGBA8; padding stays in raster bounds. | Accepted | +| D-054 | Deterministic unhinted bitmap oversampling is the baseline candidate. | Experiment | +| D-055 | Recommend MSDF generally, but require an explicit raster module. | Accepted | +| D-056 | Windfoil is research prior art, not a planned text backend. | Accepted | +| D-057 | Post-slice Slug includes color-emoji vector paint; safe OpenType-SVG and standalone-SVG icon baking lands in the large-coverage CJK/icon milestone. | Accepted | +| D-058 | Fill, opacity, outline, and hard shadow are baseline game-text styles. | Accepted | +| D-059 | Payload reports separate shaping, transport, decoded, and GPU bytes. | Accepted | +| D-061 | Slug bands compress exactly; curve compression remains quality-gated. | Accepted | +| D-064 | Merged v0 and target v1 do not support plain MSDF assets or parallel MSDF/MTSDF batches. | Accepted | +| D-065 | First-party raster packages use TSL internally; the core raster API is shader-system and backend agnostic. | Accepted | +| D-073 | Target v1 assigns one selected raster per font slot; per-glyph raster mixing is additive color/SVG work after the first release. | Accepted | +| D-075 | Latin remains the target v1 rendering and raster-coverage priority. Pre-render CJK shaping/layout conformance may harden universal core assumptions, but CJK raster paging and icon coverage remain a post-v1 milestone and do not expand the Latin-first renderer exit gate. | Accepted | +| D-076 | Raster page indexes are logical IDs; page payloads may be embedded or independently addressed, and raster modules own preparation, residency, eviction, and backend batching. | Accepted | +| D-091 | Bitmap plane bounds preserve the rasterizer's integer pixel placement with `planeUnitsPerEm = strike ppem`; the shared TSL vertex graph snaps projected quad edges to physical framebuffer pixels so native rendering maps one atlas texel to one device pixel. | Accepted | +| D-092 | Hinted grayscale strikes and optional four-phase grayscale packing remain measured research. LCD/ClearType subpixel rendering, panel-order assumptions, runtime hint interpreters, and distance-field reconstruction are out of scope. | Experiment | +| D-093 | Bitmap V0 renders fill and opacity only and rejects outline or shadow through the raster paint-validation seam; MTSDF owns those distance-based effects rather than silently degrading them. | Accepted | +| D-096 | Bitmap presentation transitions are optional `@pmndrs/text/raster/bitmap` helpers over copied glyph identities and instance origins. Shaping and layout commit discretely; only identity-matched glyph positions interpolate before the existing physical-pixel snap, and unused consumers pay no target-origin allocation. | Settled for V0 | +| D-097 | Milestone 8 owns a purpose-built `no_std + alloc` Rust MTSDF core under `packages/text/rust`, with repository-defined types, limits, reusable scratch storage, data-oriented scalar/SIMD experiments, typed errors, and the generated direct-memory C ABI/JSON boundary. The scalar path is a correctness oracle; if `simd128` wins the complete quality, full-font-time, and size comparison, it is the single default shipped Wasm kernel rather than a baker option. The core may retain proven design ideas from reviewed implementations but is not a copied Klyff fork. Pinned native Chlumsky `msdfgen` remains the canonical test-only oracle and port reference; Klyff, OxiText, Rust bindings, and UIKit/Zappar remain research evidence rather than product dependencies. | Accepted | +| D-099 | MTSDF V0 originally fixed one opinionated bake: 64 plane units per em, a full eight-pixel encoded distance range, four field-padding texels, one atlas-gap texel, 1024-pixel pages, dense 20-byte records, and lossless linear RGBA8 KTX2. The published baker composes the admitted scalar kernel with the shared Fontations provider and lossless artifact primitives; standalone generator evidence remains separately measurable but is not a second published Wasm. | Superseded by D-110 | +| D-149 | Text size is logical CSS geometry. A rendering integration supplies an explicit raster pixel ratio; bitmap targets `CSS size × ratio`, deterministically selects the nearest declared physical strike, and never changes paragraph geometry to compensate for DPR. The core installs no DOM or gesture listeners. | Accepted | +| D-150 | Bitmap density strikes remain independent grayscale record/texture sets rather than RGB(A)-channel packing. A combined artifact may carry several strikes, while Milestone 13 adds independently fetched and evictable strike pages. | Accepted | +| D-151 | Language delivery is exact-coverage-first and locale-aware. A family directory may route grapheme-safe runs to font-local units, but language labels alone never prove coverage and units never split contextual shaping runs. Compiler-produced shaping closure/remapping remains Milestone 17. | Accepted | +| D-103 | Explicit and fallback runtime raster baking accept normalized bounded coverage and raster options through the same Worker-only path. Coverage may be seeded by Unicode ranges, authored text, or exact font-local glyph IDs, but it reduces atlas generation only: it does not subset the shaping font, remap glyph IDs, or claim transitive shaping closure. | Accepted | +| D-104 | Every direct-memory Wasm ABI layout is represented by fixed-width `#[repr(C)]` Rust types. Build-only Rust generators derive published JSON and exact `as const` TypeScript contracts from `size_of`, `align_of`, and `offset_of!`; production hosts import those generated facts, and production Wasm embeds no duplicate contract or ABI-pointer bootstrap. WebAssembly direct memory uses its guaranteed little-endian order; portable GLB, KTX2, SFNT, and extension encodings retain their format-defined byte order. | Accepted | +| D-105 | Merged v0 retained the Three.js/TSL integration through Slug so real shader, resource, batching, and lifetime requirements could inform the abstraction. Target v1 extracts one renderer-neutral core beneath Bitmap, MTSDF, and Slug; Three.js, TypeGPU, Wayfare, and other engines become independently selectable integrations. Optional TypeGPU compute-baker research cannot enter unrelated runtime graphs. | Accepted | +| D-106 | Slug V0 artifacts retain exact R16UI reference grids. The Three.js 0.185.1 adapter may pair-pack those values into R32UI texels at decode time because its WebGL TSL backend does not declare an unsigned sampler for `UnsignedShortType`; this preserves reference identity and two-byte density plus at most one terminal padding value. Other adapters remain free to upload R16UI directly, and the exception does not redefine the portable artifact. | Accepted | +| D-107 | Repository TypeScript commands execute the installed native compiler through one bounded runner that first proves its kill/reap path with a synthetic allocator, supervises the native PID rather than a shell or Node shim, caps aggregate tracked RSS, enforces a wall-time limit, and reports no success while a compiler survives. TSL changes compile reduced operation fixtures and a narrow graph before package or application projects; free functions remain the first mitigation but exact-version pathological overloads use one proven concrete compatibility boundary. | Superseded by D-114 | +| D-108 | MTSDF V0 uploads only the authenticated base level and uses bilinear field sampling plus screen derivatives for reconstruction. Conventional GPU mip generation and trilinear cross-level sampling are rejected: averaging encoded MSDF channels is not a distance-field-preserving operation, and the primary MSDF paper plus official generators provide no affirmative mipmap guidance. Runtime, standalone validation, fixtures, and the inspector report the exact padded base texture-array allocation. Any future size-specific representation is an independently authored atlas layer or strike, not a conventional mip chain. | Accepted | +| D-109 | Slug V0 implemented a centered exact-distance outline in one specialized fill-plus-outline draw. Retained measurements later showed `2.44×–4.33×` fill-only GPU time, and generated-shader inspection found duplicated traversal, curve loads, closest-point refinement, and a derivative inside divergent control flow. | Superseded by D-111 | +| D-110 | MTSDF V0 exposes `emSize` and full `pixelRange` as authenticated integer bake options. `emSize` is limited to `1..=1022`, `pixelRange` to `1..=1020`, `planeUnitsPerEm` equals `emSize`, and field padding is `ceil(pixelRange / 2)`. Omitted or partial options resolve against the 64/8 compatibility defaults; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor contains both effective values. The low-level Wasm ABI is unchanged. Passing 32/4 and 32/6 155-glyph subset bakes proves the control path, not a new recommended default; quality and payload benchmarking owns that decision. | Accepted | +| D-111 | Remove the dynamic exact-distance Slug outline rather than ship an expensive fallback. Slug V0 supports fill and opacity and rejects every runtime outline or shadow property. The generic text outline API remains because MTSDF owns it. | Accepted | +| D-112 | Research one bounded Slug outline approximation that reuses ordinary fill traversal and screen derivatives without closest-point solving or independent halo traversal. It ships only if its quality is no worse than the MTSDF outline corpus and its median GPU time is at most `1.15×` a same-expanded-quad fill control on both WebGPU and forced WebGL2; otherwise Slug remains fill-only. | Experiment | +| D-114 | Carry the upstream `NodeExtras` lookup-map rewrite as a version-pinned pnpm patch for `@types/three` 0.185.1. A focused compile-only regression owns every previously explosive TSL operation. Package and application scripts invoke the pinned compiler directly; the native-process memory guard and its repository-wide invocation requirement are removed because they contained a dependency type-graph defect now corrected at its declaration boundary. | Accepted | +| D-115 | The benchmark app uses Koota as an application-boundary state manager. Coherent singleton world traits own live controls and published telemetry; direct world reads/writes coordinate capture and renderer state. The world instance is exported from a dedicated HMR-stable module. Koota does not enter core text, shaping, layout, baking, raster, or public package APIs, and entities/queries are reserved for data that genuinely has collection lifecycle. | Accepted | +| D-116 | Interactive benchmark overlays use official shadcn components backed by Base UI rather than application-owned dismissal, focus, portal, or keyboard machinery. Repository semantic tokens theme those checked-in components. Koota remains the single owner of runtime control values; shadcn/Base UI owns interaction behavior only. | Accepted | +| D-117 | Each benchmark route owns one persistent render host per backend generation. The host owns the canvas, renderer, animation loop, GPU timing, telemetry history, viewport, and serialized scene/job lifecycle. React Suspense owns cold asset readiness; scene, technique, delivery, and font selections preload and commit with React transitions so the last complete scene remains visible until an atomic replacement is ready. Compatible font changes retain the active `Text` objects and registry. | Accepted | +| D-118 | Milestone 10 replaces `buildBatches`, optional retained updates, and separate repaint mutation with one required renderer-neutral `stageBatch(previous, layout, resource, fontSlot, paint, rasterPixelRatio)` transaction. A stage owns one unpublished target batch, may retain or replace the previous batch, cannot mutate committed state before publication, commits synchronously and infallibly, and aborts idempotently; committed batch disposal is also idempotent. The portable contract exposes no Three.js, TSL, WebGPU, WebGL, or first-party raster-kind union. Three.js object attachment is an adapter requirement enforced by `Text`, not part of `RasterDrawBatch`. | Accepted | +| D-119 | Once a font, shared shaper, decoded raster resource, and layout-required raster pages are resident, `Text.setProperties` shapes, lays out, plans paint, and stages synchronously while retaining the previous complete generation. The Three.js adapter publishes that candidate at the start of `updateMatrixWorld` or `updateWorldMatrix`, before child traversal; the React adapter explicitly invalidates its R3F root after a core-property update. `ready` remains an observation channel for cold work and queued publication, not a consumer coordination requirement for warm React updates. Synchronous validation, shaping, preparation, or staging faults throw from `setProperties` without cancelling an earlier candidate or live generation; asynchronous preparation and defensive commit-contract faults reject `ready`. A raster `prepare` implementation returns `void` when its requirement is resident and one shared idempotent Promise only for genuinely cold work. | Accepted | +| D-120 | First-party raster batches allocate deterministic 25% glyph-instance slack capped at 256 instances and track logical count separately from capacity. Bitmap, MTSDF, and Slug retain their complete parallel instance records when compatible content fits; shrinks and exact-capacity growth publish authoritative draw counts, while overflow and incompatible ordered Bitmap/Slug page-run topology replace transactionally. Dirty uploads use 32-instance buckets, at most eight disjoint ranges, and a logical full-range fallback; pending renderer ranges carry forward until consumed. This reuse is per batch and does not introduce automatic batching across independent `Text` objects. | Accepted | +| D-121 | The public extension proof is a private `glyphExample` package that owns its kind, descriptor, companion artifact, embedded/external records, baker, runtime generator, decoder, Three.js/TSL adapter, retained capacity, dirty uploads, overflow, abort, and disposal using only published `@pmndrs/text` entry points plus its renderer dependency. Portable batches stay renderer-neutral through `RasterObjectDrawBatch`; `ThreeRasterDrawBatch` documents the `Text` adapter requirement. Static discovery requires the imported factory export name, package manifest key, and default baker kind to match. | Accepted | +| D-122 | Framework-neutral `Text` is a composite `Object3D`, not a `Group`, so a caller-owned parent Group remains Three.js's primary `groupOrder`. `Text.renderOrder` is the secondary paragraph base and each drawable receives that base plus its first-glyph/page-run-local order. Three raster batches implement `setRenderOrderBase`, use neutral non-`Group` roots, and preserve the base across retained commits; the adapter applies it before cold publication, resynchronizes later caller changes during ordinary matrix traversal, and rejects a nested raster Group that would replace the inherited primary key. Nested React Text remains source/span composition and does not create nested scene objects. | Accepted | +| D-123 | The target v1 core has three retained public objects: `TextRuntime`, `ParagraphBatch`, and `Paragraph`. A paragraph batch declares exactly one raster technique, capacity policy, and application render-phase boundary within which core may order and submit paragraphs; it is not a one-draw promise. Every paragraph owns its complete font selection. A multiline block, label, or font-backed icon is always a paragraph. Separate public font-group, paragraph-engine, label, icon, text-item, and mixed-technique logical-batch lifecycles are rejected. | Accepted | +| D-124 | Paragraph handles own desired text, spans, content box, style, paint, finite order, and reversible glyph-origin overrides. Observable top-level setters and indexed methods mark dirty channels without shaping; nested option records are immutable replacements. Repeated writes coalesce naturally. `TextRuntime.update()` snapshots every dirty paragraph across all paragraph batches and synchronously shapes, lays out, partitions, packs, and atomically publishes the final desired state. `updateAsync()` snapshots the same state for asynchronous preparation. A no-op synchronous update returns the current revision without allocation. | Accepted | +| D-125 | Sync versus async is selected per synchronization call, not when creating the runtime. Runtime options only provision a synchronous shaper and optional lazily created asynchronous executor. `updateAsync()` has a Promise form and a callback form that creates no public Promise; both complete asynchronously. Worker results may stream into unpublished staging storage and report bounded progress, but publication remains atomic. Mutations after an update snapshot remain dirty for the next synchronization. A newer sync or async synchronization supersedes any unpublished older asynchronous generation, which can never replace newer state. Published, superseded, and aborted requests are resolved outcomes; only an actual preparation failure rejects the Promise or enters the callback error branch. | Accepted | +| D-126 | Core owns fallback resolution, paragraph sorting, technique/resource partitioning, stable instance slots, capacity growth/chunking, canonical instance packing, dirty ranges, resolved opaque render variants, and ordered `PreparedGlyphRun` values. One same-technique paragraph batch may produce several resource buffers and repeated ordered runs from one buffer. A run is not a promised draw. Engine programs may split or coalesce adjacent compatible runs and own final draw planning, but may not reshape, resort source text, reselect resources, or reallocate core slots; they preserve order unless a documented compositing policy proves another order equivalent. | Accepted | +| D-127 | Core retains one canonical technique-defined structure-of-arrays CPU representation for every prepared glyph batch and reports exact coalesced dirty ranges. Matching targets copy/upload those ranges 1:1; different engine layouts map only those fields and ranges. First or gapped synchronization initializes live ranges referenced by the current glyph runs. Targets never reshape, source-sort, resource-partition, or allocate core slots. The CPU shadow decouples core revisions from inaccessible or in-flight GPU memory and supports multiple or late targets; targets own engine staging, final draw compilation, GPU publication, fences, and retirement. | Accepted | +| D-128 | Bitmap, MTSDF, Slug, and any external raster remain distinct techniques and cannot coexist in one `FontStack` or `ParagraphBatch`. A renderer that deliberately combines data from two existing techniques is a new technique with its own artifacts, compatibility key, instance schema, resource bindings, and shaders. Different fonts within one technique normally remain distinct GPU resource batches; a technique may opt fonts into one physical key only when it actually provides a shared addressable GPU resource. | Superseded by D-161 | +| D-129 | The Three.js surface is `FontLoader`, `TextGroup`, and transform-bearing `Text`; it privately owns every core runtime, paragraph batch, paragraph, revision, attachment, and target. First loader use lazily initializes one cached runtime/shaper. `TextGroup` declares one technique, one construction-time `ThreeRasterProgram`, and one render phase; every `Text` owns a same-technique `Font` or `FontStack`, and standalone text derives an implicit batch. `updateMatrixWorld()` reconciles membership, invokes allocation-free-when-clean runtime update, commits the staged target, runs ordinary world-matrix traversal, and writes changed glyph transforms before render-list construction. The target copies core ranges, the program compiles glyph runs into draw meshes, and WebGPURenderer performs GPU writes/draws. `TextGroup` remains an `Object3D`, preserving the nearest real Group's primary `groupOrder`; its mutable `renderOrder` is the secondary base across compiled draws. | Accepted | +| D-130 | `FontStack` is an immutable ordered logical font selection, not a plural eligibility group: its first concrete font is primary and later same-technique fonts resolve missing glyphs. Every text-facing `font` field accepts a concrete `Font` or `FontStack`; batches and `TextGroup` never declare fonts or fallback. Core exports typed `txt` and `span` template helpers that flatten nested fragments into immutable UTF-16 string/span snapshots without parsing markup. The Three entry point re-exports those helpers directly, and React nested `` composition uses the same composer. Plain strings remain valid and clear spans when assigned. | Accepted | +| D-131 | Paragraph and text counts are not public capacity dimensions. Optional `GlyphBufferCapacity` has only `size` and `policy`, applied as glyph-instance slots independently to each physical technique/resource buffer. Explicit batches default to lazy `{ size: 4_096, policy: 'chunk' }`; standalone Three text defaults to `{ size: 256, policy: 'grow' }`. Chunk preserves buffers and adds fixed chunks, grow transactionally doubles until pending glyphs fit, and fixed makes `size` a hard per-buffer limit. Fixed overflow is knowable only after shaping, fails before publication, and is retained by Three rather than escaping render. Paragraph metadata grows normally, and core preserves logical order through glyph runs across every resulting buffer. | Accepted | +| D-132 | Three.js exposes no `TextGroup.allocate()` or second text-creation path. `new Text(properties)` creates one retained late-bound object; inherited `Object3D.add()` / `remove()` are its only membership operations, and removal does not dispose it. Glyph-slot allocation is an internal synchronization result. Core retains `ParagraphBatch.add(properties)` because `Paragraph` is not independently constructible. | Accepted | +| D-133 | `span()` accepts a renderer-neutral `SpanStyle` alone, or a same-technique `Font` / `FontStack` first followed by styles and compatible font overrides. `SpanStyle` combines paragraph style and glyph paint. Inputs merge left-to-right; later scalar fields or fonts replace earlier ones, while nested features, outline, and shadow replace as units. The returned immutable tag is reusable; readonly tuples preserve inputs for later binding. A style-only tag remains technique-neutral and inherits its surrounding font. | Accepted | +| D-134 | A detached Three.js `Text` owns reusable desired state but no core paragraph, batch, or GPU target. Direct scene rendering creates a text-owned implicit batch; moving into a group publishes destination membership before GPU-safe retirement of that target. Explicit-group slots and buffers belong to `TextGroup`, so removal recycles membership without disposing shared capacity. Even while detached, `Text.dispose()` is permanent: it cancels work, clears caches/references, and prevents reattachment. It neither mutates the scene graph nor disposes group buffers or fonts. Group disposal releases group resources without disposing children or fonts. | Accepted | +| D-135 | Core `Paragraph` handles are permanently owned by their creating `ParagraphBatch`; batch disposal cascades through those handles, while paragraph disposal never disposes its batch, runtime, or fonts. Core moves desired state by immutable snapshot plus destination `add()`, never by transferring a handle. Paragraphs and Three `Text` objects lease every selected concrete font for their full retained lifetime, and font disposal fails while leases remain. Three `Text` owns its desired snapshot and leases independently of group binding, so disposing a populated `TextGroup` unbinds but does not dispose its text; each live compatible text may create fresh membership elsewhere. A disposed group left in the scene graph remains a terminal non-rendering boundary rather than falling through to an ancestor or implicit batch. | Accepted | +| D-136 | Fixed capacity forbids automatic growth, not an explicit owner-directed capacity change. Core `ParagraphBatch.setCapacity(capacity)` preserves the batch, every paragraph handle, subscriptions, and attachments; it clears a latched capacity failure only when the normalized value changes, stages replacement canonical storage at the next synchronization, publishes atomically, and leaves the prior revision live on failure. Existing attachments record that source; each target stages replacement engine buffers on its owner's next `prepare()` and retires old buffers after its fences. Three `TextGroup.setCapacity()` and standalone `Text.setCapacity()` preserve public object identity and forward to their effective or retained implicit batch. The setter records capacity intent; it does not promise immediate allocation. `TextGroup.clone()` and `copy()` are unsupported because recursive copying would silently duplicate identity-bearing text, refs, listeners, membership, and renderer state. | Accepted | +| D-137 | `ParagraphBatch.attach(target)` is the standard retained renderer coordinator, not a privileged preparation API. The public observer replays `current`, reports later revisions, and completes on disposal, so another coordinator needs no private shaping/allocation access. Publication only records the newest attachment source; the observing engine calls `attachment.prepare()` to stage its own target and `commit()` at its safe boundary. `attach()` owns technique validation, cancellation, retained target failure, and cascading disposal. `dirtyRanges` is an adjacent-revision delta: first or gapped synchronization initializes live ranges named by current glyph runs, while adjacent synchronization uploads only the delta. Targets consume or copy canonical ranges during synchronous `stage()` and never retain mutable views across later publications. | Accepted | +| D-138 | The next API splits the current combined `RasterModule` into a renderer-neutral `RasterTechnique` and engine-owned `ParagraphBatchTarget`. One portable technique owns artifact decoding, hash-validated external resource resolution, retained CPU page/table data, glyph-to-resource binding, canonical instance schema, and packing. Every prepared glyph batch exposes that typed binding, so targets create textures/buffers without rediscovering page or resource membership. A concrete technique definition infers and preserves its exact options, descriptor, decoded data, binding, and storage types; the common heterogeneous boundary exposes those associated values as `unknown` rather than erasing them with `any`, and requires narrowing before technique-specific work. GPU resource creation, shaders, pipelines/materials, scene/pass integration, submission, fences, and retirement remain outside core. An optional adapter-level `RasterProgram` may share shader/resource realization across engines using the same backend: TypeGPU programs can be reused where hosts prove compatible WebGPU device/pass interop, while TSL programs remain Three.js-specific. Bakers and portable technique entry points import no engine or shader backend. | Accepted | +| D-139 | Evaluate TypeGPU functions as an optional source for shared WebGPU raster logic. At `three@0.185.1`, `typegpu@0.11.9`, and `@typegpu/three@0.11.0`, `toTSL()` injects a nullary WGSL closure through Three's WebGPU builder; it is not native TSL conversion, has no forced-WebGL2 route, and has not carried Slug's sampleable resources. A TypeGPU `RasterProgram` may still serve direct WebGPU hosts. This remains an isolated experiment inside the maintained `/typegpu` and `/three` subpath work: acceptance requires exact-version compilation, real resource/loop/vertex proofs, generated-shader inspection, Bitmap and Slug parity, backend coverage, and measured transfer/graph/compilation cost. Core and portable techniques import neither TypeGPU nor TSL. | Experiment | +| D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | +| D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | +| D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | +| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | +| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | +| D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | +| D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | +| D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | +| D-154 | `RasterTechnique.select()` returns `undefined` when a shaped glyph intentionally has no renderable raster record, including whitespace. The built-in implementation found that a mandatory selection would allocate invalid physical instances for absent records. Font fallback and missing-glyph policy finish before raster selection; omission is only the explicit no-instance result for the already-resolved glyph. | Proposed | +| D-155 | A `TypeGpuRasterProgram` owns an exact `createTarget()` factory rather than exposing an incomplete fixed list of font-resource, pipeline, and run-compilation methods. The first implementation proved that the earlier sketch had no operation capable of creating or partially updating program-specific instance buffers from canonical storage and `dirtyRanges`. The returned public target owns those buffers, textures/tables, variant sidecars, bind groups, pipelines, staged revisions, encoding, and retirement while core remains unchanged. Built-in programs implement the factory; advanced hosts may coordinate its returned target through the ordinary public attachment contract. | Proposed | +| D-161 | Raster technique is a loaded-font/resource binding and no longer a user-authored property of `FontStack`, `ParagraphBatch`, Three `Text`, or `TextGroup`; this supersedes the technique-homogeneity clauses of D-123, D-126, D-128, D-129, D-130, D-133, and D-137 while preserving their remaining ownership, lifecycle, and ordering rules. One immutable ordered stack may contain different techniques from the same runtime, and fallback chooses fonts from shaped glyph availability without consulting renderer support. A validated render-plan policy declares its supported technique IDs, program/resource/schema mappings, paint and compositing capabilities, batching rules, and augmentations. Every first-party engine policy supports the shipped Bitmap, MSDF, and Slug techniques; third-party engine policies may safely expose a subset and grow it independently. Binding rejects stacks containing an undeclared technique, and preparation rejects unsupported resolved capabilities before atomic publication. Core partitions resolved glyphs by technique, resource, and program while preserving logical/compositing order and revision-relative patches. A mixed stack requires no synthetic composite technique or cross-technique artifact. | Accepted | +| D-162 | Retained text storage uses 16-byte-aligned SoA lanes in ABI-private 64-cluster chunks. The standard Web shaper is one compile-time `simd128` artifact: straight-line record packing stays compiler-vectorizable source, while break masks, bidi transition masks, and integer-exact chunk summaries use explicit 128-bit kernels plus scalar tails. There is no runtime dispatch. `PMNDRS_TEXT_SHAPER_SIMD=0` is the build-time release valve for a same-ABI scalar artifact. The scalar implementation remains the byte oracle. The 25,515/100,602-glyph Node and Chromium packet rejected hand-written packing and 32/128-cluster chunks, admitted the explicit mask/summary kernels, found no warm allocation or memory growth, and kept the selected lab delta to 1,158 Brotli bytes; policy execution, boundary search, native SIMD, and end-to-end contribution remain required measurements when those stages exist. | Accepted | +| D-163 | Validated render policies execute in Rust over borrowed semantic SoA inputs and policy-declared physical buffers. Registration resolves store buffer IDs once; each call preflights field shape, output schema, capacity, and live direct-memory ownership before writes. The scalar interpreter is the byte oracle. The standard Wasm artifact dispatches each straight-line operation over four `simd128` records with scalar tails; compiler auto-vectorization is rejected because it remained within scalar variance. Across 25,515 glyphs, explicit SIMD improves the representative 17-operation policy from 1.174 to 0.428 ms p95 in Node and 1.113 to 0.438 ms in Chromium with identical output bytes and no warm allocation or growth. The production SIMD module is 530 raw bytes smaller and 62 Brotli bytes larger than the same-ABI scalar build. This closes the policy-execution measurement left open by D-162; boundary search, native SIMD, and end-to-end contribution remain open. | Accepted | +| D-164 | The V0 render plan is a portable display-list and resource transaction encoded field-wise in canonical little-endian bytes, not bytecode, a backend command buffer, or a raw Rust-struct image. Its 144-byte aligned header carries revision/publication identity plus policy handle, capability set, and deterministic validated-policy fingerprint. Compiler-mapped fixed records cover semantics (44 bytes), resources (40), buffers (36), patches (36), primitives (64), draws (60), retirements (24), and diagnostics (24). Resource kind is distinct from create/update/retain action; ordered-direct and stable-indirect are explicit buffer strategies. Write-patch payloads are checked spans inside the same immutable publication and are rebased to absolute result offsets; other patch operations carry no payload address. Draw packets carry numeric material, clip, and depth identities rather than renderer objects or callbacks. Validation completes before the inactive A/B arena is touched, and failure headers expose no policy or table state. | Accepted | +| D-165 | Render-policy registration remains one compiler-mapped direct-memory transaction: a 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte physical-buffer, and 16-byte operation records. Capability-set ID participates in program selection and frame validation; exact programs override set-agnostic programs. Capability records own backend flags, binding/draw limits, update alignment, and upload-cost integers; programs own technique/resource support, requested semantic views, independent storage/draw compatibility keys, and ordered-direct or stable-indirect allocation; buffer records own scalar/vector shape, alignment, padded stride, usage, and capacity class. The draw key may include material while the storage key omits it, producing material-split draws over shared glyph buffers, or both may include material when a backend/schema requires physical partitioning. V0 outputs are independently bindable vector streams rather than aliased mutable interleaved fields: policy bytecode packs wider `vec2`/`vec4` records, preserving disjoint direct borrows and the WebGL-compatible shapes already used by MSDF and Slug. Unknown flags, unsupported allocation/capability combinations, malformed costs/strides, and undeclared update capability sets fail before publication or revision change. | Accepted | +| D-166 | Ordered-direct retained storage uses stable instance IDs plus explicit semantic content revisions as its invalidation authority; it never scans old/new physical bytes to discover changes. Preparation groups records by policy program/resource, maintains an allocation-free warm open-addressed identity set and reusable scratch, coalesces aligned writes with the registered integer cost model, and sends consecutive changed records through the SIMD policy executor. No-op, localized rewrite, suffix-moving insertion, tail retirement, checkpoint/growth, and abort are distinct transactions. CPU mirrors update only after plan serialization succeeds. Dirty updates publish complete compact resource/buffer bindings and ordered glyph-span/draw tables while physical payload stays range-minimal; one span covers consecutive compatible physical records and splits at the 65,535-record wire limit. Stable-indirect storage, session wiring, and performance claims remain unimplemented rather than inferred. | Accepted | +| D-167 | `material` replaces `renderVariant` as the single batch → paragraph/text → span rendering property and `material_id`/`materialId` names its numeric Rust/wire identity. Material changes never reshape or relayout. A registered policy independently declares whether material partitions draw packets and physical storage; every first-party policy splits draws, while capability-specific storage may remain shared or partition when its addressing/schema requires it. Rust never stores a renderer object or invokes a host callback. The renderer maps IDs to retained material/pipeline state and owns construction, caching, fences, and disposal. The bespoke `TextEffect`/effect-list vocabulary is cut. A Three material factory over canonical technique shaders remains the intended integration, but its exact public types stay explicitly open in the material-authority concept. | Accepted | +| D-168 | Stable-indirect storage assigns semantic identities to persistent physical record slots and represents logical order in fixed 64-entry chunks. Insertions and reorders do not move unchanged physical records; content revisions, not byte comparison, select record writes. Deleted record slots and removed order chunks enter publication-generation quarantine and cannot be reused until the renderer explicitly acknowledges the corresponding GPU-safe fence. `consumed_plan_revision` is not that acknowledgment: applying a plan does not prove queued GPU work completed. Prepare, commit, and abort remain transactional; abort restores tentatively claimed reusable slots/chunks. Local order edits preserve whole prefix/suffix chunks when capacity permits, while order-buffer growth republishes every live chunk because a new allocation has no assumed contents. The allocator and chunk reconciler are implemented and tested; full stable-indirect render-plan compilation, session acknowledgment wiring, and target timing remain open. | Accepted | +| D-169 | The stable-indirect render-plan compiler binds each policy-defined physical stream with one reserved `u32` logical-order buffer (`policy_buffer_id = 65,535`). Glyph primitives address consecutive order records; draws bind the order buffer and physical streams while preserving the independent storage/draw key contract. A localized insertion retains existing physical slots and, in the one-stream fixture, emits one 4-byte physical record plus one affected 16-byte order-chunk write; a pure reorder emits only the order write. The capability fragmentation budget also bounds physical order spans: exceeding it transactionally rebases only the order buffer into dense chunks, increments that buffer generation, retires the old generation after its fence, and leaves glyph-buffer generations unchanged. No-op, abort, mixed-resource ordering, shared/partitioned material storage, fence-gated slot reuse, wire validation, and settled scratch capacities are tested. Session wiring, the dedicated renderer-fence acknowledgment request field, and target-hardware planner timing remain open rather than inferred. | Accepted | +| D-170 | A render-plan frame may resolve policy programs using both ordered-direct and stable-indirect allocation. The dispatcher does not materialize strategy-specific glyph/field partitions: each compiler filters the same borrowed semantic input, then only renderer-facing records are merged. Homogeneous frames retain the one-compiler direct-view fast path. Ordered buffer IDs occupy `1..=0x7fff_ffff`; stable physical and order buffers occupy `0x8000_0000..=0xffff_ffff`. Mixed publication rebases payload spans, deduplicates and validates resources, filters a retirement when the other strategy keeps that resource generation live, and merges draws by their original global order token. Alternating strategies, cross-strategy resource continuity, zero-output no-op publication, and settled merge capacities are tested. Wasm session wiring and target timing remain open rather than inferred. | Accepted | +| D-171 | Every engine session owns one Rust render-plan dispatcher and pins the first committed policy handle/fingerprint; capability-set changes remain legal within that policy. The compiler-derived request is 124 bytes and carries `acknowledged_publication_generation` independently from `consumed_plan_revision`. Acknowledgment is monotonic, cannot name the pending publication, and survives an aborted update because it reports an already-completed renderer fence. Wasm prepares and views the Rust plan, validates/stages it in the inactive A/B arena, then commits planner and session revision together; every failure before commit aborts prepared planner state. Making both planners reachable increases optimized Wasm from 739,909 / 272,624 / 214,395 to 822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. Nonempty semantic input and Rust shaping/layout timing remain unimplemented rather than inferred. | Accepted | +| D-172 | V0 semantic update sections use compiler-mapped fixed records: 24-byte ordered UTF-16 replacements, 88-byte stable style upserts/removals, 52-byte flow constraints, 8-byte flow vertices, 56-byte regions, 48-byte exclusions, and 56-byte inline objects. Text payload stays UTF-16 so every mutation and output cluster uses the repository's canonical indexing without a host UTF-8 conversion. Regions and exclusions have an inline rectangle bounds fast path or bounded polygon vertices referenced inside the same request; the request declares all geometry before one Rust layout call. Style records carry stable identity separately from authored cascade order, stated-field presence, shaping, spacing, raster density, material, color, and decoration inputs, while language/features remain checked offset-addressed payloads. Declaring these layouts does not yet admit or retain nonempty style sections and carries no shaping/layout timing claim. | Accepted | +| D-173 | Text mutation admission is a borrowed, allocation-free decode over 24-byte ordered UTF-16 replacement records and offset-addressed little-endian payloads. The decoder rejects noncanonical empty offsets, unknown encoding/opcode, nonzero reserved fields, arithmetic/range/alignment failures, and payload overlap with the record table before engine mutation. Each session applies replacements sequentially to retained scratch, commits by swapping only after plan serialization/commit, and clears scratch on abort or any invalid later replacement; completed renderer-fence acknowledgment remains independent. Cold request growth uses `reserveSession` before re-pinning, while same-capacity edits neither grow Wasm memory nor allocate mutation objects. The reachable slice changes optimized Wasm from 822,469 / 306,502 / 242,707 to 825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616). This retains real text through `text_update`, but shaping/layout and nonempty plan output remain unimplemented and unmeasured. | Accepted | +| D-174 | Cold capacity is split by ownership instead of reserving the 25K-glyph envelope per text object. Every session creation prewarms retained active/pending UTF-16, Unicode, bidi, shaping-run, shaped-glyph, and fallback arrays to 1,024 text units by default, with shaped-glyph capacity at 2×; `createSession` and `reserveSession` accept an explicit text capacity and retain the resulting high-water mark. These arrays are session-owned because committed results must survive across multiple live sessions. The compiler-published `initialize()` export runs after Wasm instantiation and prewarms reusable module-global synchronous scratch: the 32,768-entry policy-gather arena, HarfRust's actual 32,768-codepoint buffer, and UTF-16 context scratch, covering the 25,515 target fixture without multiplying that largest reservation per session. Initialization settles 57 Wasm pages and is identity-stable when repeated. Policy registration cold-reserves its exact field lanes. Line and geometry-output workspaces remain open; legacy batch-result vectors are outside the final frame claim. Warm `text_update` may not lazily settle capacity within declared envelopes. | Accepted | +| D-175 | Font stacks are cold-registered Rust engine values containing one nonempty, duplicate-free ordered list of existing shaping-font handles. Registration is idempotent only for byte-equivalent order, and a registered stack retains its member fonts so disposal cannot leave a dangling fallback reference. Stack identity is separate from each font's render binding: technique, resource directory, glyph records, and packing lanes are owned once per loaded font rather than copied into every stack. The cold registry uses a compact vector rather than another generic tree map: the rejected map build measured 837,865 raw / 312,057 gzip / 246,478 Brotli bytes, while the selected build is 828,401 / 309,252 / 244,402. The direct-memory lifecycle is outside `text_update`; compiled Wasm with a real baked font proves registration, retention, exact missing-stack status, release, and final font disposal. This establishes fallback ownership but does not claim that fallback shaping or raster binding has landed. | Accepted | +| D-176 | Every render-policy program declares an exact ordered input-source list matching its F32 fields followed by its U32 fields. Each 4-byte compiler-mapped record names a numeric `semantic`, `glyph`, `resource`, or `strike` scope plus a typed lane index; there is no callback or renderer object. Sources are validated and retained at cold registration, participate in the deterministic policy fingerprint, and direct gather from layout state and per-font render bindings into the existing at-most-32-field SIMD policy executor. The request/program records are now 44/64 bytes. Rust and compiled-Wasm tests cover exact decoding, unknown tags, cardinality, fingerprint conflict, reserved data, and overlap. The reachable registration checkpoint changes optimized Wasm from 828,401 / 309,252 / 244,402 to 829,906 / 309,646 / 244,790 raw/gzip/Brotli bytes (+1,505 / +394 / +388); D-178 lands execution. | Accepted | +| D-177 | A shaping font owns one immutable normalized render binding: one technique/program variant, field-major glyph lanes, one scalable strike or strictly increasing physical ppem strikes, dense strike×glyph resource addresses and lanes, and field-major lanes over a strictly resource-ID-ordered directory. MSDF and Slug use the scalable strike; Bitmap selects nearest `fontSize × rasterPixelRatio` and keeps the lower exact tie. Missing glyph resources use one sentinel. This avoids a union record containing every built-in technique's fields, keeps four neighboring rows contiguous for policy gather, and makes cold resource uniqueness validation linear. Registration is a compiler-mapped direct-memory operation, requires the exact shaping glyph count, owns decoded state, is idempotent only for an equal binding, and is released with the font. Rust hostile-wire tests and a real-Inter compiled-Wasm lifecycle test pass. Reachability changes optimized Wasm from 829,906 / 309,646 / 244,790 to 838,060 / 312,606 / 246,732 raw/gzip/Brotli bytes (+8,154 / +2,960 / +1,942). Gather and frame timing remain open. | Accepted | +| D-178 | Policy gather uses one engine-global reusable workspace. Each glyph's selected program fills the same ordered field slots from semantic, font-wide glyph, selected-strike, and selected-resource sources; program-grouped plan execution makes a technique-union record unnecessary. Typed fields are contiguous 16-byte-aligned four-record blocks with scalar tails. `initialize()` reserves the policy-independent 32,768-entry, 60-byte `PlanGlyph` arena; policy registration reserves only that policy's maximum F32/U32 lane counts to the same capacity. Compiled Wasm memory settles 1,245,184→3,342,336 bytes at first initialization and 3,342,336→3,538,944 for a one-F32-lane policy; repeated initialization/registration does not grow. A Rust proof gathers all four scopes into a nonempty ordered plan with exact bytes and unchanged capacity. The production frame reaches the gather with empty layout input, so nonempty frame timing remains open. Reachability changes optimized Wasm 838,060 / 312,606 / 246,732→845,580 / 315,285 / 249,221 raw/gzip/Brotli bytes (+7,520 / +2,679 / +2,489). | Accepted | +| D-179 | Editorial flow geometry is a complete borrowed section of one `text_update`, never a host measurement callback. A nonempty geometry transaction contains at least one constraint and region and may contain bounded rectangle/polygon exclusions and text-anchored inline objects. Compiler-published enums define axis, wrap, inline/block alignment, overflow, writing mode, orientation, exclusion side, and baseline tags. Rust validates finite ordered bounds, polygon vertices, limits, unique identities, region/exclusion ranges, text offsets after pending mutations, reserved fields, and all table/payload overlap before state mutation. Sessions transactionally retain a semantic geometry fingerprint while layout will consume the borrowed records directly; pointer-only vertex offsets are excluded so equivalent repacking is deterministic. Compiled Wasm accepts a complete rectangle/exclusion/object request in one update and rejects a forged region reference without advancing the A/B publication. Optimized Wasm changes from 847,814 / 315,809 / 249,629 to 856,832 / 318,999 / 252,620 raw/gzip/Brotli bytes. Styles remain rejected until their retained mutation model lands. | Accepted | +| D-180 | The retained style ABI uses an explicit authored `cascadeOrder` independent from stable `styleId`; ID allocation therefore cannot change equal-range precedence. Its `fieldMask` is stated-property presence, not a dirty hint, so absent values inherit and explicit zero-valued declarations remain representable. The 88-byte record also carries `rasterPixelRatio` for Rust-owned bitmap strike selection, although target density remains a root target property rather than a per-span typography feature. Compiler-published style, field, decoration-style, and decoration-flag vocabularies prevent host-local tag drift. This decision fixes the wire contract only: nonempty style sections remain rejected until validation and transactional retained storage land. | Accepted | +| D-181 | Nonempty style mutations are admitted only with their Rust consumer. Decoding borrows canonical fixed records and monotonically packed offset payloads without allocation. Each session pre-reserves two flat 64-style/512-language-byte/128-feature arenas and reusable mutation/order/nesting scratch. Mutations collapse by stable ID and merge with committed ID-sorted state into a compact inactive arena; no per-style vector or stale replaced payload survives. Rust validates stated versus absent bytes, numeric domains, language/tags, UTF-16 feature/range boundaries, binary-searched font-stack reachability, one complete root, unambiguous equal-range cascade order, proper nesting, and all request-section aliasing before plan preparation. Payload admission is linear and retained validation is O(n log n), with one reusable-scratch sort. Commit swaps arenas and abort preserves committed state. A compiled-Wasm real-font transaction commits text plus root style and rejects root removal without revision advance or post-creation memory growth. Optimized Wasm changes from 856,831 / 319,003 / 252,236 to 888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes. Layout consumption and latency remain open. | Accepted | +| D-182 | Rust resolves retained stated styles into a derived A/B segment arena before shaping. One containment sweep stores the fully resolved parent in pre-reserved scope scratch, applies each stated field once, restores parent values on close, and coalesces adjacent semantically equal segments. Stable identity is irrelevant to precedence; containment and explicit authored order govern equal ranges. Language and feature results reference retained compact payloads rather than copying per segment. Root font stack, logical size, and target density are required; line height may remain absent to select natural font metrics. A nested/equal-range proof emits five exact maximal segments and verifies inherited shaping, spacing, paint, material, language, and features. Optimized Wasm changes from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 raw/gzip/Brotli bytes. Unicode/run intersection, shaping, layout, and nonempty plan output remain open. | Accepted | +| D-183 | Unicode analysis is derived session state inside the Rust frame transaction. The existing pinned Unicode 17 generator emits TypeScript tables and compact Rust Script/Script_Extensions partitions from one source; the Rust partition omits derivable starts. `unicode-segmentation` 1.13.3 runs under `no_std`, validates retained UTF-16 through a reusable UTF-8 scratch string, returns extended-grapheme boundaries to the public UTF-16 coordinate space, and feeds allocation-reusing contextual script itemization. Active and pending analysis arenas reserve with session text capacity, swap only on commit, abort with a failed frame, and are untouched when text is unchanged. Rust unit tests cover Emoji ZWJ, Indic/Kana scripts, shared marks, malformed surrogates, rollback, and capacity reuse; host/SIMD Clippy and compiled-Wasm lifecycle tests pass. Optimized Wasm changes from 895,593 / 335,396 / 264,355 to 964,019 / 360,765 / 288,742 raw/gzip/Brotli bytes. Bidi/run intersection, fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-184 | Bidi analysis and shaping-run itemization are derived A/B session state. `unicode-bidi` remains the UAX #9 algorithm and fills reusable level, class, paragraph, and equal-level-run arrays; text and root base-direction changes re-run it, while unchanged text/style skips it. Root direction selects the paragraph base level. A non-root stated LTR/RTL direction is retained as a distinct derived override, inherited through its scope, and forces only the intersected run's level parity. One forward interval sweep intersects maximal resolved styles, contextual script items, and equal-level bidi runs, excludes mandatory hard-break controls, and coalesces equal adjacent shaping records. Session text capacity pre-reserves active/pending bidi and run arrays. Unit tests cover output reuse, mixed Latin/Hebrew levels, root-only direction updates, nested override parity, hard-break exclusion, and transaction rollback; host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 964,019 / 360,765 / 288,742 to 968,086 / 362,664 / 286,438 raw/gzip/Brotli bytes. HarfRust fallback shaping, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-185 | Frame shaping consumes retained runs through a borrowed HarfRust input rather than constructing the legacy owned batch request. Legacy exports and `text_update` share the module-global prewarmed UnicodeBuffer, UTF-16 context scratch, and reusable 128-feature vector. Frame language/features borrow the active retained style arena. Each shaped run appends its font/source identity and glyph ID, UTF-16 cluster, advances, offsets, and flags directly into a pre-reserved A/B session SoA arena that swaps only on commit. The production Wasm update borrows the one existing `ShaperRegistry`; it does not duplicate font bytes or serialize shaped output through the old ABI. A compiled real-Inter test proves shape-plan cache count changes 0→1 only after `text_update` and remains unchanged after an aborted frame. Rust unit tests, host/SIMD Clippy, and focused compiled-Wasm tests pass. Optimized Wasm changes from 968,086 / 362,664 / 286,438 to 973,367 / 364,517 / 287,942 raw/gzip/Brotli bytes. This checkpoint shapes only each stack's primary font; ordered fallback, layout, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-186 | Ordered fallback is resolved inside the same Rust frame transaction from actual HarfRust `.notdef` output, never from `cmap`, raster coverage, or a host callback. Reusable flat spans name source run, UTF-16 range, stack index, and concrete font; reusable cluster records collapse multi-glyph clusters with missing status ORed across glyph zero. Records sort by source run/logical cluster to normalize RTL output, then one linear merge advances only missing ranges. Font index increases monotonically, bounding passes by stack depth. Final spans and shaped SoA commit together and abort together. A compiled Inter→Noto Devanagari `text_update` constructs exactly two HarfRust plans, causally proving primary and fallback shaping. Host/SIMD Clippy and focused compiled-Wasm tests pass. Optimized Wasm changes from 973,367 / 364,517 / 287,942 to 982,356 / 368,183 / 290,439 raw/gzip/Brotli bytes. Layout/gather still receive no glyphs, so nonempty plan output and complete-path timing remain open. | Accepted | +| D-187 | Unicode 17 line-break opportunities are retained Rust Unicode state, not host input. The pinned `@cto.af/linebreak` 4.0.3 property trie is generated into a compact scalar partition containing resolved line-break class and only the punctuation, East Asian, and unassigned-extended-pictographic flags its ordered UAX #14 rules consume. A specialized `no_std` Rust evaluator reuses flat scalar/break arrays, returns UTF-16 offsets, retains the upstream MIT notice, and commits/aborts with grapheme/script analysis. All 19,338 unchanged official `LineBreakTest` cases pass, plus focused required-break tests; host/SIMD Clippy passes. Optimized Wasm changes from 982,356 / 368,183 / 290,439 to 1,009,460 / 377,053 / 295,875 raw/gzip/Brotli bytes. Cluster measurement, composition, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-188 | Measured clusters are retained A/B Rust SoA state derived from final fallback-shaped glyphs and Unicode analysis: grapheme UTF-16 starts/ends, ordered `f64` advances, compact safe/allowed/required/hard-break flags, resolved-style index, source-run/font identity, and one offset-to-cluster index. Glyph positions remain `i32` design units through shaping and are scaled once with cached per-font UPEM; letter spacing, word spacing, and zero-width hard breaks join that ordered accumulation. Optional UAX #14 breaks are admitted only when the next grapheme is a HarfRust-safe boundary. A synthetic kernel proof fixes exact `[9, 7, 9, 0]` advances for design widths plus letter/word spacing and proves unsafe suppression; compiled real-font primary/fallback updates reach the pass. Optimized Wasm changes from 1,009,460 / 377,053 / 295,875 to 1,014,577 / 379,510 / 295,708 raw/gzip/Brotli bytes. The Brotli decrease is compressor interaction, not a speed claim. Composition, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-189 | The allocation-free Rust `layout_next_line` kernel consumes retained clusters through a grapheme cursor and one caller-supplied `f64` width. Word mode prefers safe UAX #14 opportunities and falls back to HarfRust-safe boundaries; character mode accepts every safe grapheme boundary; no-wrap ignores width but preserves required breaks; over-wide first clusters always make progress; and a terminal hard break emits the canonical trailing empty line. Focused tests prove these modes and cursor termination. The kernel is not yet connected to region-band resolution or frame output, so this checkpoint makes no end-to-end timing or production Wasm-size claim. | Accepted | +| D-190 | Validated flow snapshots become retained A/B Rust state during the production update transaction. Constraints, ordered regions, exclusions, and polygon vertices are copied into reusable session arrays; vertex offsets are rebased, so no request pointer survives `text_update`. The rectangle fast path subtracts intersecting exclusions through two reusable sorted slot vectors, applies each declared wrap side, and enforces `max_slots_per_band`. An exact fixture maps region `0..100` minus exclusion `20..40` to slots `[0..20, 40..100]`; a polygon fixture proves three vertices survive as owned values. Optimized Wasm changes from 1,014,577 / 379,510 / 295,708 to 1,016,720 / 384,593 / 297,049 raw/gzip/Brotli bytes. Polygon band intersection, line-cursor connection, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-191 | Bounded simple polygons resolve through retained allocation-reusing sweep scratch. Region bands intersect normalized cross-sections at every in-band vertex boundary and within each linear-edge slab; polygon exclusions conservatively project vertices and band-edge intersections over the margin-expanded block range before applying the declared wrap side. Horizontal boundary segments participate before interval normalization. Exact fixtures cover a serialized triangle, a concave U region yielding `[0..40, 60..100]`, a diamond exclusion yielding `[0..20, 60..100]`, and explicit `ResultTooLarge` when two normalized slots exceed a one-slot envelope. The kernel is not yet called by frame line placement, so no production Wasm-size or complete-path timing claim is made. | Accepted | +| D-192 | Production horizontal frame layout now consumes retained clusters and flow geometry into transactional line/fragment A/B arrays. A session-global slot workspace remains allocation-free after its declared high water mark. A band composes every available slot on one baseline, derives ascent/descent/line-height from each cluster's actual fallback font and resolved style in `f64`, and retries at most once with the maximum metrics found by the first widest pass. Enlarging the band conservatively intersects region slots and expands exclusion projection, so the retry consumes a subset rather than exposing later styles. Exact tests produce two fragments around a hole, raise a 10 px estimate to a 20 px line/16 px baseline, and overflow sequentially through region IDs `[1, 2, 2, 2]` without balancing. Rebuilt frame ABI and real-font/fallback integration tests pass. Optimized Wasm changes from 1,016,720 / 384,593 / 297,049 to 1,039,404 / 392,671 / 303,705 raw/gzip/Brotli bytes. Vertical flow, final positioning, boundary reshaping, nonempty plan output, and complete-path timing remain open. | Accepted | +| D-193 | Stable render identity starts with transactional UTF-16 unit IDs, not mutable offsets or collision-prone content hashes. A/B unit-ID arrays undergo the same ordered replacements as text; only inserted units receive monotonic nonzero IDs, and abort restores both committed IDs and the allocator cursor so retry is deterministic. Each grapheme inherits its first unit's ID. An exact fixture preserves `[1,2,3,4]` through abort, then grows an edit to `[1,5,6,3,4,7]`, proving that the shifted `c/d` suffix retains IDs `3/4`; a later first-unit replacement yields `[8,5,6,3,4,7]`. Optimized Wasm changes from 1,039,404 / 392,671 / 303,705 to 1,041,582 / 393,214 / 304,815 raw/gzip/Brotli bytes. Glyph-range allocation, per-glyph revision, positioning, and nonempty plan output remain open. | Accepted | +| D-194 | Logical clusters retain flat shaped-glyph adjacency built by count, prefix sum, and fill. This preserves each shaped run's glyph order while allowing line positioning to traverse cluster slices directly, including RTL output; no per-glyph object, search, or map enters the hot path. An exact fixture maps shaped clusters `[2,1,0]` to logical glyph-index slices `[2]`, `[1]`, `[0]`, and rebuilding preserves every adjacency-array capacity. Optimized Wasm changes from 1,041,582 / 393,214 / 304,815 to 1,043,289 / 394,074 / 304,902 raw/gzip/Brotli bytes. Stable glyph allocation and positioning remain open. | Accepted | +| D-195 | The stable plan pool and glyph reconciliation share one retained exact identity index. Its open-address hash chooses only a probe position; full-key equality decides matches, duplicate keys fail, and epoch clearing makes same-capacity prepare allocation-free. A collision fixture proves these properties. The isolated refactor changes optimized Wasm from 1,043,289 / 394,074 / 304,902 to 1,043,094 / 394,035 / 307,259 raw/gzip/Brotli bytes. The +2,357 Brotli regression is accepted to avoid a second correctness implementation and will be remeasured once glyph reconciliation makes the shared consumer reachable. | Accepted | +| D-196 | Per-glyph stable IDs reconcile transactionally by stable cluster ID and glyph ordinal. Existing ordinals retain monotonic IDs; inserted clusters and added ordinals allocate new IDs; cluster abort also aborts the allocator cursor. A fixture maps prior cluster IDs `[[1,2],[3]]` to `[[4],[1],[3,5]]` and reproduces the same result on retry without capacity growth. Positioning, not shaping, will compare exact final content and own `content_revision`. Optimized Wasm changes from 1,043,094 / 394,035 / 307,259 to 1,044,797 / 395,222 / 307,795 raw/gzip/Brotli bytes. | Accepted | +| D-197 | Horizontal positioning is retained Rust state and directly feeds policy gather. UAX #9 L1/L2 visual order, slot-local alignment/justification, actual fallback metrics, HarfRust offsets, baseline shift, baked extents, and positive-down bounds execute with `f64` accumulation and one narrowing. Six F32 and four U32 semantic SoA lanes remain policy-readable. Exact final bits assign transactional content revisions; a one-pixel fixture proves no-op revision reuse and changed-content advancement. Compiled real-Inter `text_update` publishes nonempty resource/buffer/patch/primitive/draw tables, while an identical warm frame preserves Wasm memory identity and emits zero patches. Optimized Wasm changes from 1,044,797 / 395,222 / 307,795 to 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes. Complete-path 25,515-glyph timing remains unmeasured. | Accepted | +| D-198 | Retained frame invalidation compares exact committed and pending semantic state rather than treating every style or geometry transaction as a full pipeline change. Direction changes restart bidi; shaping inputs restart shaping; metric inputs rebuild measured clusters and flow; positioning/paint inputs rebuild positioned semantics; exact geometry equality with no inline objects skips flow; and an unchanged ordered-direct frame publishes an empty reuse transaction without scanning glyphs. The 25,515-glyph, 8-warmup/31-sample Node run measures Rust cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit at 13.693/0.001/4.090/3.374/13.927/13.986 ms median and 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are 55.25/11.90/8.36/38.55 ms. This proves the invalidation direction, not final renderer parity: the Rust lane still executes a one-F32 policy rather than canonical Bitmap's five buffers, so full Bitmap packing remains a required comparison before publishing speedup ratios. Optimized Wasm is 1,060,175 / 400,500 / 316,984 raw/gzip/Brotli bytes. The sequential benchmark process reaches 76.25 MiB after multiple disposed sessions; that is not a per-session requirement or an accepted memory target. | Accepted | +| D-199 | Renderer-parity performance evidence uses validated real baked records and the complete first-party GPU buffer schemas rather than a representative one-lane policy. Bitmap emits five buffers and 48 bytes per renderable instance; MTSDF emits seven vec4 buffers and 112 bytes; Slug emits five float vec4 plus two unsigned vec4 buffers and 112 bytes. Derived foreground channels and inverse font size are gathered on demand without retained glyph arrays. Absent raster records are intentionally omitted, matching the portable techniques. SIMD execution retains SoA inputs, transposes four output records in registers, and writes tightly packed vec2/vec4 records with contiguous 128-bit stores; the schema-identical scalar build remains the baseline. On the unchanged 25,515-positioned-glyph stress text (21,805 renderable instances), five warmups and 11 samples measure Bitmap/MTSDF/Slug font-size medians of 5.506/6.396/7.237 ms and full-column-resize medians of 4.477/5.276/6.259 ms. This fails the sub-4 ms gate and therefore mandates dependency-directed physical-buffer execution and publication: resize recomputes geometry only, font size recomputes geometry plus Slug inverse scale, and static UV/color/band/address/count outputs remain retained. Optimized Wasm is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 57feefe0..c1296566 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1133,13 +1133,22 @@ pipeline before adding new publishing features. The single ABI is its final cuto Current checkpoint evidence: the retained Rust transaction reaches real Inter shaping, fallback, measured clusters, horizontal editorial flow, positioning, policy gather, and a nonempty render plan. Exact invalidation lets unchanged ordered-direct frames publish an empty reuse transaction and lets font-size changes reuse Unicode, bidi, and shaping. -On the established fully active 25,515-glyph stress case with eight warmups and 31 samples, request copy plus -`text_update` measures 13.693/0.001/4.090/3.374/13.927/13.986 ms median for +The original one-F32 diagnostic over 25,515 positioned glyphs measured +13.693/0.001/4.090/3.374/13.927/13.986 ms median for cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit. The unchanged TypeScript comparison measures -55.25/11.90/8.36/38.55 ms for cold/font-size/width/suffix-edit. These are not final renderer-parity ratios: the Rust lane -still packs one F32 policy output, while canonical Bitmap requires five physical buffers over real baked glyph records. -That full policy shape, memory right-sizing, incremental text edits, Three consumption, and removal of the duplicate -TypeScript path remain foundation-stack gates. +55.25/11.90/8.36/38.55 ms for cold/font-size/width/suffix-edit. + +The renderer-parity benchmark now validates the real baked Bitmap, MTSDF, and Slug fixtures and emits their canonical +GPU schemas: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec4 buffers totaling 112 bytes, and five +Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. The same stress text positions 25,515 glyphs and +selects 21,805 renderable raster instances, matching the portable techniques' deliberate omission of absent records. +With five warmups and 11 samples, Bitmap/MTSDF/Slug font-size medians are 5.506/6.396/7.237 ms and full-column-resize +medians are 4.477/5.276/6.259 ms. SIMD policy output transposes four SoA records in registers and writes tightly packed +vec2/vec4 records with contiguous 128-bit stores, but every changed glyph still executes every physical output. The +numbers therefore reject the current plan against the sub-4 ms gate and require dependency-directed policy execution: +resize touches geometry only; font size touches geometry and Slug inverse scale; static UV, color, band, address, and +count buffers remain retained. Memory right-sizing, incremental text edits, Three consumption, and deletion of the +duplicate TypeScript path remain foundation-stack gates. ### Foundation stack — Wasm, policy, render plan, and complete current semantics diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index bc30d2f9..0508019b 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -12,14 +12,16 @@ use crate::engine::frame::{ EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, RESULT_FLAG_CHECKPOINT, SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_START, - SEMANTIC_F32_FONT_SIZE, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, - SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, - SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, SHAPE_POLYGON, SHAPE_RECTANGLE, - STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, - STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, - STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, - STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, - STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + SEMANTIC_F32_FONT_SIZE, SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, + SEMANTIC_F32_FOREGROUND_GREEN, SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, + SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, + SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, + SEMANTIC_U32_REGION_ID, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, + STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, + STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; @@ -2530,7 +2532,12 @@ pub fn json() -> String { "inlineExtent": SEMANTIC_F32_INLINE_EXTENT, "blockExtent": SEMANTIC_F32_BLOCK_EXTENT, "fontSize": SEMANTIC_F32_FONT_SIZE, - "rasterPixelRatio": SEMANTIC_F32_RASTER_PIXEL_RATIO + "rasterPixelRatio": SEMANTIC_F32_RASTER_PIXEL_RATIO, + "foregroundRed": SEMANTIC_F32_FOREGROUND_RED, + "foregroundGreen": SEMANTIC_F32_FOREGROUND_GREEN, + "foregroundBlue": SEMANTIC_F32_FOREGROUND_BLUE, + "foregroundAlpha": SEMANTIC_F32_FOREGROUND_ALPHA, + "inverseFontSize": SEMANTIC_F32_INVERSE_FONT_SIZE }, "semanticU32Fields": { "foregroundRgba": SEMANTIC_U32_FOREGROUND_RGBA, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index 803cc3e8..ca31085c 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -69,6 +69,11 @@ pub(crate) const SEMANTIC_F32_INLINE_EXTENT: u8 = 2; pub(crate) const SEMANTIC_F32_BLOCK_EXTENT: u8 = 3; pub(crate) const SEMANTIC_F32_FONT_SIZE: u8 = 4; pub(crate) const SEMANTIC_F32_RASTER_PIXEL_RATIO: u8 = 5; +pub(crate) const SEMANTIC_F32_FOREGROUND_RED: u8 = 6; +pub(crate) const SEMANTIC_F32_FOREGROUND_GREEN: u8 = 7; +pub(crate) const SEMANTIC_F32_FOREGROUND_BLUE: u8 = 8; +pub(crate) const SEMANTIC_F32_FOREGROUND_ALPHA: u8 = 9; +pub(crate) const SEMANTIC_F32_INVERSE_FONT_SIZE: u8 = 10; pub(crate) const SEMANTIC_U32_FOREGROUND_RGBA: u8 = 0; pub(crate) const SEMANTIC_U32_CLUSTER_ID: u8 = 1; pub(crate) const SEMANTIC_U32_REGION_ID: u8 = 2; diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index ea70909e..1abeeb31 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -914,11 +914,72 @@ fn write_simd_outputs( output_start: usize, outputs: &mut [PhysicalBufferMut<'_>], ) { + use core::arch::wasm32::{i32x4_shuffle, v128_store}; + for (buffer_index, (schema, output)) in program.buffers.iter().zip(outputs).enumerate() { + let first = buffer_index * MAX_VECTOR_WIDTH as usize; + if matches!(schema.scalar, ScalarType::F32 | ScalarType::U32) + && schema.stride() == usize::from(schema.vector_width) * 4 + { + let destination = output_start * schema.stride(); + match schema.vector_width { + 1 => { + // SAFETY: execution validation proves four tightly packed records fit. + unsafe { + v128_store( + output.bytes.as_mut_ptr().add(destination).cast(), + values[first], + ); + } + continue; + } + 2 => { + let first_pair = i32x4_shuffle::<0, 4, 1, 5>(values[first], values[first + 1]); + let second_pair = i32x4_shuffle::<2, 6, 3, 7>(values[first], values[first + 1]); + // SAFETY: execution validation proves four tightly packed records fit. + unsafe { + v128_store( + output.bytes.as_mut_ptr().add(destination).cast(), + first_pair, + ); + v128_store( + output.bytes.as_mut_ptr().add(destination + 16).cast(), + second_pair, + ); + } + continue; + } + 4 => { + let low01 = i32x4_shuffle::<0, 4, 1, 5>(values[first], values[first + 1]); + let low23 = i32x4_shuffle::<0, 4, 1, 5>(values[first + 2], values[first + 3]); + let high01 = i32x4_shuffle::<2, 6, 3, 7>(values[first], values[first + 1]); + let high23 = i32x4_shuffle::<2, 6, 3, 7>(values[first + 2], values[first + 3]); + let records = [ + i32x4_shuffle::<0, 1, 4, 5>(low01, low23), + i32x4_shuffle::<2, 3, 6, 7>(low01, low23), + i32x4_shuffle::<0, 1, 4, 5>(high01, high23), + i32x4_shuffle::<2, 3, 6, 7>(high01, high23), + ]; + for (record, value) in records.into_iter().enumerate() { + // SAFETY: execution validation proves four tightly packed records fit. + unsafe { + v128_store( + output + .bytes + .as_mut_ptr() + .add(destination + record * 16) + .cast(), + value, + ); + } + } + continue; + } + _ => {} + } + } for lane in 0..schema.vector_width { - let lanes = simd_u32_lanes( - values[buffer_index * MAX_VECTOR_WIDTH as usize + usize::from(lane)], - ); + let lanes = simd_u32_lanes(values[first + usize::from(lane)]); for (record, value) in lanes.into_iter().enumerate() { let lane_offset = (output_start + record) * schema.stride() + usize::from(lane) * schema.scalar.byte_width(); diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index d43ba5dc..a1530f47 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -4,6 +4,10 @@ use alloc::vec::Vec; use super::{ font_binding::{FontRenderBinding, SelectedGlyphBinding}, + frame::{ + SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, SEMANTIC_F32_FOREGROUND_GREEN, + SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_U32_FOREGROUND_RGBA, + }, plan_input::{PlanGlyph, PlanInput}, policy::{CapabilitySetId, InputScope, MAX_REGISTERS, ProgramDescriptor, ValidatedPolicy}, }; @@ -41,6 +45,7 @@ pub enum GatherError { InvalidSemanticShape, FontBindingMissing, GlyphBindingMissing, + ResourceBindingMissing, ProgramMissing, SourceFieldMissing, } @@ -112,9 +117,11 @@ impl PolicyGatherWorkspace { let glyph = input.glyphs[glyph_index]; let binding = binding_for_font(glyph.font_handle).ok_or(GatherError::FontBindingMissing)?; - let selected = binding - .select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio) - .ok_or(GatherError::GlyphBindingMissing)?; + let Some(selected) = + binding.select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio) + else { + continue; + }; let program = policy .program( capability_set, @@ -127,9 +134,9 @@ impl PolicyGatherWorkspace { .resources() .get( usize::try_from(selected.resource) - .map_err(|_| GatherError::GlyphBindingMissing)?, + .map_err(|_| GatherError::ResourceBindingMissing)?, ) - .ok_or(GatherError::GlyphBindingMissing)?; + .ok_or(GatherError::ResourceBindingMissing)?; self.glyphs.push(PlanGlyph { stable_id: glyph.stable_id, content_revision: glyph.content_revision, @@ -320,6 +327,9 @@ fn source_f32( ) -> Result { let (table, row) = match scope { InputScope::Semantic => { + if let Some(value) = derived_semantic_f32(field, input, glyph_index)? { + return Ok(value); + } return input .semantic_f32 .get(usize::from(field)) @@ -341,6 +351,36 @@ fn source_f32( .ok_or(GatherError::SourceFieldMissing) } +fn derived_semantic_f32( + field: u8, + input: LayoutPlanInput<'_>, + glyph_index: usize, +) -> Result, GatherError> { + if field == SEMANTIC_F32_INVERSE_FONT_SIZE { + let font_size = input + .glyphs + .get(glyph_index) + .map(|glyph| glyph.font_size) + .ok_or(GatherError::SourceFieldMissing)?; + return Ok(Some(1.0 / font_size)); + } + let shift = match field { + SEMANTIC_F32_FOREGROUND_RED => 24, + SEMANTIC_F32_FOREGROUND_GREEN => 16, + SEMANTIC_F32_FOREGROUND_BLUE => 8, + SEMANTIC_F32_FOREGROUND_ALPHA => 0, + _ => return Ok(None), + }; + let packed = input + .semantic_u32 + .get(usize::from(SEMANTIC_U32_FOREGROUND_RGBA)) + .and_then(|values| values.get(glyph_index)) + .copied() + .ok_or(GatherError::SourceFieldMissing)?; + let channel = (packed >> shift) & 0xff; + Ok(Some((f64::from(channel) / 255.0) as f32)) +} + fn source_u32( scope: InputScope, field: u8, @@ -418,6 +458,37 @@ mod tests { const CAPABILITY: CapabilitySetId = CapabilitySetId(1); + #[test] + fn derives_policy_color_channels_and_inverse_font_size_without_retained_arrays() { + let glyphs = [layout_glyph(1, 0)]; + let foreground = [0x8040_20ff]; + let input = LayoutPlanInput { + glyphs: &glyphs, + semantic_f32: &[], + semantic_u32: &[&foreground], + }; + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_RED, input, 0), + Ok(Some((128.0_f64 / 255.0) as f32)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_GREEN, input, 0), + Ok(Some((64.0_f64 / 255.0) as f32)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_BLUE, input, 0), + Ok(Some((32.0_f64 / 255.0) as f32)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_FOREGROUND_ALPHA, input, 0), + Ok(Some(1.0)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_INVERSE_FONT_SIZE, input, 0), + Ok(Some(1.0 / 16.0)) + ); + } + #[test] fn gathers_program_specific_sources_without_a_union_record() { let binding = binding(); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index b9aac9d5..5f4ea4b1 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1639,6 +1639,7 @@ fn gather_error(error: GatherError) -> EngineError { GatherError::InvalidSemanticShape | GatherError::FontBindingMissing | GatherError::GlyphBindingMissing + | GatherError::ResourceBindingMissing | GatherError::ProgramMissing | GatherError::SourceFieldMissing => EngineError::InvalidRequest, } diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index e3d16193..527c8f5a 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -5,31 +5,36 @@ "writes": "stdout only" } */ import { readFile } from 'node:fs/promises'; +import { gunzipSync } from 'node:zlib'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { validateBitmapArtifact } from '@pmndrs/text/bakers/bitmap/validate'; +import { validateMsdfArtifact } from '@pmndrs/text/bakers/msdf/validate'; +import { validateSlugArtifact } from '@pmndrs/text/bakers/slug/validate'; +import { bitmapDescriptor } from '@pmndrs/text/raster/bitmap'; +import { msdfDescriptor } from '@pmndrs/text/raster/msdf'; +import { slugDescriptor } from '@pmndrs/text/raster/slug'; import { paragraphTextForGlyphs } from './support/paragraph-benchmark-fixture.mts'; -import { - copyIntoAllocation, - engineFrameUpdateBytes, - fontBindingBytes, - renderPolicyBytes, -} from '../tests/support/engine-abi.mjs'; +import { copyIntoAllocation, engineFrameUpdateBytes } from '../tests/support/engine-abi.mjs'; +import { techniqueProof } from './support/render-technique-proof.mjs'; const options = parseArguments(process.argv.slice(2)); const sessionId = 1; const policyHandle = 1; const fontHandle = 1; const fontStackHandle = 1; -const outputCapacity = 4 * 1024 * 1024; const regionHeight = options.height; const [wasm, abi, artifact] = await Promise.all([ readFile(new URL('../dist/text_shaper.wasm', import.meta.url)), readFile(new URL('../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8').then(JSON.parse), - readFile(new URL('../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url)), + loadArtifact(options.technique), ]); const validated = await validateFontArtifact(artifact); +const raster = await validateRaster(options.technique, artifact, validated); +const technique = techniqueProof(abi, options.technique, raster); +const outputCapacity = technique.outputBytesPerGlyph > 48 ? 8 * 1024 * 1024 : 4 * 1024 * 1024; const instance = await WebAssembly.instantiate(await WebAssembly.compile(wasm), {}); const memory = instance.exports[abi.memory]; const fn = Object.fromEntries( @@ -61,7 +66,7 @@ const initial = updateBytes({ let sessionMemory; console.log( - `memory bytes: instantiate=${memoryAtInstantiation}, initialize=${memoryAfterInitialize}, registered=${memoryAfterRegistration}`, + `technique=${options.technique} output=${technique.outputBytesPerGlyph} bytes/glyph · memory bytes: instantiate=${memoryAtInstantiation}, initialize=${memoryAfterInitialize}, registered=${memoryAfterRegistration}`, ); const reports = []; @@ -214,15 +219,7 @@ function registerFont() { } function registerBinding() { - const glyphCount = validated.glyphExtents.byteLength / 8; - const bytes = fontBindingBytes(abi, { - techniqueId: 1, - glyphCount, - strikes: [0], - resources: [{ id: 1, generation: 1, kind: 1, reference: 1 }], - resourceIndices: new Array(glyphCount).fill(0), - glyphF32: [new Array(glyphCount).fill(1)], - }); + const bytes = technique.bindingBytes; const pointer = copyIntoAllocation(memory, fn.allocate, bytes); requireStatus(fn.registerFontBinding(fontHandle, pointer, bytes.byteLength), 'register font binding'); fn.deallocate(pointer, bytes.byteLength); @@ -236,7 +233,7 @@ function registerStack() { } function registerPolicy() { - const bytes = renderPolicyBytes(abi); + const bytes = technique.policyBytes; const pointer = copyIntoAllocation(memory, fn.allocate, bytes); requireStatus(fn.registerPolicy(policyHandle, pointer, bytes.byteLength), 'register render policy'); fn.deallocate(pointer, bytes.byteLength); @@ -258,10 +255,10 @@ function summarize(name, glyphs, samples) { function printReport(caseReports) { console.log( - `\ncomplete Rust text_update · ${caseReports[0]?.glyphs ?? 0} laid-out glyphs (${options.glyphs} fixture target) · ${options.warmup} warmup · ${options.repetitions} measured`, + `\ncomplete Rust text_update + ${options.technique} render plan · ${caseReports[0]?.glyphs ?? 0} renderable instances (${options.glyphs} fixture target) · ${options.warmup} warmup · ${options.repetitions} measured`, ); console.log( - `${'case'.padEnd(16)}${'glyphs'.padStart(9)}${'median'.padStart(11)}${'p95'.padStart(11)}${'min'.padStart(11)}${'rsd'.padStart(9)}`, + `${'case'.padEnd(16)}${'instances'.padStart(9)}${'median'.padStart(11)}${'p95'.padStart(11)}${'min'.padStart(11)}${'rsd'.padStart(9)}`, ); for (const report of caseReports) { console.log( @@ -287,9 +284,51 @@ function parseArguments(arguments_) { return index === -1 ? fallback : Number.parseInt(arguments_[index + 1], 10); }; return { + technique: normalizeTechnique(readString('--technique', 'bitmap')), glyphs: read('--glyphs', 22_000), height: read('--height', 100_000), repetitions: read('--reps', 11), warmup: read('--warmup', 5), }; + + function readString(name, fallback) { + const index = arguments_.indexOf(name); + return index === -1 ? fallback : arguments_[index + 1]; + } +} + +function normalizeTechnique(value) { + const name = value === 'msdf' ? 'mtsdf' : value; + if (!['bitmap', 'mtsdf', 'slug'].includes(name)) { + throw new RangeError('--technique must be bitmap, mtsdf, msdf, or slug'); + } + return name; +} + +async function loadArtifact(techniqueName) { + const fixtures = { + bitmap: ['inter-bitmap-16.font.glb', false], + mtsdf: ['inter-mtsdf.font.glb.gz', true], + slug: ['inter-slug.font.glb.gz', true], + }; + const [file, compressed] = fixtures[techniqueName]; + const bytes = await readFile(new URL(`../../../apps/benchmarks/fixtures/rendering/${file}`, import.meta.url)); + return compressed ? gunzipSync(bytes) : bytes; +} + +async function validateRaster(techniqueName, bytes, core) { + const rasterIdentity = core.document.extensions.PMNDRS_font.rasters[0]; + const context = { + rasterKey: rasterIdentity.rasterKey, + shapingHash: core.shapingHash, + glyphCount: core.glyphCount, + glyphIdWidth: 16, + }; + if (techniqueName === 'bitmap') { + return validateBitmapArtifact(bytes, { ...context, descriptor: bitmapDescriptor({ strikes: [16] }) }); + } + if (techniqueName === 'mtsdf') { + return validateMsdfArtifact(bytes, { ...context, descriptor: msdfDescriptor() }); + } + return validateSlugArtifact(bytes, { ...context, descriptor: slugDescriptor() }); } diff --git a/packages/text/scripts/support/render-technique-proof.mjs b/packages/text/scripts/support/render-technique-proof.mjs new file mode 100644 index 00000000..6ca6edcf --- /dev/null +++ b/packages/text/scripts/support/render-technique-proof.mjs @@ -0,0 +1,327 @@ +import { fontBindingBytes, renderPolicyBytesFromPrograms } from '../../tests/support/engine-abi.mjs'; + +const ABSENT_PAGE = 0xffff; +const MISSING_RESOURCE = 0xffff_ffff; +const TECHNIQUE_ID = 1; + +export function techniqueProof(abi, name, raster) { + if (name === 'bitmap') return bitmapProof(abi, raster); + if (name === 'mtsdf') return mtsdfProof(abi, raster); + if (name === 'slug') return slugProof(abi, raster); + throw new RangeError(`unknown render technique ${name}`); +} + +function bitmapProof(abi, raster) { + const strike = raster.strikes[0]; + const view = recordView(strike.records); + const fields = denseAtlasFields(view, raster.glyphCount, strike.planeUnitsPerEm, strike.pages); + return proof(abi, bitmapProgram(abi, 'strike'), { + glyphCount: raster.glyphCount, + strikes: [strike.ppem], + resources: strike.pages.map(resource), + resourceIndices: pageIndices(view, raster.glyphCount), + strikeF32: fields, + }); +} + +function mtsdfProof(abi, raster) { + const extension = raster.document.extensions.PMNDRS_font_distance_field; + const view = recordView(raster.records); + const fields = denseAtlasFields(view, raster.glyphCount, extension.planeUnitsPerEm, raster.pages); + fields.push( + field(raster.glyphCount, (record) => { + const page = view.getUint16(record + 16, true); + if (page === ABSENT_PAGE) return 0; + const width = raster.pages[page].width; + return view.getUint16(record + 12, true) / width; + }), + field(raster.glyphCount, (record) => { + const page = view.getUint16(record + 16, true); + if (page === ABSENT_PAGE) return 0; + const height = raster.pages[page].height; + return view.getUint16(record + 14, true) / height; + }), + ); + return proof(abi, mtsdfProgram(abi), { + glyphCount: raster.glyphCount, + strikes: [0], + resources: [{ id: 1, generation: 1, kind: 1, reference: 1 }], + resourceIndices: pageIndices(view, raster.glyphCount, true), + glyphF32: fields, + glyphU32: [field(raster.glyphCount, (record) => view.getUint16(record + 16, true))], + }); +} + +function slugProof(abi, raster) { + const extension = raster.document.extensions.PMNDRS_font_slug; + const view = recordView(raster.records); + const units = extension.planeUnitsPerEm; + const normalized = (offset) => field(raster.glyphCount, (record) => view.getInt16(record + offset, true) / units, 40); + const left = normalized(0); + const bottom = normalized(2); + const right = normalized(4); + const top = normalized(6); + const width = left.map((value, index) => right[index] - value); + const height = bottom.map((value, index) => top[index] - value); + const horizontalBands = field(raster.glyphCount, (record) => view.getUint16(record + 10, true), 40); + const verticalBands = field(raster.glyphCount, (record) => view.getUint16(record + 12, true), 40); + const bandScaleX = width.map((value, index) => (value === 0 ? 0 : verticalBands[index] / value)); + const bandScaleY = height.map((value, index) => (value === 0 ? 0 : horizontalBands[index] / value)); + const fields = [ + left, + top, + width, + height, + bandScaleX, + bandScaleY, + left.map((value, index) => -value * bandScaleX[index]), + bottom.map((value, index) => -value * bandScaleY[index]), + ]; + const integers = [ + field(raster.glyphCount, (record) => view.getUint32(record + 16, true), 40), + field(raster.glyphCount, (record) => view.getUint32(record + 24, true), 40), + field(raster.glyphCount, (record) => view.getUint32(record + 28, true), 40), + field(raster.glyphCount, (record) => view.getUint32(record + 32, true), 40), + horizontalBands, + verticalBands, + ]; + return proof(abi, slugProgram(abi), { + glyphCount: raster.glyphCount, + strikes: [0], + resources: raster.pages.map(resource), + resourceIndices: field( + raster.glyphCount, + (record) => { + const page = view.getUint16(record + 8, true); + return page === ABSENT_PAGE ? MISSING_RESOURCE : page; + }, + 40, + ), + glyphF32: fields, + glyphU32: integers, + }); +} + +function proof(abi, descriptor, binding) { + return { + policyBytes: renderPolicyBytesFromPrograms(abi, [descriptor]), + bindingBytes: fontBindingBytes(abi, { techniqueId: TECHNIQUE_ID, ...binding }), + outputBytesPerGlyph: descriptor.buffers.reduce( + (sum, buffer) => sum + buffer.vectorWidth * scalarBytes(abi, buffer.scalar), + 0, + ), + }; +} + +function bitmapProgram(abi, glyphScope) { + const context = programContext(abi, glyphScope, 8, 0); + const { loadF32, binary, storeF32 } = context; + loadF32(15); + binary('multiplyF32', 15, 7, 2); + binary('addF32', 16, 0, 15); + binary('multiplyF32', 17, 8, 2); + binary('subtractF32', 18, 1, 17); + binary('multiplyF32', 19, 9, 2); + binary('multiplyF32', 20, 10, 2); + stores(storeF32, [ + [1, [16, 18]], + [2, [19, 20]], + [3, [11, 12]], + [4, [13, 14]], + [5, [3, 4, 5, 6]], + ]); + return program(context, floatBuffers(abi, [2, 2, 2, 2, 4])); +} + +function mtsdfProgram(abi) { + const context = programContext(abi, 'glyph', 10, 1); + const { operations: ops, loadF32, loadU32, binary, constantF32, storeF32 } = context; + loadF32(17); + loadU32(17, 0); + binary('multiplyF32', 18, 7, 2); + binary('addF32', 19, 0, 18); + binary('multiplyF32', 20, 8, 2); + binary('subtractF32', 21, 1, 20); + binary('multiplyF32', 22, 9, 2); + binary('multiplyF32', 23, 10, 2); + ops.push({ opcode: abi.policy.opcodes.convertU32ToF32, target: 24, operand0: 17 }); + constantF32(25, 0); + stores(storeF32, [ + [1, [19, 21, 22, 23]], + [2, [11, 12, 13, 14]], + [3, [11, 12, 15, 16]], + [4, [3, 4, 5, 6]], + [5, [25, 25, 25, 25]], + [6, [25, 25, 25, 25]], + [7, [25, 25, 25, 24]], + ]); + return program(context, floatBuffers(abi, [4, 4, 4, 4, 4, 4, 4])); +} + +function slugProgram(abi) { + const context = programContext(abi, 'glyph', 8, 6, true); + const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; + loadF32(16); + for (let fieldIndex = 0; fieldIndex < 6; fieldIndex += 1) loadU32(21 + fieldIndex, fieldIndex); + binary('multiplyF32', 16, 8, 2); + binary('addF32', 17, 0, 16); + binary('multiplyF32', 18, 9, 2); + binary('subtractF32', 19, 1, 18); + binary('multiplyF32', 20, 10, 2); + binary('multiplyF32', 27, 11, 2); + constantF32(28, 0); + constantU32(29, 0); + stores(storeF32, [ + [1, [17, 19, 20, 27]], + [2, [8, 9, 10, 11]], + [3, [12, 13, 14, 15]], + [4, [3, 4, 5, 6]], + [5, [7, 28, 28, 28]], + ]); + stores(storeU32, [ + [6, [21, 22, 23, 24]], + [7, [25, 26, 29, 29]], + ]); + return program(context, [...floatBuffers(abi, [4, 4, 4, 4, 4]), ...uintBuffers(abi, [4, 4], 6)]); +} + +function programContext(abi, bindingScope, bindingF32Count, bindingU32Count, inverseFontSize = false) { + const operations = []; + const semantic = abi.engine.semanticF32Fields; + const inputs = [ + { scope: 'semantic', field: semantic.inlineStart }, + { scope: 'semantic', field: semantic.blockStart }, + { scope: 'semantic', field: semantic.fontSize }, + { scope: 'semantic', field: semantic.foregroundRed }, + { scope: 'semantic', field: semantic.foregroundGreen }, + { scope: 'semantic', field: semantic.foregroundBlue }, + { scope: 'semantic', field: semantic.foregroundAlpha }, + ...(inverseFontSize ? [{ scope: 'semantic', field: semantic.inverseFontSize }] : []), + ...Array.from({ length: bindingF32Count }, (_, fieldIndex) => ({ scope: bindingScope, field: fieldIndex })), + ...Array.from({ length: bindingU32Count }, (_, fieldIndex) => ({ scope: bindingScope, field: fieldIndex })), + ]; + const f32InputCount = 7 + (inverseFontSize ? 1 : 0) + bindingF32Count; + return { + inputs, + operations, + loadF32(count) { + for (let fieldIndex = 0; fieldIndex < count; fieldIndex += 1) { + operations.push({ opcode: abi.policy.opcodes.loadF32, target: fieldIndex, operand0: fieldIndex }); + } + }, + loadU32(target, fieldIndex) { + operations.push({ opcode: abi.policy.opcodes.loadU32, target, operand0: fieldIndex }); + }, + binary(name, target, left, right) { + operations.push({ opcode: abi.policy.opcodes[name], target, operand0: left, operand1: right }); + }, + constantF32(target, value) { + operations.push({ opcode: abi.policy.opcodes.constantF32, target, immediate0: f32Bits(value) }); + }, + constantU32(target, value) { + operations.push({ opcode: abi.policy.opcodes.constantU32, target, immediate0: value }); + }, + storeF32(buffer, lane, register) { + operations.push({ + opcode: abi.policy.opcodes.storeF32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + storeU32(buffer, lane, register) { + operations.push({ + opcode: abi.policy.opcodes.storeU32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + f32InputCount, + u32InputCount: bindingU32Count, + }; +} + +function program(context, buffers) { + return { + techniqueId: TECHNIQUE_ID, + programId: 1, + f32InputCount: context.f32InputCount, + u32InputCount: context.u32InputCount, + inputs: context.inputs, + buffers, + operations: context.operations, + }; +} + +function stores(write, groups) { + for (const [buffer, registers] of groups) { + for (const [lane, register] of registers.entries()) write(buffer, lane, register); + } +} + +function floatBuffers(abi, widths) { + return widths.map((vectorWidth, index) => ({ id: index + 1, scalar: abi.policy.scalarTypes.f32, vectorWidth })); +} + +function uintBuffers(abi, widths, firstId) { + return widths.map((vectorWidth, index) => ({ id: firstId + index, scalar: abi.policy.scalarTypes.u32, vectorWidth })); +} + +function denseAtlasFields(view, glyphCount, units, pages) { + return [ + field(glyphCount, (record) => view.getInt16(record, true) / units), + field(glyphCount, (record) => view.getInt16(record + 6, true) / units), + field(glyphCount, (record) => (view.getInt16(record + 4, true) - view.getInt16(record, true)) / units), + field(glyphCount, (record) => (view.getInt16(record + 6, true) - view.getInt16(record + 2, true)) / units), + field(glyphCount, (record) => atlasValue(view, record, pages, 8, 'width')), + field(glyphCount, (record) => atlasValue(view, record, pages, 10, 'height')), + field(glyphCount, (record) => atlasSpan(view, record, pages, 8, 12, 'width')), + field(glyphCount, (record) => atlasSpan(view, record, pages, 10, 14, 'height')), + ]; +} + +function atlasValue(view, record, pages, offset, dimension) { + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE ? 0 : view.getUint16(record + offset, true) / pages[page][dimension]; +} + +function atlasSpan(view, record, pages, start, end, dimension) { + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE + ? 0 + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / pages[page][dimension]; +} + +function pageIndices(view, glyphCount, arrayResource = false, stride = 20) { + return field( + glyphCount, + (record) => { + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE ? MISSING_RESOURCE : arrayResource ? 0 : page; + }, + stride, + ); +} + +function field(glyphCount, read, stride = 20) { + return Array.from({ length: glyphCount }, (_, glyph) => read(glyph * stride)); +} + +function recordView(records) { + return new DataView(records.buffer, records.byteOffset, records.byteLength); +} + +function resource(_, index) { + return { id: index + 1, generation: 1, kind: 1, reference: index + 1 }; +} + +function scalarBytes(abi, scalar) { + return scalar === abi.policy.scalarTypes.u16 ? 2 : 4; +} + +function f32Bits(value) { + const bytes = new ArrayBuffer(4); + new DataView(bytes).setFloat32(0, value, true); + return new DataView(bytes).getUint32(0, true); +} diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 22be779e..127587af 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -126,8 +126,13 @@ export const textShaperAbi = { "blockExtent": 3, "blockStart": 1, "fontSize": 4, + "foregroundAlpha": 9, + "foregroundBlue": 8, + "foregroundGreen": 7, + "foregroundRed": 6, "inlineExtent": 2, "inlineStart": 0, + "inverseFontSize": 10, "rasterPixelRatio": 5 }, "semanticKinds": { diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 73c5f3c9..cfffbf2a 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -84,8 +84,13 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as blockExtent: 3, blockStart: 1, fontSize: 4, + foregroundAlpha: 9, + foregroundBlue: 8, + foregroundGreen: 7, + foregroundRed: 6, inlineExtent: 2, inlineStart: 0, + inverseFontSize: 10, rasterPixelRatio: 5, }); assert.deepEqual(abi.engine.semanticU32Fields, { diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index d2a649a9..e48b7b39 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -1,5 +1,5 @@ export function renderPolicyBytes(abi) { - return policyBytes(abi, [ + return renderPolicyBytesFromPrograms(abi, [ { techniqueId: 1, programId: 1, @@ -16,7 +16,7 @@ export function renderPolicyBytes(abi) { export function kernelPolicyBytes(abi) { const opcodes = abi.policy.opcodes; - return policyBytes(abi, [ + return renderPolicyBytesFromPrograms(abi, [ { techniqueId: 1, programId: 1, @@ -325,7 +325,7 @@ function align(value, alignment) { return Math.ceil(value / alignment) * alignment; } -function policyBytes(abi, programs) { +export function renderPolicyBytesFromPrograms(abi, programs) { const requestLayout = abi.layouts.policyRequest; const capabilityLayout = abi.layouts.policyCapabilitySet; const programLayout = abi.layouts.policyProgram; From 86855712515b578884dd3d01aa1adc6c82e6e7cf Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 18:32:24 -0400 Subject: [PATCH 046/128] perf(text): emit dependency-directed buffer patches --- docs/log.md | 11 + docs/packages/text.md | 6 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 20 +- .../rust/shaper/src/engine/ordered_plan.rs | 113 +++++++++ .../text/rust/shaper/src/engine/plan_input.rs | 3 + .../rust/shaper/src/engine/plan_packing.rs | 17 +- .../text/rust/shaper/src/engine/policy.rs | 218 +++++++++++++++++- .../rust/shaper/src/engine/policy_gather.rs | 36 ++- .../rust/shaper/src/engine/positioning.rs | 66 ++++-- .../shaper/src/engine/render_plan_compiler.rs | 1 + .../rust/shaper/src/engine/stable_plan.rs | 110 +++++++++ packages/text/rust/shaper/src/engine/state.rs | 1 + .../scripts/benchmark-rust-layout-engine.mjs | 42 +++- 14 files changed, 587 insertions(+), 58 deletions(-) diff --git a/docs/log.md b/docs/log.md index ca103abf..af9346e6 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,17 @@ ## 2026-08-08 +- **Made physical render-plan patches dependency-directed** — Positioning now records exact six-F32/four-U32 change + bits in a compact side lane while preserving the 60-byte `PlanGlyph`. Policy validation propagates those bits through + the straight-line program once and records each output buffer's semantic dependencies. Ordered-direct and + stable-indirect compilers execute and publish only intersecting buffers; new, rebound, and conservatively described + records still rewrite every output. On 21,805 real raster instances, a full-column resize now writes 170.4 KiB for + Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan payloads. Font-size writes + 340.7/340.7/681.4 KiB respectively. Five-warmup/11-sample latency remains above the gate at + 4.599/4.916/6.057 ms for Bitmap/MTSDF/Slug resize; the reduction does not support a packing-dominance claim and makes + layout the next measured optimization target. The optimized module is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli + bytes. + - **Proved all three canonical render-policy shapes** — The retained Rust frame now consumes validated real Inter Bitmap, MTSDF, and Slug records, derives linear color channels and inverse font size during policy gather without retained per-glyph arrays, omits the same absent raster records as the portable techniques, and emits the exact diff --git a/docs/packages/text.md b/docs/packages/text.md index 27ed3cb7..da2dfc02 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:e9d7020001d06a9ab3dcf421c63ade6f0046d22f73e44ac21ad781e6abdebd20' +source_digest: 'sha256:f0446f18de7e14725259e1a3ad9fc562f081daf8b7764abfc59c613190ede3e6' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -693,7 +693,9 @@ Horizontal positioning now writes retained `LayoutGlyph` records and six F32/fou Exact retained invalidation now stops at the earliest affected Rust stage. Font-size changes reuse Unicode, bidi, and HarfRust output; exact rectangle geometry reuses flow when no inline object needs retained comparison; unchanged ordered-direct frames publish an empty plan transaction without walking glyphs. A terminal hard-break cluster is skipped before visual-run lookup, matching its deliberate absence from shaping runs. At 25,515 laid-out glyphs with eight discarded warmups and 31 samples, the one-F32 diagnostic path measures 13.693/0.001/4.090/3.374/13.927/13.986 millisecond medians for cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit, with corresponding p95 values of 14.111/0.001/4.236/3.706/14.511/14.381 milliseconds. The unchanged TypeScript comparison measures 55.25/11.90/8.36/38.55 millisecond medians for cold/font-size/width/suffix-edit. -The full-policy benchmark now validates real baked Inter artifacts and compiles all three first-party GPU shapes: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec4 buffers totaling 112 bytes, and five Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. Absent raster records are omitted exactly as `RasterTechnique.select` omits them, leaving 21,805 renderable instances from the unchanged 25,515-positioned-glyph stress text. Derived linear color channels and inverse font size materialize only in requested gather lanes; they add no retained per-glyph arrays. SIMD execution transposes four-record SoA arithmetic into tightly packed vec2/vec4 output and uses contiguous 128-bit stores. With five warmups and 11 samples, Bitmap/MTSDF/Slug font-size medians are 5.506/6.396/7.237 milliseconds and full-column-resize medians are 4.477/5.276/6.259 milliseconds. These are rejection evidence, not final speedup claims: the compiler still reruns and publishes static physical outputs when only geometry changed. Validated output dependencies and exact per-frame semantic change masks must make resize geometry-only and keep UV, color, Slug band/address/count, and other static buffers retained. The optimized module is 1,060,971 raw / 400,835 gzip / 317,139 Brotli bytes. A 96.69 MiB sequential-process high-water mark remains an unresolved memory finding, not an accepted session budget. +The full-policy benchmark validates real baked Inter artifacts and compiles all three first-party GPU shapes: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec4 buffers totaling 112 bytes, and five Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. Absent raster records are omitted exactly as `RasterTechnique.select` omits them, leaving 21,805 renderable instances from the unchanged 25,515-positioned-glyph stress text. Derived linear color channels and inverse font size materialize only in requested gather lanes; they add no retained per-glyph arrays. SIMD execution transposes four-record SoA arithmetic into tightly packed vec2/vec4 output and uses contiguous 128-bit stores. + +Policy registration now propagates semantic dependencies to every physical buffer. Positioning records exact six-F32/four-U32 change bits in a compact side lane while preserving the 60-byte `PlanGlyph`; both ordered-direct and stable-indirect planning publish only buffers whose dependencies intersect. A full-column resize over 21,805 instances writes 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug rather than their 1,022.1/2,384.9/2,384.9 KiB cold-plan payloads. Font-size writes 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians remain 4.599/4.916/6.057 milliseconds, so the exact payload reduction does not support a packing-dominance or general latency-speedup claim; layout remains above the sub-4 ms gate. The optimized module is 1,065,394 raw / 399,111 gzip / 317,830 Brotli bytes. A 97.19 MiB sequential-process high-water mark remains unresolved process evidence, not an accepted session budget. The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 6a2edcbe..ae48baf2 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -264,6 +264,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-197 | Horizontal positioning is retained Rust state and directly feeds policy gather. UAX #9 L1/L2 visual order, slot-local alignment/justification, actual fallback metrics, HarfRust offsets, baseline shift, baked extents, and positive-down bounds execute with `f64` accumulation and one narrowing. Six F32 and four U32 semantic SoA lanes remain policy-readable. Exact final bits assign transactional content revisions; a one-pixel fixture proves no-op revision reuse and changed-content advancement. Compiled real-Inter `text_update` publishes nonempty resource/buffer/patch/primitive/draw tables, while an identical warm frame preserves Wasm memory identity and emits zero patches. Optimized Wasm changes from 1,044,797 / 395,222 / 307,795 to 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes. Complete-path 25,515-glyph timing remains unmeasured. | Accepted | | D-198 | Retained frame invalidation compares exact committed and pending semantic state rather than treating every style or geometry transaction as a full pipeline change. Direction changes restart bidi; shaping inputs restart shaping; metric inputs rebuild measured clusters and flow; positioning/paint inputs rebuild positioned semantics; exact geometry equality with no inline objects skips flow; and an unchanged ordered-direct frame publishes an empty reuse transaction without scanning glyphs. The 25,515-glyph, 8-warmup/31-sample Node run measures Rust cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit at 13.693/0.001/4.090/3.374/13.927/13.986 ms median and 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are 55.25/11.90/8.36/38.55 ms. This proves the invalidation direction, not final renderer parity: the Rust lane still executes a one-F32 policy rather than canonical Bitmap's five buffers, so full Bitmap packing remains a required comparison before publishing speedup ratios. Optimized Wasm is 1,060,175 / 400,500 / 316,984 raw/gzip/Brotli bytes. The sequential benchmark process reaches 76.25 MiB after multiple disposed sessions; that is not a per-session requirement or an accepted memory target. | Accepted | | D-199 | Renderer-parity performance evidence uses validated real baked records and the complete first-party GPU buffer schemas rather than a representative one-lane policy. Bitmap emits five buffers and 48 bytes per renderable instance; MTSDF emits seven vec4 buffers and 112 bytes; Slug emits five float vec4 plus two unsigned vec4 buffers and 112 bytes. Derived foreground channels and inverse font size are gathered on demand without retained glyph arrays. Absent raster records are intentionally omitted, matching the portable techniques. SIMD execution retains SoA inputs, transposes four output records in registers, and writes tightly packed vec2/vec4 records with contiguous 128-bit stores; the schema-identical scalar build remains the baseline. On the unchanged 25,515-positioned-glyph stress text (21,805 renderable instances), five warmups and 11 samples measure Bitmap/MTSDF/Slug font-size medians of 5.506/6.396/7.237 ms and full-column-resize medians of 4.477/5.276/6.259 ms. This fails the sub-4 ms gate and therefore mandates dependency-directed physical-buffer execution and publication: resize recomputes geometry only, font size recomputes geometry plus Slug inverse scale, and static UV/color/band/address/count outputs remain retained. Optimized Wasm is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes. | Accepted | +| D-200 | Physical render-plan writes are selected by validated policy dependencies, not a frame-wide dirty flag. Positioning stores exact six-F32/four-U32 change bits in a compact side lane without enlarging the 60-byte `PlanGlyph`; policy registration propagates those dependencies through the straight-line program once. Ordered-direct and stable-indirect compilers intersect glyph and buffer masks, while new, rebound, or conservatively described records rewrite every output. At 21,805 renderable instances, full-column resize emits 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan writes; font-size emits 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians are 4.599/4.916/6.057 ms. The exact payload reduction is accepted, but the latency evidence does not establish packing dominance or a general speedup and still fails the sub-4 ms gate; layout composition is the next measured target. Optimized Wasm is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli bytes. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index c1296566..3bd39026 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1142,13 +1142,19 @@ The renderer-parity benchmark now validates the real baked Bitmap, MTSDF, and Sl GPU schemas: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec4 buffers totaling 112 bytes, and five Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. The same stress text positions 25,515 glyphs and selects 21,805 renderable raster instances, matching the portable techniques' deliberate omission of absent records. -With five warmups and 11 samples, Bitmap/MTSDF/Slug font-size medians are 5.506/6.396/7.237 ms and full-column-resize -medians are 4.477/5.276/6.259 ms. SIMD policy output transposes four SoA records in registers and writes tightly packed -vec2/vec4 records with contiguous 128-bit stores, but every changed glyph still executes every physical output. The -numbers therefore reject the current plan against the sub-4 ms gate and require dependency-directed policy execution: -resize touches geometry only; font size touches geometry and Slug inverse scale; static UV, color, band, address, and -count buffers remain retained. Memory right-sizing, incremental text edits, Three consumption, and deletion of the -duplicate TypeScript path remain foundation-stack gates. +Policy validation now propagates semantic input dependencies through the straight-line program once and stores a mask +per physical output. Positioning records exact six-F32/four-U32 change bits in a compact side lane without enlarging the +60-byte `PlanGlyph`; ordered-direct and stable-indirect planning intersect the two masks. New or rebound records remain +conservative full writes. For 21,805 renderable instances, full-column resize now emits one position/geometry patch: +170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug, down from cold-plan writes of 1,022.1, 2,384.9, and 2,384.9 KiB. +Font-size emits 340.7/340.7/681.4 KiB because Bitmap size and Slug inverse scale also change. Static UV, color, bounds, +effects, band, address, and count outputs remain retained. + +Five-warmup/11-sample Bitmap/MTSDF/Slug medians are now 5.812/6.274/7.443 ms for font-size and +4.599/4.916/6.057 ms for full-column resize. Run-to-run latency does not establish a general speedup despite the exact +payload reduction; it confirms that packing and copying were not the dominant cost. The sub-4 ms gate still rejects the +current path, so layout composition is the next measured optimization target. Memory right-sizing, incremental text +edits, Three consumption, and deletion of the duplicate TypeScript path remain foundation-stack gates. ### Foundation stack — Wasm, policy, render plan, and complete current semantics diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index 4f37c2b0..b5e9adaa 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -89,6 +89,7 @@ struct InstanceState { stable_id: u32, content_revision: u32, input_index: u32, + semantic_change_mask: u16, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -549,6 +550,11 @@ impl OrderedPlanCompiler { stable_id: glyph.stable_id, content_revision: glyph.content_revision, input_index: input_index as u32, + semantic_change_mask: input + .semantic_change_masks + .get(input_index) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES), }; self.input_slots[input_index] = u32::try_from(destination) .map_err(|_| OrderedPlanError::ArithmeticOverflow)? @@ -738,8 +744,20 @@ impl OrderedPlanCompiler { let changed = self.changed_ranges[range_index]; let aligned = align_record_range(changed, record_alignment)?; let count = aligned.end - aligned.start; + let active_buffers = active_buffers_for_range( + policy, + capability_set, + program, + prior_instances, + next_instances, + changed, + replace, + )?; let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } let byte_count = usize::try_from(count) .ok() .and_then(|value| value.checked_mul(schema.stride())) @@ -788,10 +806,14 @@ impl OrderedPlanCompiler { &mut self.payload, &payload_starts, count, + active_buffers, )?; } for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } let buffer_id = pending.buffer_ids[schema_index]; let buffer_generation = pending.buffer_generations[schema_index]; let byte_length = count @@ -1102,6 +1124,42 @@ impl OrderedPlanCompiler { } } +fn active_buffers_for_range( + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + program: &super::policy::ProgramDescriptor, + previous: &[InstanceState], + next: &[InstanceState], + changed: RecordRange, + replace: bool, +) -> Result { + let all = (1_u32 << program.buffers.len()) - 1; + if replace { + return Ok(all); + } + let dependencies = policy + .buffer_dependency_masks(capability_set, program.technique, program.variant) + .ok_or(OrderedPlanError::ProgramMissing)?; + let mut active = 0_u32; + for slot in changed.start..changed.end { + let next = next[slot as usize]; + let Some(previous) = previous.get(slot as usize) else { + return Ok(all); + }; + if previous.stable_id != next.stable_id + || next.semantic_change_mask == super::positioning::ALL_SEMANTIC_CHANGES + { + return Ok(all); + } + for (index, &dependency) in dependencies.iter().enumerate() { + if dependency & next.semantic_change_mask != 0 { + active |= 1 << index; + } + } + } + Ok(active) +} + fn collect_changed_ranges( ranges: &mut Vec, previous: &[InstanceState], @@ -1225,6 +1283,60 @@ mod tests { assert_eq!(read_f32(compiler.buffer_bytes(1).unwrap(), 4), 20.0); } + #[test] + fn semantic_dependencies_suppress_unrelated_physical_buffer_writes() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0], true); + compiler.commit().unwrap(); + + let mut block_changed = glyph(1, 2); + block_changed.block_start = 4.0; + compiler + .prepare( + &policy, + CAPABILITY, + OrderedPlanInput { + glyphs: &[block_changed], + semantic_change_masks: &[1 << 1], + f32_fields: &[&[1.0]], + u32_fields: &[], + }, + false, + 1, + ) + .unwrap(); + assert!( + compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .patches + .is_empty() + ); + compiler.commit().unwrap(); + + compiler + .prepare( + &policy, + CAPABILITY, + OrderedPlanInput { + glyphs: &[glyph(1, 3)], + semantic_change_masks: &[1], + f32_fields: &[&[2.0]], + u32_fields: &[], + }, + false, + 1, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches.len(), 1); + assert_eq!(plan.payload, 2.0_f32.to_le_bytes()); + } + #[test] fn insertion_rewrites_only_the_ordered_batch_suffix_and_abort_preserves_state() { let policy = policy(); @@ -1429,6 +1541,7 @@ mod tests { CAPABILITY, OrderedPlanInput { glyphs, + semantic_change_masks: &[], f32_fields: &[x], u32_fields: &[], }, diff --git a/packages/text/rust/shaper/src/engine/plan_input.rs b/packages/text/rust/shaper/src/engine/plan_input.rs index c6fa2e26..bfd315a3 100644 --- a/packages/text/rust/shaper/src/engine/plan_input.rs +++ b/packages/text/rust/shaper/src/engine/plan_input.rs @@ -25,6 +25,7 @@ pub struct PlanGlyph { #[derive(Clone, Copy)] pub struct PlanInput<'a> { pub glyphs: &'a [PlanGlyph], + pub semantic_change_masks: &'a [u16], pub f32_fields: &'a [&'a [f32]], pub u32_fields: &'a [&'a [u32]], } @@ -38,6 +39,8 @@ pub enum PlanInputError { pub fn validate_input(input: PlanInput<'_>) -> Result<(), PlanInputError> { if u32::try_from(input.glyphs.len()).is_err() + || (!input.semantic_change_masks.is_empty() + && input.semantic_change_masks.len() != input.glyphs.len()) || input .f32_fields .iter() diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs index d9380376..78cb49cf 100644 --- a/packages/text/rust/shaper/src/engine/plan_packing.rs +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -119,6 +119,7 @@ pub fn execute_run( payload: &mut [u8], payload_starts: &[usize; MAX_PHYSICAL_BUFFERS], output_records: u32, + active_buffers: u32, ) -> Result<(), PackingError> { let input_end = input_index .checked_add(record_count as usize) @@ -146,10 +147,19 @@ pub fn execute_run( [const { mem::MaybeUninit::uninit() }; MAX_PHYSICAL_BUFFERS]; let base = payload.as_mut_ptr(); for (index, schema) in program.buffers.iter().copied().enumerate() { - let length = output_records as usize * schema.stride(); + let length = if active_buffers & (1 << index) == 0 { + 0 + } else { + output_records as usize * schema.stride() + }; // SAFETY: the caller sizes mutually disjoint payload segments before this call, and the // payload cannot reallocate while these temporary views exist. - let bytes = unsafe { slice::from_raw_parts_mut(base.add(payload_starts[index]), length) }; + let start = if length == 0 { + 0 + } else { + payload_starts[index] + }; + let bytes = unsafe { slice::from_raw_parts_mut(base.add(start), length) }; outputs[index].write(PhysicalBufferMut { schema, bytes }); } // SAFETY: the prefix contains exactly one initialized value per declared program buffer. @@ -160,7 +170,7 @@ pub fn execute_run( ) }; policy - .execute( + .execute_buffers( capability_set, program.technique, program.variant, @@ -171,6 +181,7 @@ pub fn execute_run( }, output_record as usize, outputs, + active_buffers, ) .map_err(PackingError::Policy) } diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 1abeeb31..0554c513 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -383,7 +383,78 @@ impl ValidatedPolicy { .execution .get(program_index) .ok_or(PolicyExecutionError::ProgramMissing)?; - execute_program(program, execution, inputs, output_start, outputs) + let active_buffers = (1_u32 << program.buffers.len()) - 1; + execute_program( + program, + execution, + inputs, + output_start, + outputs, + active_buffers, + ) + } + + pub(crate) fn execute_buffers( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + inputs: SemanticInputBatch<'_>, + output_start: usize, + outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, + ) -> Result<(), PolicyExecutionError> { + if self.capability_set(capability_set).is_none() { + return Err(PolicyExecutionError::CapabilitySetMissing); + } + let program_index = self + .programs + .iter() + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + }) + .ok_or(PolicyExecutionError::ProgramMissing)?; + execute_program( + &self.programs[program_index], + &self.execution[program_index], + inputs, + output_start, + outputs, + active_buffers, + ) + } + + pub(crate) fn buffer_dependency_masks( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + ) -> Option<&[u16]> { + let index = self + .programs + .iter() + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + })?; + Some(&self.execution.get(index)?.buffer_dependency_masks) } } @@ -523,14 +594,21 @@ fn mix_u32(fingerprint: &mut u64, value: u32) { #[derive(Clone, Debug, PartialEq, Eq)] struct ExecutableProgram { store_buffer_indices: Vec, + buffer_dependency_masks: Vec, } impl ExecutableProgram { fn new(program: &ProgramDescriptor) -> Result { let mut store_buffer_indices = Vec::new(); + let mut buffer_dependency_masks = Vec::new(); store_buffer_indices .try_reserve_exact(program.operations.len()) .map_err(|_| PolicyError::AllocationFailed)?; + buffer_dependency_masks + .try_reserve_exact(program.buffers.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + buffer_dependency_masks.resize(program.buffers.len(), 0); + let mut register_dependencies = [0_u16; MAX_REGISTERS]; for operation in &program.operations { let index = match store_buffer(operation) { Some(buffer) => program @@ -543,13 +621,108 @@ impl ExecutableProgram { None => NOT_A_STORE, }; store_buffer_indices.push(index); + propagate_dependencies( + program, + operation, + &mut register_dependencies, + &mut buffer_dependency_masks, + )?; } Ok(Self { store_buffer_indices, + buffer_dependency_masks, }) } } +fn propagate_dependencies( + program: &ProgramDescriptor, + operation: &Operation, + registers: &mut [u16; MAX_REGISTERS], + buffers: &mut [u16], +) -> Result<(), PolicyError> { + match *operation { + Operation::LoadF32 { target, field } => { + registers[usize::from(target)] = + f32_input_dependency(program.inputs[usize::from(field)]); + } + Operation::LoadU32 { target, field } => { + let input = usize::from(program.f32_input_count) + usize::from(field); + registers[usize::from(target)] = u32_input_dependency(program.inputs[input]); + } + Operation::ConstantF32 { target, .. } | Operation::ConstantU32 { target, .. } => { + registers[usize::from(target)] = 0; + } + Operation::AddF32 { + target, + left, + right, + } + | Operation::SubtractF32 { + target, + left, + right, + } + | Operation::MultiplyF32 { + target, + left, + right, + } + | Operation::LessThanF32 { + target, + left, + right, + } => { + registers[usize::from(target)] = + registers[usize::from(left)] | registers[usize::from(right)]; + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + registers[usize::from(target)] = registers[usize::from(condition)] + | registers[usize::from(when_true)] + | registers[usize::from(when_false)]; + } + Operation::ConvertU32ToF32 { target, source } => { + registers[usize::from(target)] = registers[usize::from(source)]; + } + Operation::StoreF32 { source, buffer, .. } + | Operation::StoreU32 { source, buffer, .. } + | Operation::StoreU16 { source, buffer, .. } => { + let index = program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + buffers[index] |= registers[usize::from(source)]; + } + } + Ok(()) +} + +fn f32_input_dependency(source: InputSource) -> u16 { + if source.scope != InputScope::Semantic { + return 0; + } + match source.field { + 0..=5 => 1 << source.field, + 6..=9 => 1 << 6, + 10 => 1 << 4, + _ => 0, + } +} + +fn u32_input_dependency(source: InputSource) -> u16 { + if source.scope == InputScope::Semantic && source.field < 4 { + 1 << (6 + source.field) + } else { + 0 + } +} + #[derive(Clone, Copy)] pub struct SemanticInputBatch<'a> { pub f32_fields: &'a [&'a [f32]], @@ -626,11 +799,20 @@ fn execute_program( inputs: SemanticInputBatch<'_>, output_start: usize, outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, ) -> Result<(), PolicyExecutionError> { - validate_execution(program, inputs, output_start, outputs)?; + validate_execution(program, inputs, output_start, outputs, active_buffers)?; #[cfg(all(target_arch = "wasm32", feature = "simd128"))] - let completed = - unsafe { execute_simd_records(program, execution, inputs, output_start, outputs)? }; + let completed = unsafe { + execute_simd_records( + program, + execution, + inputs, + output_start, + outputs, + active_buffers, + )? + }; #[cfg(not(all(target_arch = "wasm32", feature = "simd128")))] let completed = 0; for record in completed..inputs.record_count { @@ -641,6 +823,7 @@ fn execute_program( output_start + record, record, outputs, + active_buffers, )?; } Ok(()) @@ -651,6 +834,7 @@ fn validate_execution( inputs: SemanticInputBatch<'_>, output_start: usize, outputs: &[PhysicalBufferMut<'_>], + active_buffers: u32, ) -> Result<(), PolicyExecutionError> { if inputs.f32_fields.len() != usize::from(program.f32_input_count) || inputs.u32_fields.len() != usize::from(program.u32_input_count) @@ -674,10 +858,13 @@ fn validate_execution( let output_end = output_start .checked_add(inputs.record_count) .ok_or(PolicyExecutionError::OutputCapacity)?; - for (output, schema) in outputs.iter().zip(&program.buffers) { + for (index, (output, schema)) in outputs.iter().zip(&program.buffers).enumerate() { if output.schema != *schema { return Err(PolicyExecutionError::OutputSchema); } + if active_buffers & (1 << index) == 0 { + continue; + } let required = output_end .checked_mul(schema.stride()) .ok_or(PolicyExecutionError::OutputCapacity)?; @@ -695,6 +882,7 @@ fn execute_record( output_record: usize, input_record: usize, outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, ) -> Result<(), PolicyExecutionError> { let mut registers = [0_u32; MAX_REGISTERS]; let mut values = [0_u32; MAX_OUTPUT_LANES]; @@ -771,6 +959,9 @@ fn execute_record( } } for (buffer_index, (schema, output)) in program.buffers.iter().zip(outputs).enumerate() { + if active_buffers & (1 << buffer_index) == 0 { + continue; + } let record_offset = output_record * schema.stride(); for lane in 0..schema.vector_width { let value = values[buffer_index * MAX_VECTOR_WIDTH as usize + usize::from(lane)]; @@ -797,6 +988,7 @@ unsafe fn execute_simd_records( inputs: SemanticInputBatch<'_>, output_start: usize, outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, ) -> Result { use core::arch::wasm32::{ f32x4_add, f32x4_convert_u32x4, f32x4_lt, f32x4_mul, f32x4_sub, i32x4_ne, u32x4_splat, @@ -902,7 +1094,13 @@ unsafe fn execute_simd_records( } } } - write_simd_outputs(program, &values, output_start + input_record, outputs); + write_simd_outputs( + program, + &values, + output_start + input_record, + outputs, + active_buffers, + ); } Ok(completed) } @@ -913,10 +1111,14 @@ fn write_simd_outputs( values: &[core::arch::wasm32::v128; MAX_OUTPUT_LANES], output_start: usize, outputs: &mut [PhysicalBufferMut<'_>], + active_buffers: u32, ) { use core::arch::wasm32::{i32x4_shuffle, v128_store}; for (buffer_index, (schema, output)) in program.buffers.iter().zip(outputs).enumerate() { + if active_buffers & (1 << buffer_index) == 0 { + continue; + } let first = buffer_index * MAX_VECTOR_WIDTH as usize; if matches!(schema.scalar, ScalarType::F32 | ScalarType::U32) && schema.stride() == usize::from(schema.vector_width) * 4 @@ -1461,6 +1663,10 @@ mod tests { Some(PROGRAM) ); assert_eq!(policy.program(CAPABILITY, BITMAP, 1), None); + assert_eq!( + policy.buffer_dependency_masks(CAPABILITY, BITMAP, 0), + Some([0b11].as_slice()) + ); } #[test] diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index a1530f47..82cd3329 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -35,6 +35,7 @@ pub struct LayoutGlyph { #[derive(Clone, Copy)] pub struct LayoutPlanInput<'a> { pub glyphs: &'a [LayoutGlyph], + pub semantic_change_masks: &'a [u16], pub semantic_f32: &'a [&'a [f32]], pub semantic_u32: &'a [&'a [u32]], } @@ -53,6 +54,7 @@ pub enum GatherError { #[derive(Default)] pub struct PolicyGatherWorkspace { glyphs: Vec, + semantic_change_masks: Vec, f32_fields: Vec>, u32_fields: Vec>, } @@ -69,6 +71,7 @@ struct AlignedField { pub struct GatheredPlanInput<'a> { glyphs: &'a [PlanGlyph], + semantic_change_masks: &'a [u16], f32_fields: [&'a [f32]; MAX_REGISTERS], u32_fields: [&'a [u32]; MAX_REGISTERS], f32_field_count: usize, @@ -77,7 +80,8 @@ pub struct GatheredPlanInput<'a> { impl PolicyGatherWorkspace { pub fn reserve_records(&mut self, record_capacity: usize) -> Result<(), GatherError> { - reserve(&mut self.glyphs, record_capacity) + reserve(&mut self.glyphs, record_capacity)?; + reserve(&mut self.semantic_change_masks, record_capacity) } pub fn reserve_policy( @@ -155,6 +159,13 @@ impl PolicyGatherWorkspace { inline_extent: glyph.inline_extent, block_extent: glyph.block_extent, }); + self.semantic_change_masks.push( + input + .semantic_change_masks + .get(glyph_index) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES), + ); } Ok(()) } @@ -170,6 +181,7 @@ impl PolicyGatherWorkspace { } GatheredPlanInput { glyphs: &self.glyphs, + semantic_change_masks: &self.semantic_change_masks, f32_fields, u32_fields, f32_field_count: self.f32_fields.len(), @@ -224,6 +236,7 @@ impl PolicyGatherWorkspace { fn clear(&mut self) { self.glyphs.clear(); + self.semantic_change_masks.clear(); for field in &mut self.f32_fields { field.clear(); } @@ -298,6 +311,7 @@ impl GatheredPlanInput<'_> { pub fn plan_input(&self) -> PlanInput<'_> { PlanInput { glyphs: self.glyphs, + semantic_change_masks: self.semantic_change_masks, f32_fields: &self.f32_fields[..self.f32_field_count], u32_fields: &self.u32_fields[..self.u32_field_count], } @@ -305,12 +319,15 @@ impl GatheredPlanInput<'_> { } fn validate_semantic_shape(input: LayoutPlanInput<'_>) -> Result<(), GatherError> { - if input.semantic_f32.iter().any(|field| { - field.len() != input.glyphs.len() || field.iter().any(|value| !value.is_finite()) - }) || input - .semantic_u32 - .iter() - .any(|field| field.len() != input.glyphs.len()) + if (!input.semantic_change_masks.is_empty() + && input.semantic_change_masks.len() != input.glyphs.len()) + || input.semantic_f32.iter().any(|field| { + field.len() != input.glyphs.len() || field.iter().any(|value| !value.is_finite()) + }) + || input + .semantic_u32 + .iter() + .any(|field| field.len() != input.glyphs.len()) { return Err(GatherError::InvalidSemanticShape); } @@ -464,6 +481,7 @@ mod tests { let foreground = [0x8040_20ff]; let input = LayoutPlanInput { glyphs: &glyphs, + semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[&foreground], }; @@ -505,6 +523,7 @@ mod tests { CAPABILITY, LayoutPlanInput { glyphs: &glyphs, + semantic_change_masks: &[], semantic_f32: &[&semantic_x], semantic_u32: &[&semantic_kind], }, @@ -567,6 +586,7 @@ mod tests { CAPABILITY, LayoutPlanInput { glyphs: &glyphs, + semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[], }, @@ -580,6 +600,7 @@ mod tests { CapabilitySetId(2), LayoutPlanInput { glyphs: &glyphs, + semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[], }, @@ -593,6 +614,7 @@ mod tests { CAPABILITY, LayoutPlanInput { glyphs: &glyphs, + semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[], }, diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index b45d84fa..8e79867f 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -17,6 +17,8 @@ use super::{ pub(crate) const SEMANTIC_F32_FIELD_COUNT: usize = 6; pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 4; +pub(crate) const ALL_SEMANTIC_CHANGES: u16 = + (1 << (SEMANTIC_F32_FIELD_COUNT + SEMANTIC_U32_FIELD_COUNT)) - 1; const BIDI_BN: u8 = 9; const BIDI_B: u8 = 10; @@ -35,6 +37,7 @@ const BIDI_PDI: u8 = 22; #[derive(Default)] pub(crate) struct PositionedGlyphArena { glyphs: Vec, + semantic_change_masks: Vec, semantic_f32: [Vec; SEMANTIC_F32_FIELD_COUNT], semantic_u32: [Vec; SEMANTIC_U32_FIELD_COUNT], visual_clusters: Vec, @@ -45,6 +48,7 @@ pub(crate) struct PositionedGlyphArena { impl PositionedGlyphArena { pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { reserve(&mut self.glyphs, capacity)?; + reserve(&mut self.semantic_change_masks, capacity)?; for field in &mut self.semantic_f32 { reserve(field, capacity)?; } @@ -113,6 +117,7 @@ impl PositionedGlyphArena { pub(crate) fn clear(&mut self) { self.glyphs.clear(); + self.semantic_change_masks.clear(); for field in &mut self.semantic_f32 { field.clear(); } @@ -128,6 +133,10 @@ impl PositionedGlyphArena { &self.glyphs } + pub(crate) fn semantic_change_masks(&self) -> &[u16] { + &self.semantic_change_masks + } + pub(crate) fn semantic_f32(&self) -> [&[f32]; SEMANTIC_F32_FIELD_COUNT] { core::array::from_fn(|index| self.semantic_f32[index].as_slice()) } @@ -348,10 +357,12 @@ impl PositionedGlyphArena { let previous_slot = index .get(self.glyphs[slot].stable_id) .and_then(|value| usize::try_from(value).ok()); - let revision = if let Some(previous_slot) = previous_slot - .filter(|&previous_slot| self.same_content(slot, previous, previous_slot)) - { - previous.glyphs[previous_slot].content_revision + let change_mask = previous_slot.map_or(ALL_SEMANTIC_CHANGES, |previous_slot| { + self.semantic_change_mask(slot, previous, previous_slot) + }); + let revision = if change_mask == 0 { + previous.glyphs[previous_slot.expect("zero change requires a previous glyph")] + .content_revision } else { let revision = *next_revision; *next_revision = next_revision @@ -363,33 +374,38 @@ impl PositionedGlyphArena { return Err(EngineError::ResultTooLarge); } self.glyphs[slot].content_revision = revision; + self.semantic_change_masks.push(change_mask); } Ok(()) } - fn same_content(&self, slot: usize, previous: &Self, previous_slot: usize) -> bool { + fn semantic_change_mask(&self, slot: usize, previous: &Self, previous_slot: usize) -> u16 { let next = self.glyphs[slot]; let old = previous.glyphs[previous_slot]; - next.stable_id == old.stable_id - && next.font_handle == old.font_handle - && next.glyph_id == old.glyph_id - && next.semantic_id == old.semantic_id - && next.material_id == old.material_id - && next.clip_id == old.clip_id - && next.depth_key == old.depth_key - && next.font_size.to_bits() == old.font_size.to_bits() - && next.raster_pixel_ratio.to_bits() == old.raster_pixel_ratio.to_bits() - && next.inline_start.to_bits() == old.inline_start.to_bits() - && next.block_start.to_bits() == old.block_start.to_bits() - && next.inline_extent.to_bits() == old.inline_extent.to_bits() - && next.block_extent.to_bits() == old.block_extent.to_bits() - && (0..SEMANTIC_F32_FIELD_COUNT).all(|field| { - self.semantic_f32[field][slot].to_bits() - == previous.semantic_f32[field][previous_slot].to_bits() - }) - && (0..SEMANTIC_U32_FIELD_COUNT).all(|field| { - self.semantic_u32[field][slot] == previous.semantic_u32[field][previous_slot] - }) + if next.stable_id != old.stable_id + || next.font_handle != old.font_handle + || next.glyph_id != old.glyph_id + || next.semantic_id != old.semantic_id + || next.material_id != old.material_id + || next.clip_id != old.clip_id + || next.depth_key != old.depth_key + { + return ALL_SEMANTIC_CHANGES; + } + let mut mask = 0_u16; + for field in 0..SEMANTIC_F32_FIELD_COUNT { + if self.semantic_f32[field][slot].to_bits() + != previous.semantic_f32[field][previous_slot].to_bits() + { + mask |= 1 << field; + } + } + for field in 0..SEMANTIC_U32_FIELD_COUNT { + if self.semantic_u32[field][slot] != previous.semantic_u32[field][previous_slot] { + mask |= 1 << (SEMANTIC_F32_FIELD_COUNT + field); + } + } + mask } } diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index d2af7442..4017dd37 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -858,6 +858,7 @@ mod tests { CAPABILITY, PlanInput { glyphs, + semantic_change_masks: &[], f32_fields: &[x], u32_fields: &[], }, diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index cca628dc..30bd7b0a 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -870,8 +870,20 @@ impl StablePlanCompiler { for range_index in 0..self.changed_ranges.len() { let changed = align_record_range(self.changed_ranges[range_index], record_alignment)?; let count = changed.end - changed.start; + let active_buffers = stable_active_buffers( + context.policy, + context.capability_set, + program, + context.input, + &self.slot_writes, + changed, + replace, + )?; let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } let byte_count = count as usize * schema.stride(); let payload_start = self.payload.len(); reserve(&mut self.payload, byte_count)?; @@ -923,10 +935,14 @@ impl StablePlanCompiler { &mut self.payload, &payload_starts, count, + active_buffers, )?; write_index = end; } for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } reserve(&mut self.patches, 1)?; self.patches.push(PatchRecord { opcode: PATCH_WRITE, @@ -1538,6 +1554,44 @@ impl StablePlanCompiler { } } +fn stable_active_buffers( + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + program: &super::policy::ProgramDescriptor, + input: StablePlanInput<'_>, + writes: &[SlotWrite], + changed: RecordRange, + replace: bool, +) -> Result { + let all = (1_u32 << program.buffers.len()) - 1; + if replace { + return Ok(all); + } + let dependencies = policy + .buffer_dependency_masks(capability_set, program.technique, program.variant) + .ok_or(StablePlanError::ProgramMissing)?; + let mut active = 0_u32; + for write in writes + .iter() + .filter(|write| write.changed && (changed.start..changed.end).contains(&write.slot)) + { + let mask = input + .semantic_change_masks + .get(write.input_index as usize) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES); + if mask == super::positioning::ALL_SEMANTIC_CHANGES { + return Ok(all); + } + for (index, &dependency) in dependencies.iter().enumerate() { + if dependency & mask != 0 { + active |= 1 << index; + } + } + } + Ok(active) +} + fn order_schema() -> BufferSchema { BufferSchema::packed( BufferId(POLICY_BUFFER_ORDER), @@ -1629,6 +1683,61 @@ mod tests { assert_eq!(read_u32(compiler.buffer_bytes(2).unwrap(), 4), 3); } + #[test] + fn stable_semantic_dependencies_suppress_unrelated_physical_buffer_writes() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + prepare(&mut compiler, &policy, &[glyph(1, 1)], &[1.0], true, 1, 0); + compiler.commit().unwrap(); + + let mut block_changed = glyph(1, 2); + block_changed.block_start = 4.0; + compiler + .prepare( + &policy, + CAPABILITY, + StablePlanInput { + glyphs: &[block_changed], + semantic_change_masks: &[1 << 1], + f32_fields: &[&[1.0]], + u32_fields: &[], + }, + false, + 2, + 0, + ) + .unwrap(); + assert!( + compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap() + .patches + .is_empty() + ); + compiler.commit().unwrap(); + + compiler + .prepare( + &policy, + CAPABILITY, + StablePlanInput { + glyphs: &[glyph(1, 3)], + semantic_change_masks: &[1], + f32_fields: &[&[2.0]], + u32_fields: &[], + }, + false, + 3, + 0, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.patches.len(), 1); + assert_eq!(plan.payload, 2.0_f32.to_le_bytes()); + } + #[test] fn reorder_changes_only_the_indirection_buffer() { let policy = policy(false); @@ -1925,6 +2034,7 @@ mod tests { CAPABILITY, StablePlanInput { glyphs, + semantic_change_masks: &[], f32_fields: &[x], u32_fields: &[], }, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 5f4ea4b1..3fa82cf8 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -619,6 +619,7 @@ impl TextEngine { CapabilitySetId(request.capability_set), LayoutPlanInput { glyphs: positioned.glyphs(), + semantic_change_masks: positioned.semantic_change_masks(), semantic_f32: &semantic_f32, semantic_u32: &semantic_u32, }, diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index 527c8f5a..4722bf21 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -78,15 +78,19 @@ printReport(reports); function measureCold() { const samples = []; + const plans = []; let glyphs = 0; for (let index = 0; index < options.warmup + options.repetitions; index += 1) { createSession(initial.byteLength); const result = execute(initial, true); glyphs = result.primitiveCount; - if (index >= options.warmup) samples.push(result.durationMs); + if (index >= options.warmup) { + samples.push(result.durationMs); + plans.push(result); + } requireStatus(fn.disposeSession(sessionId), 'dispose cold session'); } - return summarize('cold', glyphs, samples); + return summarize('cold', glyphs, samples, plans); } function measureWarm(name) { @@ -96,6 +100,7 @@ function measureWarm(name) { const localizedText = [...utf16]; let suffixLength = utf16.length; const samples = []; + const plans = []; for (let index = 0; index < options.warmup + options.repetitions; index += 1) { const revision = index + 2; const common = { @@ -141,10 +146,13 @@ function measureWarm(name) { bytes = updateBytes({ ...common, geometry: baseGeometry }); } state = execute(bytes, index < options.warmup, `${name}[${index}]`); - if (index >= options.warmup) samples.push(state.durationMs); + if (index >= options.warmup) { + samples.push(state.durationMs); + plans.push(state); + } } requireStatus(fn.disposeSession(sessionId), `dispose ${name} session`); - return summarize(name, livePrimitiveCount, samples); + return summarize(name, livePrimitiveCount, samples, plans); } function createSession(requestCapacity) { @@ -176,13 +184,25 @@ function execute(bytes, allowGrowth = false, operation = 'text_update') { const layout = abi.layouts.engineResult; const result = new DataView(memory.buffer, resultPointer, layout.size); requireStatus(result.getUint32(layout.status, true), operation); + const patchCount = result.getUint32(layout.patchCount, true); + const patchesOffset = result.getUint32(layout.patchesOffset, true); + const patchLayout = abi.layouts.enginePatch; + let writeBytes = 0; + for (let index = 0; index < patchCount; index += 1) { + const at = resultPointer + patchesOffset + index * patchLayout.size; + const patch = new DataView(memory.buffer, at, patchLayout.size); + if (patch.getUint8(patchLayout.opcode) === abi.engine.patchOpcodes.write) { + writeBytes += patch.getUint32(patchLayout.byteLength, true); + } + } return { durationMs, engineRevision: result.getUint32(layout.engineRevision, true), planRevision: result.getUint32(layout.planRevision, true), publicationGeneration: result.getUint32(layout.publicationGeneration, true), primitiveCount: result.getUint32(layout.primitiveCount, true), - patchCount: result.getUint32(layout.patchCount, true), + patchCount, + writeBytes, }; } @@ -239,7 +259,7 @@ function registerPolicy() { fn.deallocate(pointer, bytes.byteLength); } -function summarize(name, glyphs, samples) { +function summarize(name, glyphs, samples, plans) { const sorted = samples.toSorted((left, right) => left - right); const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; const variance = sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / sorted.length; @@ -250,6 +270,8 @@ function summarize(name, glyphs, samples) { p95Ms: sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))], minMs: sorted[0], rsdPercent: (Math.sqrt(variance) / mean) * 100, + patchCount: plans[Math.floor(plans.length / 2)].patchCount, + writeBytes: plans[Math.floor(plans.length / 2)].writeBytes, }; } @@ -258,11 +280,11 @@ function printReport(caseReports) { `\ncomplete Rust text_update + ${options.technique} render plan · ${caseReports[0]?.glyphs ?? 0} renderable instances (${options.glyphs} fixture target) · ${options.warmup} warmup · ${options.repetitions} measured`, ); console.log( - `${'case'.padEnd(16)}${'instances'.padStart(9)}${'median'.padStart(11)}${'p95'.padStart(11)}${'min'.padStart(11)}${'rsd'.padStart(9)}`, + `${'case'.padEnd(16)}${'instances'.padStart(9)}${'median'.padStart(11)}${'p95'.padStart(11)}${'min'.padStart(11)}${'rsd'.padStart(9)}${'patches'.padStart(9)}${'writes'.padStart(11)}`, ); for (const report of caseReports) { console.log( - `${report.name.padEnd(16)}${String(report.glyphs).padStart(9)}${`${report.medianMs.toFixed(3)}ms`.padStart(11)}${`${report.p95Ms.toFixed(3)}ms`.padStart(11)}${`${report.minMs.toFixed(3)}ms`.padStart(11)}${`${report.rsdPercent.toFixed(1)}%`.padStart(9)}`, + `${report.name.padEnd(16)}${String(report.glyphs).padStart(9)}${`${report.medianMs.toFixed(3)}ms`.padStart(11)}${`${report.p95Ms.toFixed(3)}ms`.padStart(11)}${`${report.minMs.toFixed(3)}ms`.padStart(11)}${`${report.rsdPercent.toFixed(1)}%`.padStart(9)}${String(report.patchCount).padStart(9)}${formatBytes(report.writeBytes).padStart(11)}`, ); } console.log('column-resize is the existing layout-width case: one fully active column is reflowed end to end.'); @@ -270,6 +292,10 @@ function printReport(caseReports) { console.log(`Wasm memory after retained high-water mark: ${(memory.buffer.byteLength / 1024 / 1024).toFixed(2)} MiB`); } +function formatBytes(value) { + return value < 1024 ? `${value} B` : `${(value / 1024).toFixed(1)} KiB`; +} + function stringToUtf16(value) { return Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); } From 98d51434173219e6f9d8168a5b19f2d0ae20da6e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 18:49:01 -0400 Subject: [PATCH 047/128] perf(text): fast-path stable positioning order --- docs/log.md | 8 ++ docs/packages/text.md | 4 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 7 ++ .../rust/shaper/src/engine/positioning.rs | 76 ++++++++++++++----- .../scripts/benchmark-rust-layout-engine.mjs | 27 +++++-- 6 files changed, 98 insertions(+), 25 deletions(-) diff --git a/docs/log.md b/docs/log.md index af9346e6..5c5f319c 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Avoided identity hashing for order-preserving reflow** — Positioned glyph reconciliation now checks the common + equal-length/equal-stable-ID order first and compares exact content by slot; reordered output retains the exact + identity-index fallback. A symbolized, non-shipping Wasm CPU profile identifies positioning as the largest sampled + column-resize function and policy gather as the next largest. The benchmark can now isolate one case and accept an + explicit profiling Wasm without changing the canonical default sequence. On the unchanged five-warmup/11-sample + workload, Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; variance prevents attributing a precise speedup, + and the sub-4 ms gate remains open. The optimized module is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes. + - **Made physical render-plan patches dependency-directed** — Positioning now records exact six-F32/four-U32 change bits in a compact side lane while preserving the 60-byte `PlanGlyph`. Policy validation propagates those bits through the straight-line program once and records each output buffer's semantic dependencies. Ordered-direct and diff --git a/docs/packages/text.md b/docs/packages/text.md index da2dfc02..27834b3c 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:f0446f18de7e14725259e1a3ad9fc562f081daf8b7764abfc59c613190ede3e6' +source_digest: 'sha256:1f5414678eccbef60f2e2ecce2164c807d8d8b3a4806ef5db2bc76918a819c89' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -697,6 +697,8 @@ The full-policy benchmark validates real baked Inter artifacts and compiles all Policy registration now propagates semantic dependencies to every physical buffer. Positioning records exact six-F32/four-U32 change bits in a compact side lane while preserving the 60-byte `PlanGlyph`; both ordered-direct and stable-indirect planning publish only buffers whose dependencies intersect. A full-column resize over 21,805 instances writes 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug rather than their 1,022.1/2,384.9/2,384.9 KiB cold-plan payloads. Font-size writes 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians remain 4.599/4.916/6.057 milliseconds, so the exact payload reduction does not support a packing-dominance or general latency-speedup claim; layout remains above the sub-4 ms gate. The optimized module is 1,065,394 raw / 399,111 gzip / 317,830 Brotli bytes. A 97.19 MiB sequential-process high-water mark remains unresolved process evidence, not an accepted session budget. +Order-preserving reflow now compares retained positioned glyphs directly by stable-ID slot and uses the exact identity index only after a reorder. A symbolized temporary Wasm profile identifies positioning as the largest sampled resize function and policy gather as the second. The canonical benchmark supports case isolation and an explicit profiling module while retaining its unchanged defaults. Current Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 milliseconds; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 raw / 399,248 gzip / 318,131 Brotli bytes. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index ae48baf2..791caf00 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -265,6 +265,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-198 | Retained frame invalidation compares exact committed and pending semantic state rather than treating every style or geometry transaction as a full pipeline change. Direction changes restart bidi; shaping inputs restart shaping; metric inputs rebuild measured clusters and flow; positioning/paint inputs rebuild positioned semantics; exact geometry equality with no inline objects skips flow; and an unchanged ordered-direct frame publishes an empty reuse transaction without scanning glyphs. The 25,515-glyph, 8-warmup/31-sample Node run measures Rust cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit at 13.693/0.001/4.090/3.374/13.927/13.986 ms median and 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are 55.25/11.90/8.36/38.55 ms. This proves the invalidation direction, not final renderer parity: the Rust lane still executes a one-F32 policy rather than canonical Bitmap's five buffers, so full Bitmap packing remains a required comparison before publishing speedup ratios. Optimized Wasm is 1,060,175 / 400,500 / 316,984 raw/gzip/Brotli bytes. The sequential benchmark process reaches 76.25 MiB after multiple disposed sessions; that is not a per-session requirement or an accepted memory target. | Accepted | | D-199 | Renderer-parity performance evidence uses validated real baked records and the complete first-party GPU buffer schemas rather than a representative one-lane policy. Bitmap emits five buffers and 48 bytes per renderable instance; MTSDF emits seven vec4 buffers and 112 bytes; Slug emits five float vec4 plus two unsigned vec4 buffers and 112 bytes. Derived foreground channels and inverse font size are gathered on demand without retained glyph arrays. Absent raster records are intentionally omitted, matching the portable techniques. SIMD execution retains SoA inputs, transposes four output records in registers, and writes tightly packed vec2/vec4 records with contiguous 128-bit stores; the schema-identical scalar build remains the baseline. On the unchanged 25,515-positioned-glyph stress text (21,805 renderable instances), five warmups and 11 samples measure Bitmap/MTSDF/Slug font-size medians of 5.506/6.396/7.237 ms and full-column-resize medians of 4.477/5.276/6.259 ms. This fails the sub-4 ms gate and therefore mandates dependency-directed physical-buffer execution and publication: resize recomputes geometry only, font size recomputes geometry plus Slug inverse scale, and static UV/color/band/address/count outputs remain retained. Optimized Wasm is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes. | Accepted | | D-200 | Physical render-plan writes are selected by validated policy dependencies, not a frame-wide dirty flag. Positioning stores exact six-F32/four-U32 change bits in a compact side lane without enlarging the 60-byte `PlanGlyph`; policy registration propagates those dependencies through the straight-line program once. Ordered-direct and stable-indirect compilers intersect glyph and buffer masks, while new, rebound, or conservatively described records rewrite every output. At 21,805 renderable instances, full-column resize emits 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan writes; font-size emits 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians are 4.599/4.916/6.057 ms. The exact payload reduction is accepted, but the latency evidence does not establish packing dominance or a general speedup and still fails the sub-4 ms gate; layout composition is the next measured target. Optimized Wasm is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli bytes. | Accepted | +| D-201 | Order-preserving positioned-glyph reconciliation compares equal stable-ID slots directly and builds the exact identity index only when output order or membership changes. Reordered content retains the map fallback and an exact revision-preservation test. The canonical benchmark may isolate a named case and load an explicit temporary profiling Wasm, but its default workload and shipping artifact remain unchanged. A symbolized no-`std` profile identifies positioning as the largest sampled full-column-resize function and policy gather as the next largest. Current five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 3bd39026..2cf89135 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1156,6 +1156,13 @@ payload reduction; it confirms that packing and copying were not the dominant co current path, so layout composition is the next measured optimization target. Memory right-sizing, incremental text edits, Three consumption, and deletion of the duplicate TypeScript path remain foundation-stack gates. +A symbolized temporary no-`std` Wasm profile can now isolate the canonical column-resize case without changing its +default measurement sequence. Positioning is the largest sampled Rust function, followed by policy gather. Positioned +reconciliation now checks the common equal-length/equal-stable-ID order before building the exact identity index; bidi +or flow reordering retains the map fallback. The current five-warmup/11-sample resize medians are +4.414/4.984/5.196 ms for Bitmap/MTSDF/Slug. The inter-run variance is too large to assign a precise causal speedup to +this small change, and all three remain above the median gate. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index 8e79867f..ffdeb35a 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -341,6 +341,19 @@ impl PositionedGlyphArena { index: &mut IdentityIndex, next_revision: &mut u32, ) -> Result<(), EngineError> { + if self.glyphs.len() == previous.glyphs.len() + && self + .glyphs + .iter() + .zip(&previous.glyphs) + .all(|(next, old)| next.stable_id == old.stable_id) + { + *next_revision = (*next_revision).max(1); + for slot in 0..self.glyphs.len() { + self.assign_content_revision(slot, previous, Some(slot), next_revision)?; + } + return Ok(()); + } index .prepare(previous.glyphs.len()) .map_err(identity_index_error)?; @@ -357,28 +370,39 @@ impl PositionedGlyphArena { let previous_slot = index .get(self.glyphs[slot].stable_id) .and_then(|value| usize::try_from(value).ok()); - let change_mask = previous_slot.map_or(ALL_SEMANTIC_CHANGES, |previous_slot| { - self.semantic_change_mask(slot, previous, previous_slot) - }); - let revision = if change_mask == 0 { - previous.glyphs[previous_slot.expect("zero change requires a previous glyph")] - .content_revision - } else { - let revision = *next_revision; - *next_revision = next_revision - .checked_add(1) - .ok_or(EngineError::ResultTooLarge)?; - revision - }; - if revision == 0 { - return Err(EngineError::ResultTooLarge); - } - self.glyphs[slot].content_revision = revision; - self.semantic_change_masks.push(change_mask); + self.assign_content_revision(slot, previous, previous_slot, next_revision)?; } Ok(()) } + fn assign_content_revision( + &mut self, + slot: usize, + previous: &Self, + previous_slot: Option, + next_revision: &mut u32, + ) -> Result<(), EngineError> { + let change_mask = previous_slot.map_or(ALL_SEMANTIC_CHANGES, |previous_slot| { + self.semantic_change_mask(slot, previous, previous_slot) + }); + let revision = if change_mask == 0 { + previous.glyphs[previous_slot.expect("zero change requires a previous glyph")] + .content_revision + } else { + let revision = *next_revision; + *next_revision = next_revision + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + revision + }; + if revision == 0 { + return Err(EngineError::ResultTooLarge); + } + self.glyphs[slot].content_revision = revision; + self.semantic_change_masks.push(change_mask); + Ok(()) + } + fn semantic_change_mask(&self, slot: usize, previous: &Self, previous_slot: usize) -> u16 { let next = self.glyphs[slot]; let old = previous.glyphs[previous_slot]; @@ -780,5 +804,21 @@ mod tests { assert_eq!(pending.glyphs[0].content_revision, 3); assert_eq!(pending.glyphs[1].content_revision, 4); assert_eq!(next_revision, 5); + + let mut reordered = PositionedGlyphArena::default(); + reordered.glyphs.extend(active.glyphs.iter().rev().copied()); + for field in 0..SEMANTIC_F32_FIELD_COUNT { + reordered.semantic_f32[field].extend(active.semantic_f32[field].iter().rev().copied()); + } + for field in 0..SEMANTIC_U32_FIELD_COUNT { + reordered.semantic_u32[field].extend(active.semantic_u32[field].iter().rev().copied()); + } + reordered + .assign_content_revisions(&active, &mut index, &mut next_revision) + .unwrap(); + assert_eq!(reordered.glyphs[0].content_revision, 2); + assert_eq!(reordered.glyphs[1].content_revision, 1); + assert_eq!(reordered.semantic_change_masks, [0, 0]); + assert_eq!(next_revision, 5); } } diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index 4722bf21..862d7912 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -27,7 +27,7 @@ const fontStackHandle = 1; const regionHeight = options.height; const [wasm, abi, artifact] = await Promise.all([ - readFile(new URL('../dist/text_shaper.wasm', import.meta.url)), + readFile(options.wasm ?? new URL('../dist/text_shaper.wasm', import.meta.url)), readFile(new URL('../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8').then(JSON.parse), loadArtifact(options.technique), ]); @@ -70,9 +70,9 @@ console.log( ); const reports = []; -reports.push(measureCold()); -for (const name of ['no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit']) { - reports.push(measureWarm(name)); +const cases = ['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit']; +for (const name of options.case === undefined ? cases : [options.case]) { + reports.push(name === 'cold' ? measureCold() : measureWarm(name)); } printReport(reports); @@ -261,6 +261,8 @@ function registerPolicy() { function summarize(name, glyphs, samples, plans) { const sorted = samples.toSorted((left, right) => left - right); + const patchCounts = plans.map((plan) => plan.patchCount).toSorted((left, right) => left - right); + const writeBytes = plans.map((plan) => plan.writeBytes).toSorted((left, right) => left - right); const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; const variance = sorted.reduce((sum, value) => sum + (value - mean) ** 2, 0) / sorted.length; return { @@ -270,8 +272,8 @@ function summarize(name, glyphs, samples, plans) { p95Ms: sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))], minMs: sorted[0], rsdPercent: (Math.sqrt(variance) / mean) * 100, - patchCount: plans[Math.floor(plans.length / 2)].patchCount, - writeBytes: plans[Math.floor(plans.length / 2)].writeBytes, + patchCount: patchCounts[Math.floor(patchCounts.length / 2)], + writeBytes: writeBytes[Math.floor(writeBytes.length / 2)], }; } @@ -311,6 +313,8 @@ function parseArguments(arguments_) { }; return { technique: normalizeTechnique(readString('--technique', 'bitmap')), + wasm: readString('--wasm'), + case: readCase('--case'), glyphs: read('--glyphs', 22_000), height: read('--height', 100_000), repetitions: read('--reps', 11), @@ -321,6 +325,17 @@ function parseArguments(arguments_) { const index = arguments_.indexOf(name); return index === -1 ? fallback : arguments_[index + 1]; } + + function readCase(name) { + const value = readString(name); + if ( + value !== undefined && + !['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit'].includes(value) + ) { + throw new RangeError(`unknown benchmark case: ${value}`); + } + return value; + } } function normalizeTechnique(value) { From 1943ad88579d02942f040b528e0862a8b930acca Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 19:10:43 -0400 Subject: [PATCH 048/128] perf(text): skip visual scratch for ltr layout --- docs/log.md | 7 ++ docs/packages/text.md | 6 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 9 ++ .../rust/shaper/src/engine/positioning.rs | 95 ++++++++++++++----- 5 files changed, 90 insertions(+), 28 deletions(-) diff --git a/docs/log.md b/docs/log.md index 5c5f319c..c9fa0f8c 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Removed visual-order scratch from proven LTR positioning** — A positioning pass now checks once that all retained + bidi levels are even and no run is direction-overridden, then walks logical clusters directly. Odd levels and + overrides retain the complete UAX #9 L1/L2 path. On the unchanged 25,515-positioned/21,805-renderable stress case, + two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197/5.162 ms and two optimized runs measured + 4.849/4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF/Slug medians are 5.355/6.001 ms; the sub-4 ms + gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes. + - **Avoided identity hashing for order-preserving reflow** — Positioned glyph reconciliation now checks the common equal-length/equal-stable-ID order first and compares exact content by slot; reordered output retains the exact identity-index fallback. A symbolized, non-shipping Wasm CPU profile identifies positioning as the largest sampled diff --git a/docs/packages/text.md b/docs/packages/text.md index 27834b3c..a4c582f4 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:1f5414678eccbef60f2e2ecce2164c807d8d8b3a4806ef5db2bc76918a819c89' +source_digest: 'sha256:0e38f1a6590252d59ab0bae52ce79c6984c0cb47d859a9e2386ee6ab33a3ac7f' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -172,7 +172,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-08T20:45:32Z' + at: '2026-08-08T23:04:50Z' --- # Package reference: `@pmndrs/text` @@ -699,6 +699,8 @@ Policy registration now propagates semantic dependencies to every physical buffe Order-preserving reflow now compares retained positioned glyphs directly by stable-ID slot and uses the exact identity index only after a reorder. A symbolized temporary Wasm profile identifies positioning as the largest sampled resize function and policy gather as the second. The canonical benchmark supports case isolation and an explicit profiling module while retaining its unchanged defaults. Current Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 milliseconds; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 raw / 399,248 gzip / 318,131 Brotli bytes. +Trivially LTR positioning now proves all retained bidi levels are even and no run is direction-overridden once, then walks logical clusters without filling line-level or visual-order scratch. Odd levels and overrides preserve the complete UAX #9 L1/L2 path. Two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 milliseconds; two optimized runs measured 4.849 and 4.878 milliseconds with the same 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 milliseconds. The optimized module is 1,065,857 raw / 403,525 gzip / 318,137 Brotli bytes, and the sub-4 ms gate remains open. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 791caf00..e131a3ed 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -266,6 +266,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-199 | Renderer-parity performance evidence uses validated real baked records and the complete first-party GPU buffer schemas rather than a representative one-lane policy. Bitmap emits five buffers and 48 bytes per renderable instance; MTSDF emits seven vec4 buffers and 112 bytes; Slug emits five float vec4 plus two unsigned vec4 buffers and 112 bytes. Derived foreground channels and inverse font size are gathered on demand without retained glyph arrays. Absent raster records are intentionally omitted, matching the portable techniques. SIMD execution retains SoA inputs, transposes four output records in registers, and writes tightly packed vec2/vec4 records with contiguous 128-bit stores; the schema-identical scalar build remains the baseline. On the unchanged 25,515-positioned-glyph stress text (21,805 renderable instances), five warmups and 11 samples measure Bitmap/MTSDF/Slug font-size medians of 5.506/6.396/7.237 ms and full-column-resize medians of 4.477/5.276/6.259 ms. This fails the sub-4 ms gate and therefore mandates dependency-directed physical-buffer execution and publication: resize recomputes geometry only, font size recomputes geometry plus Slug inverse scale, and static UV/color/band/address/count outputs remain retained. Optimized Wasm is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes. | Accepted | | D-200 | Physical render-plan writes are selected by validated policy dependencies, not a frame-wide dirty flag. Positioning stores exact six-F32/four-U32 change bits in a compact side lane without enlarging the 60-byte `PlanGlyph`; policy registration propagates those dependencies through the straight-line program once. Ordered-direct and stable-indirect compilers intersect glyph and buffer masks, while new, rebound, or conservatively described records rewrite every output. At 21,805 renderable instances, full-column resize emits 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan writes; font-size emits 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians are 4.599/4.916/6.057 ms. The exact payload reduction is accepted, but the latency evidence does not establish packing dominance or a general speedup and still fails the sub-4 ms gate; layout composition is the next measured target. Optimized Wasm is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli bytes. | Accepted | | D-201 | Order-preserving positioned-glyph reconciliation compares equal stable-ID slots directly and builds the exact identity index only when output order or membership changes. Reordered content retains the map fallback and an exact revision-preservation test. The canonical benchmark may isolate a named case and load an explicit temporary profiling Wasm, but its default workload and shipping artifact remain unchanged. A symbolized no-`std` profile identifies positioning as the largest sampled full-column-resize function and policy gather as the next largest. Current five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes. | Accepted | +| D-202 | Positioned layout admits a conservative trivial-LTR lane only when every retained bidi level is even and no shaping run carries a direction override. That lane traverses logical clusters directly and does not fill line-level, visual-cluster, or visual-level scratch; any odd level or override retains the complete UAX #9 L1/L2 path. Exact positioning tests cover the admitted and rejected predicates, while the mixed-direction package golden remains unchanged. On the unchanged 25,515-positioned/21,805-renderable workload, two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms; two optimized runs measured 4.849 and 4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. The sub-4 ms gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes; the six-byte Brotli increase is the meaningful compressed-size comparison, while gzip changed materially from code-layout interaction. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 2cf89135..a9198e8d 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1163,6 +1163,15 @@ or flow reordering retains the map fallback. The current five-warmup/11-sample r 4.414/4.984/5.196 ms for Bitmap/MTSDF/Slug. The inter-run variance is too large to assign a precise causal speedup to this small change, and all three remain above the median gate. +The common visually LTR lane now proves every retained bidi level is even and that no run has a direction override once +per positioning pass. It then walks logical clusters directly, avoiding per-line L1 scratch copies and visual +cluster/level writes; any odd level or override takes the unchanged complete L1/L2 path. On the same workload, two +adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms, while two optimized runs measured 4.849 +and 4.878 ms with the same one-patch 170.4 KiB output. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. This +retains a measured positioning reduction without claiming the still-open sub-4 ms gate. Optimized Wasm is 1,065,857 +raw / 403,525 gzip / 318,137 Brotli bytes; Brotli changes by six bytes from D-201, while gzip is sensitive to the new +code layout. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index ffdeb35a..776b1228 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -78,6 +78,7 @@ impl PositionedGlyphArena { ) -> Result<(), EngineError> { self.clear(); self.reserve(shape.glyph_ids.len())?; + let visually_ltr = is_trivially_ltr(bidi, runs); for (line_index, line) in flow.lines.iter().copied().enumerate() { let fragments = line_fragments(flow, line)?; let Some(first) = fragments.first() else { @@ -86,12 +87,14 @@ impl PositionedGlyphArena { let Some(last) = fragments.last() else { continue; }; - prepare_line_levels( - &mut self.line_levels, - bidi, - first.line.text_start, - last.line.text_end, - )?; + if !visually_ltr { + prepare_line_levels( + &mut self.line_levels, + bidi, + first.line.text_start, + last.line.text_end, + )?; + } let final_line = flow .lines .get(line_index + 1) @@ -107,6 +110,7 @@ impl PositionedGlyphArena { shape, styles, bidi, + visually_ltr, metrics_for, extents_for, )?; @@ -157,6 +161,7 @@ impl PositionedGlyphArena { shape: &ShapeArena, styles: &[StyleSegment], bidi: &BidiAnalysis, + visually_ltr: bool, metrics_for: impl Fn(u32) -> Option + Copy, extents_for: impl Fn(u32, u32) -> Option + Copy, ) -> Result<(), EngineError> { @@ -165,25 +170,27 @@ impl PositionedGlyphArena { let cluster_end = usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; let visual_start = self.visual_clusters.len(); - for cluster in cluster_start..cluster_end { - if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { - continue; + if !visually_ltr { + for cluster in cluster_start..cluster_end { + if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { + continue; + } + self.visual_clusters + .push(u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?); + self.visual_levels.push(cluster_level( + cluster, + fragment.line.text_start, + clusters, + runs, + &self.line_levels, + )?); } - self.visual_clusters - .push(u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?); - self.visual_levels.push(cluster_level( - cluster, - fragment.line.text_start, - clusters, - runs, - &self.line_levels, - )?); + reorder_l2( + &mut self.visual_clusters, + &mut self.visual_levels, + visual_start, + ); } - reorder_l2( - &mut self.visual_clusters, - &mut self.visual_levels, - visual_start, - ); let available = (fragment.slot_end - fragment.slot_start - fragment.line.advance).max(0.0); let paragraph_level = paragraph_level_at(bidi, fragment.line.text_start); @@ -205,9 +212,18 @@ impl PositionedGlyphArena { }; let mut cursor = fragment.slot_start + offset; let baseline = line.block_start + line.baseline; - for visual in visual_start..self.visual_clusters.len() { - let cluster = usize::try_from(self.visual_clusters[visual]) - .map_err(|_| EngineError::InvalidRequest)?; + let visual_count = if visually_ltr { + cluster_end.saturating_sub(cluster_start) + } else { + self.visual_clusters.len().saturating_sub(visual_start) + }; + for ordinal in 0..visual_count { + let cluster = if visually_ltr { + cluster_start + ordinal + } else { + usize::try_from(self.visual_clusters[visual_start + ordinal]) + .map_err(|_| EngineError::InvalidRequest)? + }; if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { continue; } @@ -443,6 +459,11 @@ fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragme .ok_or(EngineError::InvalidRequest) } +fn is_trivially_ltr(bidi: &BidiAnalysis, runs: &[ShapingRun]) -> bool { + bidi.levels.iter().all(|level| level & 1 == 0) + && runs.iter().all(|run| !run.style.bidi_override) +} + fn prepare_line_levels( target: &mut Vec, bidi: &BidiAnalysis, @@ -643,6 +664,28 @@ mod tests { assert_eq!((indices.capacity(), levels.capacity()), capacities); } + #[test] + fn only_even_unoverridden_runs_skip_visual_reordering() { + let mut bidi = BidiAnalysis { + levels: vec![0, 2, 0], + ..BidiAnalysis::default() + }; + let mut run = ShapingRun { + text_start: 0, + text_end: 3, + script: u32::from_be_bytes(*b"Latn"), + direction: 0, + bidi_level: 0, + style: ResolvedStyle::default(), + }; + assert!(is_trivially_ltr(&bidi, &[run])); + bidi.levels[1] = 1; + assert!(!is_trivially_ltr(&bidi, &[run])); + bidi.levels[1] = 0; + run.style.bidi_override = true; + assert!(!is_trivially_ltr(&bidi, &[run])); + } + #[test] fn positions_once_and_revisions_only_exact_content_changes() { let text = vec![0x61, 0x62, 0x0a]; From 97144b8af28220a0b6042c43a7e48f4eaf32ab76 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 19:42:45 -0400 Subject: [PATCH 049/128] perf(text): elide inactive policy work --- docs/log.md | 10 + docs/packages/text.md | 13 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 15 + .../text/rust/shaper/src/engine/policy.rs | 273 ++++++++++++++++++ .../rust/shaper/src/engine/policy_gather.rs | 109 ++++++- packages/text/rust/shaper/src/engine/state.rs | 1 + .../scripts/benchmark-rust-layout-engine.mjs | 9 +- 8 files changed, 417 insertions(+), 14 deletions(-) diff --git a/docs/log.md b/docs/log.md index c9fa0f8c..48242280 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-08 +- **Made policy work dependency-directed from gather through execution** — Policy registration now compiles both + input-to-buffer and operation-to-buffer reachability. A positioned update gathers only source lanes reaching a + semantically changed output and the scalar/SIMD executors skip operations reaching no active output; checkpoints, + new glyphs, and non-positioning changes remain conservative full evaluations. Consecutive glyphs reuse their resolved + font binding and policy program. The mechanisms compound: selective gather alone regressed and operation liveness + alone was neutral, while together moved canonical Bitmap/MTSDF/Slug resize medians from 4.878/5.355/6.001 ms to + 4.207/4.833/5.615 ms. Adding the lookup cache measured 3.981 then 4.120 ms for Bitmap, 4.646 ms for MTSDF, and + 5.622 ms for Slug. An isolated Bitmap resize measured 4.799 ms, so JIT-sensitive evidence does not yet close the + sub-4 ms gate. Optimized Wasm is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes. + - **Removed visual-order scratch from proven LTR positioning** — A positioning pass now checks once that all retained bidi levels are even and no run is direction-overridden, then walks logical clusters directly. Odd levels and overrides retain the complete UAX #9 L1/L2 path. On the unchanged 25,515-positioned/21,805-renderable stress case, diff --git a/docs/packages/text.md b/docs/packages/text.md index a4c582f4..f230ada9 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:0e38f1a6590252d59ab0bae52ce79c6984c0cb47d859a9e2386ee6ab33a3ac7f' +source_digest: 'sha256:5219712d1fc2859a65b1018bead1334d25c58850f351c7408983a97e2149f17b' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -701,6 +701,17 @@ Order-preserving reflow now compares retained positioned glyphs directly by stab Trivially LTR positioning now proves all retained bidi levels are even and no run is direction-overridden once, then walks logical clusters without filling line-level or visual-order scratch. Odd levels and overrides preserve the complete UAX #9 L1/L2 path. Two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 milliseconds; two optimized runs measured 4.849 and 4.878 milliseconds with the same 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 milliseconds. The optimized module is 1,065,857 raw / 403,525 gzip / 318,137 Brotli bytes, and the sub-4 ms gate remains open. +Policy validation now also compiles which source lanes and straight-line operations can reach each physical buffer. +Position-only updates gather zero placeholders for unreachable inputs and skip unreachable scalar/SIMD operations; +checkpoints, inserted glyphs, and non-positioning updates still evaluate every declared input and output. The gather +loop caches only consecutive font-binding and immutable policy-program resolution, while glyph selection and resource +selection remain per glyph. A factorial measurement rejected either optimization in isolation: selective gathering +measured 5.027/5.685/6.443 ms for Bitmap/MTSDF/Slug resize, and operation liveness measured +4.880/5.329/5.985 ms. Combined they measured 4.207/4.833/5.615 ms; adding resolution caching measured Bitmap at +3.981 and 4.120 ms in two canonical full-sequence runs, MTSDF at 4.646 ms, and Slug at 5.622 ms. A case-isolated +Bitmap run measured 4.799 ms, exposing material Node/Wasm tiering sensitivity, so the sub-4 ms target is approached but +not reproducibly closed. Final optimized size is 1,069,973 raw / 405,888 gzip / 319,558 Brotli bytes. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index e131a3ed..00121fc0 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -267,6 +267,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-200 | Physical render-plan writes are selected by validated policy dependencies, not a frame-wide dirty flag. Positioning stores exact six-F32/four-U32 change bits in a compact side lane without enlarging the 60-byte `PlanGlyph`; policy registration propagates those dependencies through the straight-line program once. Ordered-direct and stable-indirect compilers intersect glyph and buffer masks, while new, rebound, or conservatively described records rewrite every output. At 21,805 renderable instances, full-column resize emits 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan writes; font-size emits 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians are 4.599/4.916/6.057 ms. The exact payload reduction is accepted, but the latency evidence does not establish packing dominance or a general speedup and still fails the sub-4 ms gate; layout composition is the next measured target. Optimized Wasm is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli bytes. | Accepted | | D-201 | Order-preserving positioned-glyph reconciliation compares equal stable-ID slots directly and builds the exact identity index only when output order or membership changes. Reordered content retains the map fallback and an exact revision-preservation test. The canonical benchmark may isolate a named case and load an explicit temporary profiling Wasm, but its default workload and shipping artifact remain unchanged. A symbolized no-`std` profile identifies positioning as the largest sampled full-column-resize function and policy gather as the next largest. Current five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes. | Accepted | | D-202 | Positioned layout admits a conservative trivial-LTR lane only when every retained bidi level is even and no shaping run carries a direction override. That lane traverses logical clusters directly and does not fill line-level, visual-cluster, or visual-level scratch; any odd level or override retains the complete UAX #9 L1/L2 path. Exact positioning tests cover the admitted and rejected predicates, while the mixed-direction package golden remains unchanged. On the unchanged 25,515-positioned/21,805-renderable workload, two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms; two optimized runs measured 4.849 and 4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. The sub-4 ms gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes; the six-byte Brotli increase is the meaningful compressed-size comparison, while gzip changed materially from code-layout interaction. | Accepted | +| D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index a9198e8d..ba9e8917 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1172,6 +1172,21 @@ retains a measured positioning reduction without claiming the still-open sub-4 m raw / 403,525 gzip / 318,137 Brotli bytes; Brotli changes by six bytes from D-201, while gzip is sensitive to the new code layout. +Policy registration now closes the dependency chain in both directions: forward propagation records which F32/U32 +input lanes reach each physical buffer, and reverse liveness records which physical buffers consume each operation. +For a positioned update, the frame-level semantic-change union selects active buffers, gather reads only their required +lanes, and scalar/SIMD execution skips operations that reach no active buffer. New glyphs, checkpoints, and changes +outside retained positioning force all inputs. Consecutive glyphs reuse their last resolved font binding and immutable +policy program without caching selection results. + +The mechanisms must be evaluated together. Selective gather alone measured 5.027/5.685/6.443 ms for +Bitmap/MTSDF/Slug resize and operation liveness alone measured 4.880/5.329/5.985 ms, against the preceding +4.878/5.355/6.001 ms checkpoint. Combined they measured 4.207/4.833/5.615 ms. Adding binding/program resolution +caching measured 3.981 and 4.120 ms in two canonical full-sequence Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for +Slug. A separate case-isolated Bitmap process measured 4.799 ms, so process/JIT tiering prevents treating the single +3.981 ms result as closure of the sub-4 ms gate. Optimized Wasm is 1,069,973 raw / 405,888 gzip / 319,558 Brotli +bytes, an increase of 14,116 raw / 2,363 gzip / 1,421 Brotli bytes over D-202. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 0554c513..10e69f11 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -456,6 +456,63 @@ impl ValidatedPolicy { })?; Some(&self.execution.get(index)?.buffer_dependency_masks) } + + pub(crate) fn input_masks_for_changes( + &self, + capability_set: CapabilitySetId, + technique: TechniqueId, + variant: u16, + semantic_changes: u16, + force_all: bool, + ) -> Option<(u32, u32)> { + let index = self + .programs + .iter() + .position(|program| { + program.capability_set == capability_set + && program.technique == technique + && program.variant == variant + }) + .or_else(|| { + self.programs.iter().position(|program| { + program.capability_set.0 == 0 + && program.technique == technique + && program.variant == variant + }) + })?; + let execution = self.execution.get(index)?; + let program = self.programs.get(index)?; + if force_all || semantic_changes == super::positioning::ALL_SEMANTIC_CHANGES { + return Some(( + low_bits(program.f32_input_count), + low_bits(program.u32_input_count), + )); + } + let all_buffers = (1_u32 << execution.buffer_dependency_masks.len()) - 1; + let active_buffers = execution.buffer_dependency_masks.iter().enumerate().fold( + 0_u32, + |active, (buffer, dependency)| { + active | (u32::from(dependency & semantic_changes != 0) << buffer) + }, + ) & all_buffers; + let mut f32_inputs = 0_u32; + let mut u32_inputs = 0_u32; + for buffer in 0..execution.buffer_dependency_masks.len() { + if active_buffers & (1 << buffer) != 0 { + f32_inputs |= execution.buffer_f32_input_masks[buffer]; + u32_inputs |= execution.buffer_u32_input_masks[buffer]; + } + } + Some((f32_inputs, u32_inputs)) + } +} + +fn low_bits(count: u8) -> u32 { + if count == 0 { + 0 + } else { + u32::MAX >> (32 - u32::from(count)) + } } fn policy_fingerprint(descriptor: &PolicyDescriptor) -> u64 { @@ -595,20 +652,36 @@ fn mix_u32(fingerprint: &mut u64, value: u32) { struct ExecutableProgram { store_buffer_indices: Vec, buffer_dependency_masks: Vec, + buffer_f32_input_masks: Vec, + buffer_u32_input_masks: Vec, + operation_buffer_masks: Vec, } impl ExecutableProgram { fn new(program: &ProgramDescriptor) -> Result { + let operation_buffer_masks = operation_buffer_masks(program)?; let mut store_buffer_indices = Vec::new(); let mut buffer_dependency_masks = Vec::new(); + let mut buffer_f32_input_masks = Vec::new(); + let mut buffer_u32_input_masks = Vec::new(); store_buffer_indices .try_reserve_exact(program.operations.len()) .map_err(|_| PolicyError::AllocationFailed)?; buffer_dependency_masks .try_reserve_exact(program.buffers.len()) .map_err(|_| PolicyError::AllocationFailed)?; + buffer_f32_input_masks + .try_reserve_exact(program.buffers.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + buffer_u32_input_masks + .try_reserve_exact(program.buffers.len()) + .map_err(|_| PolicyError::AllocationFailed)?; buffer_dependency_masks.resize(program.buffers.len(), 0); + buffer_f32_input_masks.resize(program.buffers.len(), 0); + buffer_u32_input_masks.resize(program.buffers.len(), 0); let mut register_dependencies = [0_u16; MAX_REGISTERS]; + let mut register_f32_inputs = [0_u32; MAX_REGISTERS]; + let mut register_u32_inputs = [0_u32; MAX_REGISTERS]; for operation in &program.operations { let index = match store_buffer(operation) { Some(buffer) => program @@ -627,14 +700,178 @@ impl ExecutableProgram { &mut register_dependencies, &mut buffer_dependency_masks, )?; + propagate_input_dependencies( + program, + operation, + &mut register_f32_inputs, + &mut register_u32_inputs, + &mut buffer_f32_input_masks, + &mut buffer_u32_input_masks, + )?; } Ok(Self { store_buffer_indices, buffer_dependency_masks, + buffer_f32_input_masks, + buffer_u32_input_masks, + operation_buffer_masks, }) } } +fn operation_buffer_masks(program: &ProgramDescriptor) -> Result, PolicyError> { + let mut masks = Vec::new(); + masks + .try_reserve_exact(program.operations.len()) + .map_err(|_| PolicyError::AllocationFailed)?; + masks.resize(program.operations.len(), 0); + let mut register_consumers = [0_u32; MAX_REGISTERS]; + for (operation_index, operation) in program.operations.iter().enumerate().rev() { + if let Some(buffer) = store_buffer(operation) { + let buffer_index = program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + let mask = 1_u32 << buffer_index; + masks[operation_index] = mask; + register_consumers[usize::from(operation_sources(operation)[0])] |= mask; + continue; + } + let Some(target) = operation_target(operation) else { + continue; + }; + let mask = register_consumers[usize::from(target)]; + masks[operation_index] = mask; + register_consumers[usize::from(target)] = 0; + for source in operation_sources(operation) { + if source != u8::MAX { + register_consumers[usize::from(source)] |= mask; + } + } + } + Ok(masks) +} + +fn operation_target(operation: &Operation) -> Option { + match *operation { + Operation::LoadF32 { target, .. } + | Operation::LoadU32 { target, .. } + | Operation::ConstantF32 { target, .. } + | Operation::ConstantU32 { target, .. } + | Operation::AddF32 { target, .. } + | Operation::SubtractF32 { target, .. } + | Operation::MultiplyF32 { target, .. } + | Operation::LessThanF32 { target, .. } + | Operation::SelectF32 { target, .. } + | Operation::ConvertU32ToF32 { target, .. } => Some(target), + Operation::StoreF32 { .. } | Operation::StoreU32 { .. } | Operation::StoreU16 { .. } => { + None + } + } +} + +fn operation_sources(operation: &Operation) -> [u8; 3] { + match *operation { + Operation::AddF32 { left, right, .. } + | Operation::SubtractF32 { left, right, .. } + | Operation::MultiplyF32 { left, right, .. } + | Operation::LessThanF32 { left, right, .. } => [left, right, u8::MAX], + Operation::SelectF32 { + condition, + when_true, + when_false, + .. + } => [condition, when_true, when_false], + Operation::ConvertU32ToF32 { source, .. } + | Operation::StoreF32 { source, .. } + | Operation::StoreU32 { source, .. } + | Operation::StoreU16 { source, .. } => [source, u8::MAX, u8::MAX], + Operation::LoadF32 { .. } + | Operation::LoadU32 { .. } + | Operation::ConstantF32 { .. } + | Operation::ConstantU32 { .. } => [u8::MAX; 3], + } +} + +fn propagate_input_dependencies( + program: &ProgramDescriptor, + operation: &Operation, + f32_registers: &mut [u32; MAX_REGISTERS], + u32_registers: &mut [u32; MAX_REGISTERS], + f32_buffers: &mut [u32], + u32_buffers: &mut [u32], +) -> Result<(), PolicyError> { + match *operation { + Operation::LoadF32 { target, field } => { + f32_registers[usize::from(target)] = 1_u32 << field; + u32_registers[usize::from(target)] = 0; + } + Operation::LoadU32 { target, field } => { + f32_registers[usize::from(target)] = 0; + u32_registers[usize::from(target)] = 1_u32 << field; + } + Operation::ConstantF32 { target, .. } | Operation::ConstantU32 { target, .. } => { + f32_registers[usize::from(target)] = 0; + u32_registers[usize::from(target)] = 0; + } + Operation::AddF32 { + target, + left, + right, + } + | Operation::SubtractF32 { + target, + left, + right, + } + | Operation::MultiplyF32 { + target, + left, + right, + } + | Operation::LessThanF32 { + target, + left, + right, + } => { + f32_registers[usize::from(target)] = + f32_registers[usize::from(left)] | f32_registers[usize::from(right)]; + u32_registers[usize::from(target)] = + u32_registers[usize::from(left)] | u32_registers[usize::from(right)]; + } + Operation::SelectF32 { + target, + condition, + when_true, + when_false, + } => { + f32_registers[usize::from(target)] = f32_registers[usize::from(condition)] + | f32_registers[usize::from(when_true)] + | f32_registers[usize::from(when_false)]; + u32_registers[usize::from(target)] = u32_registers[usize::from(condition)] + | u32_registers[usize::from(when_true)] + | u32_registers[usize::from(when_false)]; + } + Operation::ConvertU32ToF32 { target, source } => { + f32_registers[usize::from(target)] = f32_registers[usize::from(source)]; + u32_registers[usize::from(target)] = u32_registers[usize::from(source)]; + } + Operation::StoreF32 { source, buffer, .. } + | Operation::StoreU32 { source, buffer, .. } + | Operation::StoreU16 { source, buffer, .. } => { + let index = program + .buffers + .iter() + .position(|schema| schema.id == buffer) + .ok_or(PolicyError::UnknownBuffer)?; + f32_buffers[index] |= f32_registers[usize::from(source)]; + u32_buffers[index] |= u32_registers[usize::from(source)]; + } + } + Ok(()) +} + fn propagate_dependencies( program: &ProgramDescriptor, operation: &Operation, @@ -887,6 +1124,9 @@ fn execute_record( let mut registers = [0_u32; MAX_REGISTERS]; let mut values = [0_u32; MAX_OUTPUT_LANES]; for (operation_index, operation) in program.operations.iter().enumerate() { + if execution.operation_buffer_masks[operation_index] & active_buffers == 0 { + continue; + } match *operation { Operation::LoadF32 { target, field } => { registers[usize::from(target)] = @@ -1000,6 +1240,9 @@ unsafe fn execute_simd_records( let mut registers = [u32x4_splat(0); MAX_REGISTERS]; let mut values = [u32x4_splat(0); MAX_OUTPUT_LANES]; for (operation_index, operation) in program.operations.iter().enumerate() { + if execution.operation_buffer_masks[operation_index] & active_buffers == 0 { + continue; + } match *operation { Operation::LoadF32 { target, field } => { // SAFETY: validation proves this field contains four records from `input_record`. @@ -1585,6 +1828,7 @@ mod tests { const BITMAP: TechniqueId = TechniqueId(1); const PROGRAM: ProgramId = ProgramId(1); const ORIGINS: BufferId = BufferId(1); + const COLORS: BufferId = BufferId(2); const CAPABILITY: CapabilitySetId = CapabilitySetId(1); fn valid_capability_set() -> CapabilitySet { @@ -1669,6 +1913,35 @@ mod tests { ); } + #[test] + fn compiles_each_operation_to_only_its_reachable_buffers() { + let mut program = valid_program(); + program.buffers.push(BufferSchema::packed( + COLORS, + ScalarType::U32, + 1, + BUFFER_USAGE_STORAGE | BUFFER_USAGE_COPY_DST, + 1, + )); + program.operations.extend([ + Operation::ConstantU32 { + target: 2, + value: 0xff00_00ff, + }, + Operation::StoreU32 { + source: 2, + buffer: COLORS, + lane: 0, + }, + ]); + let policy = ValidatedPolicy::new(descriptor(vec![program])).unwrap(); + + assert_eq!( + policy.execution[0].operation_buffer_masks, + [1, 1, 1, 1, 2, 2] + ); + } + #[test] fn input_sources_are_exact_and_participate_in_policy_identity() { let program = valid_program(); diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index 82cd3329..11b891f8 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -112,28 +112,74 @@ impl PolicyGatherWorkspace { policy: &ValidatedPolicy, capability_set: CapabilitySetId, input: LayoutPlanInput<'_>, + force_all_inputs: bool, mut binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, ) -> Result<(), GatherError> { validate_semantic_shape(input)?; self.reserve_policy(policy, input.glyphs.len())?; self.clear(); + let semantic_changes = if input.semantic_change_masks.len() == input.glyphs.len() { + input + .semantic_change_masks + .iter() + .copied() + .fold(0_u16, |union, mask| union | mask) + } else { + super::positioning::ALL_SEMANTIC_CHANGES + }; + let mut cached_font_handle = None; + let mut cached_binding = None; + let mut cached_program = None; for glyph_index in 0..input.glyphs.len() { let glyph = input.glyphs[glyph_index]; - let binding = - binding_for_font(glyph.font_handle).ok_or(GatherError::FontBindingMissing)?; + let binding = if cached_font_handle == Some(glyph.font_handle) { + cached_binding.ok_or(GatherError::FontBindingMissing)? + } else { + let binding = + binding_for_font(glyph.font_handle).ok_or(GatherError::FontBindingMissing)?; + cached_font_handle = Some(glyph.font_handle); + cached_binding = Some(binding); + binding + }; let Some(selected) = binding.select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio) else { continue; }; - let program = policy - .program( - capability_set, - binding.technique(), - binding.program_variant(), - ) - .ok_or(GatherError::ProgramMissing)?; - self.gather_fields(input, glyph_index, binding, selected, program)?; + let technique = binding.technique(); + let variant = binding.program_variant(); + let (program, f32_inputs, u32_inputs) = match cached_program { + Some((cached_technique, cached_variant, program, f32_inputs, u32_inputs)) + if cached_technique == technique && cached_variant == variant => + { + (program, f32_inputs, u32_inputs) + } + _ => { + let program = policy + .program(capability_set, technique, variant) + .ok_or(GatherError::ProgramMissing)?; + let (f32_inputs, u32_inputs) = policy + .input_masks_for_changes( + capability_set, + technique, + variant, + semantic_changes, + force_all_inputs, + ) + .ok_or(GatherError::ProgramMissing)?; + cached_program = Some((technique, variant, program, f32_inputs, u32_inputs)); + (program, f32_inputs, u32_inputs) + } + }; + self.gather_fields( + input, + glyph_index, + binding, + selected, + program, + f32_inputs, + u32_inputs, + )?; let resource = binding .resources() .get( @@ -196,11 +242,13 @@ impl PolicyGatherWorkspace { binding: &FontRenderBinding, selected: SelectedGlyphBinding, program: &ProgramDescriptor, + required_f32: u32, + required_u32: u32, ) -> Result<(), GatherError> { let f32_count = usize::from(program.f32_input_count); let u32_count = usize::from(program.u32_input_count); for field in 0..self.f32_fields.len() { - let value = if field < f32_count { + let value = if field < f32_count && required_f32 & (1 << field) != 0 { let source = program.inputs[field]; source_f32( source.scope, @@ -216,7 +264,7 @@ impl PolicyGatherWorkspace { self.f32_fields[field].push(value)?; } for field in 0..self.u32_fields.len() { - let value = if field < u32_count { + let value = if field < u32_count && required_u32 & (1 << field) != 0 { let source = program.inputs[f32_count + field]; source_u32( source.scope, @@ -527,6 +575,7 @@ mod tests { semantic_f32: &[&semantic_x], semantic_u32: &[&semantic_kind], }, + true, |handle| (handle == 9).then_some(&binding), ) .unwrap(); @@ -574,6 +623,39 @@ mod tests { assert_eq!(workspace.capacities(), capacities); } + #[test] + fn changed_gather_reads_only_inputs_reaching_changed_buffers() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let semantic_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + glyphs: &glyphs, + semantic_change_masks: &[1, 1], + semantic_f32: &[&semantic_x], + semantic_u32: &[&semantic_kind], + }, + false, + |_| Some(&binding), + ) + .unwrap(); + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.f32_fields[0], &[10.0, 20.0]); + assert!( + input.f32_fields[1..] + .iter() + .all(|field| *field == [0.0, 0.0]) + ); + assert!(input.u32_fields.iter().all(|field| *field == [0, 0])); + } + #[test] fn missing_program_binding_and_source_are_explicit() { let binding = binding(); @@ -590,6 +672,7 @@ mod tests { semantic_f32: &[], semantic_u32: &[], }, + true, |_| None, ), Err(GatherError::FontBindingMissing) @@ -604,6 +687,7 @@ mod tests { semantic_f32: &[], semantic_u32: &[], }, + true, |_| Some(&binding), ), Err(GatherError::ProgramMissing) @@ -618,6 +702,7 @@ mod tests { semantic_f32: &[], semantic_u32: &[], }, + true, |_| Some(&binding), ), Err(GatherError::SourceFieldMissing) diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 3fa82cf8..134dc729 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -623,6 +623,7 @@ impl TextEngine { semantic_f32: &semantic_f32, semantic_u32: &semantic_u32, }, + checkpoint || !positioned_changed, |handle| { font_bindings .iter() diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index 862d7912..1af6a929 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -183,7 +183,14 @@ function execute(bytes, allowGrowth = false, operation = 'text_update') { if (resultPointer === 0) throw new Error('text_update returned a null result'); const layout = abi.layouts.engineResult; const result = new DataView(memory.buffer, resultPointer, layout.size); - requireStatus(result.getUint32(layout.status, true), operation); + const status = result.getUint32(layout.status, true); + if (status !== abi.status.ok) { + const requiredRequestCapacity = result.getUint32(layout.requiredRequestCapacity, true); + const requiredResultCapacity = result.getUint32(layout.requiredResultCapacity, true); + throw new Error( + `${operation} failed with status ${status}; required request=${requiredRequestCapacity}, result=${requiredResultCapacity}`, + ); + } const patchCount = result.getUint32(layout.patchCount, true); const patchesOffset = result.getUint32(layout.patchesOffset, true); const patchLayout = abi.layouts.enginePatch; From d0f1e8afff0493536b900cd285110e1b49fbb2e2 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 19:53:41 -0400 Subject: [PATCH 050/128] feat(text): expose retained frame host --- docs/log.md | 7 + docs/packages/text.md | 10 +- docs/planning/rust-layout-engine.md | 8 + .../text/src/internal/text-engine-host.ts | 272 ++++++++++++++++++ packages/text/src/shaper.ts | 49 ++++ .../integration/text-engine-host.test.mjs | 60 ++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 packages/text/src/internal/text-engine-host.ts create mode 100644 packages/text/tests/integration/text-engine-host.test.mjs diff --git a/docs/log.md b/docs/log.md index 48242280..d3e0ff53 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Promoted the retained frame ABI into a production host** — `RuntimeShaper` now exposes one package-internal, + ownership-checked view of its existing Wasm instance to a typed text-engine host. The host owns cold policy, + font-binding, font-stack, and session registration; reserves before pinning; writes requests into the retained arena; + and returns the published A/B slot as borrowed bytes without copying. An integration test publishes slots A and B + through the compiled module and proves B does not mutate A. Three still consumes the legacy paragraph batch in this + checkpoint; request/policy compilation and render-plan lowering are the next cutover slices. + - **Made policy work dependency-directed from gather through execution** — Policy registration now compiles both input-to-buffer and operation-to-buffer reachability. A positioned update gathers only source lanes reaching a semantically changed output and the scalar/SIMD executors skip operations reaching no active output; checkpoints, diff --git a/docs/packages/text.md b/docs/packages/text.md index f230ada9..839e7759 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:5219712d1fc2859a65b1018bead1334d25c58850f351c7408983a97e2149f17b' +source_digest: 'sha256:ae50b899148c1413445945b56f61b230046f081ac4a724a9001389db0f4c57f4' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -712,6 +712,14 @@ measured 5.027/5.685/6.443 ms for Bitmap/MTSDF/Slug resize, and operation livene Bitmap run measured 4.799 ms, exposing material Node/Wasm tiering sensitivity, so the sub-4 ms target is approached but not reproducibly closed. Final optimized size is 1,069,973 raw / 405,888 gzip / 319,558 Brotli bytes. +The production `RuntimeShaper` and retained text engine now share the same initialized Wasm module and registered-font +state. A package-internal host owns policy, font-binding, font-stack, and session lifecycles, performs cold reservation +before pinning, copies only request bytes into the retained staging arena, and exposes each A/B render-plan publication +as a borrowed direct-memory view. The host does not decode typography or copy plan payloads. A compiled-Wasm integration +test publishes alternating slots and proves the preceding slot remains byte-stable. This is the production ABI seam; +the public runtime and Three adapter still use the legacy paragraph-batch path until request/policy compilation and GPU +plan lowering are connected. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index ba9e8917..eecc76f4 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1187,6 +1187,14 @@ Slug. A separate case-isolated Bitmap process measured 4.799 ms, so process/JIT 3.981 ms result as closure of the sub-4 ms gate. Optimized Wasm is 1,069,973 raw / 405,888 gzip / 319,558 Brotli bytes, an increase of 14,116 raw / 2,363 gzip / 1,421 Brotli bytes over D-202. +The first production cutover slice now shares the already initialized `RuntimeShaper` Wasm instance with a typed +text-engine host. The host owns raw policy/font-binding/font-stack registration and session disposal, invokes cold +reservation before pinning when the exact request outgrows its arena, writes directly into retained request memory, and +returns a borrowed view over the published A/B slot. It performs no layout, shaping, or render-plan interpretation in +TypeScript and does not create a second module instance. A compiled-Wasm integration test observes revisions 1/2 and +slots A/B while proving publication B leaves A byte-stable. The Three adapter is not switched by this slice; production +request/policy compilation and plan lowering remain open and are not claimed by the host proof. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/src/internal/text-engine-host.ts b/packages/text/src/internal/text-engine-host.ts new file mode 100644 index 00000000..89f0543c --- /dev/null +++ b/packages/text/src/internal/text-engine-host.ts @@ -0,0 +1,272 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import { runtimeShaperEngineExports, type RuntimeShaper } from '../shaper.js'; + +const MAX_U32 = 0xffff_ffff; + +export interface TextEngineSessionOptions { + readonly handle: number; + readonly requestCapacity: number; + readonly resultCapacity: number; + readonly textCapacity?: number; +} + +/** + * One borrowed A/B render-plan publication. Its bytes remain readable until the next call into the same Wasm module; + * synchronous renderers must consume them before updating, reserving, disposing, or otherwise calling the shaper. + */ +export interface TextEnginePublication { + readonly bytes: Uint8Array; + readonly memoryBuffer: ArrayBuffer; + readonly memoryGrew: boolean; + readonly engineRevision: number; + readonly planRevision: number; + readonly requiredBaseRevision: number; + readonly publicationGeneration: number; + readonly outputSlot: number; + readonly flags: number; + readonly policyHandle: number; + readonly capabilitySet: number; + readonly primitiveCount: number; + readonly patchCount: number; + readonly drawCount: number; +} + +export class TextEngineStatusError extends Error { + readonly status: number; + readonly requiredRequestCapacity: number; + readonly requiredResultCapacity: number; + + constructor(operation: string, status: number, requiredRequestCapacity = 0, requiredResultCapacity = 0) { + super( + `${operation} failed with text-engine status ${status}` + + (requiredRequestCapacity === 0 && requiredResultCapacity === 0 + ? '' + : ` (required request=${requiredRequestCapacity}, result=${requiredResultCapacity})`), + ); + this.name = 'TextEngineStatusError'; + this.status = status; + this.requiredRequestCapacity = requiredRequestCapacity; + this.requiredResultCapacity = requiredResultCapacity; + } +} + +/** Internal lifecycle owner for retained policy, font-stack, and session state in a RuntimeShaper's Wasm instance. */ +export class TextEngineHost { + readonly #exports; + readonly #sessions = new Set(); + readonly #policies = new Set(); + readonly #fontStacks = new Set(); + #disposed = false; + + constructor(shaper: RuntimeShaper) { + this.#exports = runtimeShaperEngineExports(shaper); + } + + registerFontBinding(fontHandle: number, bytes: Uint8Array): void { + this.#assertActive(); + uint32Handle(fontHandle, 'font handle'); + this.#withBytes(bytes, (pointer, length) => + requireStatus(this.#exports.registerFontBinding(fontHandle, pointer, length), 'register font binding'), + ); + } + + registerFontStack(handle: number, fontHandles: readonly number[]): void { + this.#assertActive(); + uint32Handle(handle, 'font stack handle'); + if (fontHandles.length === 0) throw new RangeError('font stack must contain at least one font'); + const bytes = new Uint8Array(checkedProduct(fontHandles.length, 4, 'font stack bytes')); + const view = new DataView(bytes.buffer); + for (const [index, fontHandle] of fontHandles.entries()) { + view.setUint32(index * 4, uint32Handle(fontHandle, 'font handle'), true); + } + this.#withBytes(bytes, (pointer) => + requireStatus(this.#exports.registerFontStack(handle, pointer, fontHandles.length), 'register font stack'), + ); + this.#fontStacks.add(handle); + } + + registerPolicy(handle: number, bytes: Uint8Array): void { + this.#assertActive(); + uint32Handle(handle, 'policy handle'); + this.#withBytes(bytes, (pointer, length) => + requireStatus(this.#exports.registerPolicy(handle, pointer, length), 'register render policy'), + ); + this.#policies.add(handle); + } + + createSession(options: TextEngineSessionOptions): TextEngineSession { + this.#assertActive(); + const handle = uint32Handle(options.handle, 'session handle'); + const requestCapacity = uint32(options.requestCapacity, 'request capacity'); + const resultCapacity = uint32(options.resultCapacity, 'result capacity'); + const textCapacity = uint32(options.textCapacity ?? 0, 'text capacity'); + requireStatus( + this.#exports.createSession(handle, requestCapacity, resultCapacity, textCapacity), + 'create text session', + ); + const session = new TextEngineSession(this.#exports, handle, requestCapacity, resultCapacity, textCapacity, () => + this.#sessions.delete(session), + ); + this.#sessions.add(session); + return session; + } + + dispose(): void { + if (this.#disposed) return; + for (const session of [...this.#sessions]) session.dispose(); + for (const handle of this.#fontStacks) requireStatus(this.#exports.disposeFontStack(handle), 'dispose font stack'); + for (const handle of this.#policies) requireStatus(this.#exports.disposePolicy(handle), 'dispose render policy'); + this.#fontStacks.clear(); + this.#policies.clear(); + this.#disposed = true; + } + + #withBytes(bytes: Uint8Array, call: (pointer: number, length: number) => void): void { + if (!(bytes instanceof Uint8Array) || bytes.byteLength === 0) { + throw new TypeError('text-engine registration bytes must be a nonempty Uint8Array'); + } + const length = uint32(bytes.byteLength, 'registration byte length'); + const pointer = this.#exports.allocate(length); + if (pointer === 0) + throw new TextEngineStatusError('allocate registration bytes', textShaperAbi.status.resultTooLarge); + try { + new Uint8Array(this.#exports.memory.buffer, pointer, length).set(bytes); + call(pointer, length); + } finally { + this.#exports.deallocate(pointer, length); + } + } + + #assertActive(): void { + if (this.#disposed) throw new Error('text engine host is disposed'); + } +} + +export class TextEngineSession { + readonly #exports; + readonly #handle: number; + readonly #onDispose: () => void; + #requestCapacity: number; + #resultCapacity: number; + #textCapacity: number; + #disposed = false; + + constructor( + exports: ReturnType, + handle: number, + requestCapacity: number, + resultCapacity: number, + textCapacity: number, + onDispose: () => void, + ) { + this.#exports = exports; + this.#handle = handle; + this.#requestCapacity = requestCapacity; + this.#resultCapacity = resultCapacity; + this.#textCapacity = textCapacity; + this.#onDispose = onDispose; + } + + get handle(): number { + return this.#handle; + } + + reserve(requestCapacity: number, resultCapacity: number, textCapacity: number = this.#textCapacity): void { + this.#assertActive(); + requestCapacity = uint32(requestCapacity, 'request capacity'); + resultCapacity = uint32(resultCapacity, 'result capacity'); + textCapacity = uint32(textCapacity, 'text capacity'); + requireStatus( + this.#exports.reserveSession(this.#handle, requestCapacity, resultCapacity, textCapacity), + 'reserve text session', + ); + this.#requestCapacity = Math.max(this.#requestCapacity, requestCapacity); + this.#resultCapacity = Math.max(this.#resultCapacity, resultCapacity); + this.#textCapacity = Math.max(this.#textCapacity, textCapacity); + } + + update(request: Uint8Array): TextEnginePublication { + this.#assertActive(); + if (!(request instanceof Uint8Array) || request.byteLength === 0) { + throw new TypeError('text update request must be a nonempty Uint8Array'); + } + const requestLength = uint32(request.byteLength, 'text update byte length'); + if (requestLength > this.#requestCapacity || requestLength > this.#exports.requestCapacity(this.#handle)) { + this.reserve(requestLength, this.#resultCapacity); + } + const requestPointer = this.#exports.requestPointer(this.#handle); + if (requestPointer === 0) + throw new TextEngineStatusError('resolve text request arena', textShaperAbi.status.sessionMissing); + const before = this.#exports.memory.buffer; + new Uint8Array(before, requestPointer, requestLength).set(request); + const resultPointer = this.#exports.textUpdate(this.#handle, requestPointer, requestLength); + const memoryBuffer = this.#exports.memory.buffer; + if (resultPointer === 0) + throw new TextEngineStatusError('publish text update', textShaperAbi.status.resultTooLarge); + const layout = textShaperAbi.layouts.engineResult; + if (resultPointer + layout.size > memoryBuffer.byteLength) { + throw new RangeError('text engine returned an out-of-bounds result header'); + } + const header = new DataView(memoryBuffer, resultPointer, layout.size); + const status = header.getUint32(layout.status, true); + const requiredRequestCapacity = header.getUint32(layout.requiredRequestCapacity, true); + const requiredResultCapacity = header.getUint32(layout.requiredResultCapacity, true); + if (status !== textShaperAbi.status.ok) { + throw new TextEngineStatusError('publish text update', status, requiredRequestCapacity, requiredResultCapacity); + } + const byteLength = header.getUint32(layout.byteLength, true); + if (byteLength < layout.size || resultPointer + byteLength > memoryBuffer.byteLength) { + throw new RangeError('text engine returned an out-of-bounds publication'); + } + this.#requestCapacity = header.getUint32(layout.requestCapacity, true); + this.#resultCapacity = header.getUint32(layout.resultCapacity, true); + return { + bytes: new Uint8Array(memoryBuffer, resultPointer, byteLength), + memoryBuffer, + memoryGrew: memoryBuffer !== before, + engineRevision: header.getUint32(layout.engineRevision, true), + planRevision: header.getUint32(layout.planRevision, true), + requiredBaseRevision: header.getUint32(layout.requiredBaseRevision, true), + publicationGeneration: header.getUint32(layout.publicationGeneration, true), + outputSlot: header.getUint32(layout.outputSlot, true), + flags: header.getUint32(layout.flags, true), + policyHandle: header.getUint32(layout.policyHandle, true), + capabilitySet: header.getUint32(layout.capabilitySet, true), + primitiveCount: header.getUint32(layout.primitiveCount, true), + patchCount: header.getUint32(layout.patchCount, true), + drawCount: header.getUint32(layout.drawCount, true), + }; + } + + dispose(): void { + if (this.#disposed) return; + requireStatus(this.#exports.disposeSession(this.#handle), 'dispose text session'); + this.#disposed = true; + this.#onDispose(); + } + + #assertActive(): void { + if (this.#disposed) throw new Error('text engine session is disposed'); + } +} + +function requireStatus(status: number, operation: string): void { + if (status !== textShaperAbi.status.ok) throw new TextEngineStatusError(operation, status); +} + +function uint32(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_U32) throw new RangeError(`${label} must be a u32`); + return value; +} + +function uint32Handle(value: number, label: string): number { + value = uint32(value, label); + if (value === 0) throw new RangeError(`${label} must be nonzero`); + return value; +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} diff --git a/packages/text/src/shaper.ts b/packages/text/src/shaper.ts index 0727919d..53f577fb 100644 --- a/packages/text/src/shaper.ts +++ b/packages/text/src/shaper.ts @@ -108,6 +108,13 @@ export function registerRuntimeShaperFontData(shaper: RuntimeShaper, data: Runti shaper._registerFontData(data); } +/** @internal Shared direct-memory access for the retained text-engine host. */ +export function runtimeShaperEngineExports(shaper: RuntimeShaper): ShaperExports { + if (!(shaper instanceof RuntimeShaperImpl)) throw new TypeError('runtime shaper was not created by this package'); + shaper._assertEngineAccess(); + return shaper._engineExports(); +} + interface LayoutBase { readonly size: number; readonly alignment: number; @@ -227,6 +234,27 @@ interface ShaperExports { readonly analyzeBidi: (pointer: number, length: number) => number; readonly resultPointer: () => number; readonly resultLength: () => number; + readonly registerFontBinding: (fontHandle: number, pointer: number, length: number) => number; + readonly registerFontStack: (handle: number, pointer: number, count: number) => number; + readonly disposeFontStack: (handle: number) => number; + readonly registerPolicy: (handle: number, pointer: number, length: number) => number; + readonly disposePolicy: (handle: number) => number; + readonly createSession: ( + handle: number, + requestCapacity: number, + resultCapacity: number, + textCapacity: number, + ) => number; + readonly reserveSession: ( + handle: number, + requestCapacity: number, + resultCapacity: number, + textCapacity: number, + ) => number; + readonly disposeSession: (handle: number) => number; + readonly requestPointer: (handle: number) => number; + readonly requestCapacity: (handle: number) => number; + readonly textUpdate: (handle: number, pointer: number, length: number) => number; } interface ShaperModule { @@ -361,6 +389,16 @@ class RuntimeShaperImpl implements RuntimeShaper { this.#disposed = true; } + /** @internal */ + _engineExports(): ShaperExports { + return this.#exports; + } + + /** @internal */ + _assertEngineAccess(): void { + this.#assertActive(); + } + #call(request: ShapeBatchRequest, ranges: readonly ReshapeRange[] | undefined): ShapedBatchViews { const bytes = packRequest(this.#layouts, request, ranges); const allocation = copyIntoWasm(this.#exports, bytes); @@ -427,6 +465,17 @@ function readModule(instance: WebAssembly.Instance): ShaperModule { analyzeBidi: exportedFunction(instance, functions.analyzeBidi), resultPointer: exportedFunction(instance, functions.resultPointer), resultLength: exportedFunction(instance, functions.resultLength), + registerFontBinding: exportedFunction(instance, functions.registerFontBinding), + registerFontStack: exportedFunction(instance, functions.registerFontStack), + disposeFontStack: exportedFunction(instance, functions.disposeFontStack), + registerPolicy: exportedFunction(instance, functions.registerPolicy), + disposePolicy: exportedFunction(instance, functions.disposePolicy), + createSession: exportedFunction(instance, functions.createSession), + reserveSession: exportedFunction(instance, functions.reserveSession), + disposeSession: exportedFunction(instance, functions.disposeSession), + requestPointer: exportedFunction(instance, functions.requestPointer), + requestCapacity: exportedFunction(instance, functions.requestCapacity), + textUpdate: exportedFunction(instance, functions.textUpdate), }, layouts: textShaperAbi.layouts, }; diff --git a/packages/text/tests/integration/text-engine-host.test.mjs b/packages/text/tests/integration/text-engine-host.test.mjs new file mode 100644 index 00000000..50661947 --- /dev/null +++ b/packages/text/tests/integration/text-engine-host.test.mjs @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { TextEngineHost } from '../../dist/internal/text-engine-host.js'; +import { createRuntimeShaper } from '../../dist/shaper.js'; +import { engineUpdateBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('production text-engine host publishes borrowed A/B plans through the runtime shaper instance', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const shaper = await createRuntimeShaper({ wasm }); + const host = new TextEngineHost(shaper); + const policyHandle = 11; + const sessionId = 5; + host.registerPolicy(policyHandle, renderPolicyBytes(abi)); + const firstRequest = engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + }); + const session = host.createSession({ + handle: sessionId, + requestCapacity: firstRequest.byteLength, + resultCapacity: abi.layouts.engineResult.size, + }); + + const first = session.update(firstRequest); + assert.equal(first.engineRevision, 1); + assert.equal(first.planRevision, 1); + assert.equal(first.requiredBaseRevision, 0); + assert.equal(first.publicationGeneration, 1); + assert.equal(first.outputSlot, 0); + assert.equal(first.policyHandle, policyHandle); + assert.equal(first.bytes.byteLength, abi.layouts.engineResult.size); + const retainedFirst = first.bytes.slice(); + + const second = session.update( + engineUpdateBytes(abi, { + sessionId, + policyHandle, + expectedEngineRevision: first.engineRevision, + consumedPlanRevision: first.planRevision, + acknowledgedPublicationGeneration: first.publicationGeneration, + }), + ); + assert.equal(second.engineRevision, 2); + assert.equal(second.planRevision, 2); + assert.equal(second.requiredBaseRevision, first.planRevision); + assert.equal(second.publicationGeneration, 2); + assert.equal(second.outputSlot, 1); + assert.deepEqual(first.bytes, retainedFirst, 'publishing slot B must not mutate borrowed slot A'); + + host.dispose(); + assert.throws(() => session.update(firstRequest), /disposed/); + shaper.dispose(); +}); From 4920a7e56f7b75ff994bff286ad356ace5282a37 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 20:16:44 -0400 Subject: [PATCH 051/128] feat(text): compile first-party render contracts --- docs/log.md | 9 + docs/packages/text.md | 21 +- docs/planning/decision-register.md | 9 +- docs/planning/rust-layout-engine.md | 10 + .../text/src/internal/font-binding-wire.ts | 429 +++++++++++++++ .../text/src/internal/render-policy-wire.ts | 509 ++++++++++++++++++ .../text/src/internal/text-engine-host.ts | 2 + .../integration/font-binding-wire.test.mjs | 118 ++++ .../integration/text-engine-host.test.mjs | 37 ++ 9 files changed, 1139 insertions(+), 5 deletions(-) create mode 100644 packages/text/src/internal/font-binding-wire.ts create mode 100644 packages/text/src/internal/render-policy-wire.ts create mode 100644 packages/text/tests/integration/font-binding-wire.test.mjs diff --git a/docs/log.md b/docs/log.md index d3e0ff53..2deb6a48 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Compiled the first production Three policy and raster bindings** — One deterministic policy now registers Bitmap, + MTSDF, and Slug together against the retained Rust planner. Draw identity includes numeric `material_id`; storage + identity excludes it, so a renderer may share physical buffers while splitting draws by material. Production binding + compilers lower every validated first-party raster record directly into one field-major request allocation, including + every bitmap strike, and exact tests compare every emitted lane with the established renderer-parity fixtures. Public + string technique and resource identities use deterministic UTF-8 FNV-1a `u32` wire IDs with one runtime-scoped + collision registry; collisions fail registration instead of silently aliasing. This checkpoint does not yet switch + the public Three adapter from its legacy paragraph batches. + - **Promoted the retained frame ABI into a production host** — `RuntimeShaper` now exposes one package-internal, ownership-checked view of its existing Wasm instance to a typed text-engine host. The host owns cold policy, font-binding, font-stack, and session registration; reserves before pinning; writes requests into the retained arena; diff --git a/docs/packages/text.md b/docs/packages/text.md index 839e7759..f5a43a15 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:ae50b899148c1413445945b56f61b230046f081ac4a724a9001389db0f4c57f4' +source_digest: 'sha256:4987208b99eecd7f622a65d67ec9682da6dee9cb4d804b264fcbccf8197f37de' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -119,6 +119,15 @@ sources: - id: raster-records resource: ../../packages/text/src/internal/raster-records.ts title: Shared dependency-light dense-record validation + - id: render-policy-wire + resource: ../../packages/text/src/internal/render-policy-wire.ts + title: First-party render-policy compiler + - id: font-binding-wire + resource: ../../packages/text/src/internal/font-binding-wire.ts + title: First-party font-binding compiler + - id: text-engine-host + resource: ../../packages/text/src/internal/text-engine-host.ts + title: Retained frame host - id: raster-validation resource: ../../packages/text/src/internal/raster-artifact-validation.ts title: Shared standalone raster artifact validation @@ -720,6 +729,16 @@ test publishes alternating slots and proves the preceding slot remains byte-stab the public runtime and Three adapter still use the legacy paragraph-batch path until request/policy compilation and GPU plan lowering are connected. +The first production Three policy registers Bitmap, MTSDF, and Slug together rather than assigning a synthetic +per-benchmark technique number. Its storage key includes technique, program, and raster resource; its draw key adds +`material_id`, clip, depth, and order. A renderer can therefore retain one physical glyph buffer across material +changes while emitting the draw partitions its backend requires. Production font-binding compilers lower validated +bitmap strikes, MTSDF glyph records, and Slug bands directly into one field-major request allocation. Exact integration +tests compare every emitted lane with the established real-font renderer-parity tables. Public string technique and +resource IDs lower through deterministic UTF-8 FNV-1a into the wire's nonzero `u32` namespace, and one runtime-scoped +registry rejects any collision before Rust registration. The policy and binding bytes are still package-internal; +public third-party policy authoring and Three render-plan consumption remain open. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 00121fc0..45e4544e 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -264,10 +264,11 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-197 | Horizontal positioning is retained Rust state and directly feeds policy gather. UAX #9 L1/L2 visual order, slot-local alignment/justification, actual fallback metrics, HarfRust offsets, baseline shift, baked extents, and positive-down bounds execute with `f64` accumulation and one narrowing. Six F32 and four U32 semantic SoA lanes remain policy-readable. Exact final bits assign transactional content revisions; a one-pixel fixture proves no-op revision reuse and changed-content advancement. Compiled real-Inter `text_update` publishes nonempty resource/buffer/patch/primitive/draw tables, while an identical warm frame preserves Wasm memory identity and emits zero patches. Optimized Wasm changes from 1,044,797 / 395,222 / 307,795 to 1,057,210 / 400,071 / 311,492 raw/gzip/Brotli bytes. Complete-path 25,515-glyph timing remains unmeasured. | Accepted | | D-198 | Retained frame invalidation compares exact committed and pending semantic state rather than treating every style or geometry transaction as a full pipeline change. Direction changes restart bidi; shaping inputs restart shaping; metric inputs rebuild measured clusters and flow; positioning/paint inputs rebuild positioned semantics; exact geometry equality with no inline objects skips flow; and an unchanged ordered-direct frame publishes an empty reuse transaction without scanning glyphs. The 25,515-glyph, 8-warmup/31-sample Node run measures Rust cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit at 13.693/0.001/4.090/3.374/13.927/13.986 ms median and 14.111/0.001/4.236/3.706/14.511/14.381 ms p95. The unchanged TypeScript cold/font-size/width/suffix-edit medians are 55.25/11.90/8.36/38.55 ms. This proves the invalidation direction, not final renderer parity: the Rust lane still executes a one-F32 policy rather than canonical Bitmap's five buffers, so full Bitmap packing remains a required comparison before publishing speedup ratios. Optimized Wasm is 1,060,175 / 400,500 / 316,984 raw/gzip/Brotli bytes. The sequential benchmark process reaches 76.25 MiB after multiple disposed sessions; that is not a per-session requirement or an accepted memory target. | Accepted | | D-199 | Renderer-parity performance evidence uses validated real baked records and the complete first-party GPU buffer schemas rather than a representative one-lane policy. Bitmap emits five buffers and 48 bytes per renderable instance; MTSDF emits seven vec4 buffers and 112 bytes; Slug emits five float vec4 plus two unsigned vec4 buffers and 112 bytes. Derived foreground channels and inverse font size are gathered on demand without retained glyph arrays. Absent raster records are intentionally omitted, matching the portable techniques. SIMD execution retains SoA inputs, transposes four output records in registers, and writes tightly packed vec2/vec4 records with contiguous 128-bit stores; the schema-identical scalar build remains the baseline. On the unchanged 25,515-positioned-glyph stress text (21,805 renderable instances), five warmups and 11 samples measure Bitmap/MTSDF/Slug font-size medians of 5.506/6.396/7.237 ms and full-column-resize medians of 4.477/5.276/6.259 ms. This fails the sub-4 ms gate and therefore mandates dependency-directed physical-buffer execution and publication: resize recomputes geometry only, font size recomputes geometry plus Slug inverse scale, and static UV/color/band/address/count outputs remain retained. Optimized Wasm is 1,060,971 / 400,835 / 317,139 raw/gzip/Brotli bytes. | Accepted | -| D-200 | Physical render-plan writes are selected by validated policy dependencies, not a frame-wide dirty flag. Positioning stores exact six-F32/four-U32 change bits in a compact side lane without enlarging the 60-byte `PlanGlyph`; policy registration propagates those dependencies through the straight-line program once. Ordered-direct and stable-indirect compilers intersect glyph and buffer masks, while new, rebound, or conservatively described records rewrite every output. At 21,805 renderable instances, full-column resize emits 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan writes; font-size emits 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians are 4.599/4.916/6.057 ms. The exact payload reduction is accepted, but the latency evidence does not establish packing dominance or a general speedup and still fails the sub-4 ms gate; layout composition is the next measured target. Optimized Wasm is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli bytes. | Accepted | -| D-201 | Order-preserving positioned-glyph reconciliation compares equal stable-ID slots directly and builds the exact identity index only when output order or membership changes. Reordered content retains the map fallback and an exact revision-preservation test. The canonical benchmark may isolate a named case and load an explicit temporary profiling Wasm, but its default workload and shipping artifact remain unchanged. A symbolized no-`std` profile identifies positioning as the largest sampled full-column-resize function and policy gather as the next largest. Current five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes. | Accepted | -| D-202 | Positioned layout admits a conservative trivial-LTR lane only when every retained bidi level is even and no shaping run carries a direction override. That lane traverses logical clusters directly and does not fill line-level, visual-cluster, or visual-level scratch; any odd level or override retains the complete UAX #9 L1/L2 path. Exact positioning tests cover the admitted and rejected predicates, while the mixed-direction package golden remains unchanged. On the unchanged 25,515-positioned/21,805-renderable workload, two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms; two optimized runs measured 4.849 and 4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. The sub-4 ms gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes; the six-byte Brotli increase is the meaningful compressed-size comparison, while gzip changed materially from code-layout interaction. | Accepted | -| D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | +| D-200 | Physical render-plan writes are selected by validated policy dependencies, not a frame-wide dirty flag. Positioning stores exact six-F32/four-U32 change bits in a compact side lane without enlarging the 60-byte `PlanGlyph`; policy registration propagates those dependencies through the straight-line program once. Ordered-direct and stable-indirect compilers intersect glyph and buffer masks, while new, rebound, or conservatively described records rewrite every output. At 21,805 renderable instances, full-column resize emits 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug instead of 1,022.1/2,384.9/2,384.9 KiB cold-plan writes; font-size emits 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians are 4.599/4.916/6.057 ms. The exact payload reduction is accepted, but the latency evidence does not establish packing dominance or a general speedup and still fails the sub-4 ms gate; layout composition is the next measured target. Optimized Wasm is 1,065,394 / 399,111 / 317,830 raw/gzip/Brotli bytes. | Accepted | +| D-201 | Order-preserving positioned-glyph reconciliation compares equal stable-ID slots directly and builds the exact identity index only when output order or membership changes. Reordered content retains the map fallback and an exact revision-preservation test. The canonical benchmark may isolate a named case and load an explicit temporary profiling Wasm, but its default workload and shipping artifact remain unchanged. A symbolized no-`std` profile identifies positioning as the largest sampled full-column-resize function and policy gather as the next largest. Current five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 ms; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 / 399,248 / 318,131 raw/gzip/Brotli bytes. | Accepted | +| D-202 | Positioned layout admits a conservative trivial-LTR lane only when every retained bidi level is even and no shaping run carries a direction override. That lane traverses logical clusters directly and does not fill line-level, visual-cluster, or visual-level scratch; any odd level or override retains the complete UAX #9 L1/L2 path. Exact positioning tests cover the admitted and rejected predicates, while the mixed-direction package golden remains unchanged. On the unchanged 25,515-positioned/21,805-renderable workload, two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms; two optimized runs measured 4.849 and 4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. The sub-4 ms gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes; the six-byte Brotli increase is the meaningful compressed-size comparison, while gzip changed materially from code-layout interaction. | Accepted | +| D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | +| D-204 | The first production Three policy registers Bitmap, MTSDF, and Slug together. Technique, program, and resource identify storage; material, clip, depth, and order additionally identify draws, allowing material-directed draw splits without forcing duplicate physical storage. Public string technique and raster-resource identities lower to deterministic nonzero `u32` wire IDs with UTF-8 FNV-1a, and one runtime-scoped registry rejects collisions across the shared namespace before registration. First-party font bindings compile validated raster data directly into one field-major request allocation, preserve every bitmap strike, sort resources by wire ID, and remap record references accordingly. Exact real-fixture tests compare every production field against the established renderer-parity tables, and the combined policy registers in compiled Wasm. Three render-plan consumption and public third-party policy authoring remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index eecc76f4..05d5b916 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1195,6 +1195,16 @@ TypeScript and does not create a second module instance. A compiled-Wasm integra slots A/B while proving publication B leaves A byte-stable. The Three adapter is not switched by this slice; production request/policy compilation and plan lowering remain open and are not claimed by the host proof. +The next production slice compiles one Three policy containing the complete first-party Bitmap, MTSDF, and Slug +program set and compiles validated raster artifacts into the corresponding immutable field-major font bindings. The +compiler allocates one final binding request rather than materializing per-field arrays. Exact fixture comparison covers +every emitted lane, and the combined policy registers in the real Wasm module. Technique, program, and resource form +storage identity; `material_id`, clip, depth, and order additionally form draw identity, preserving renderer authority +to share storage or split physical buffers without placing a callback in Wasm. String technique and resource IDs map +deterministically to nonzero `u32` values with UTF-8 FNV-1a, while a runtime-scoped registry rejects collisions across +both domains. This is production policy/binding compilation, not the Three cutover: the public adapter still consumes +legacy paragraph batches until frame-request compilation and render-plan lowering land. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/src/internal/font-binding-wire.ts b/packages/text/src/internal/font-binding-wire.ts new file mode 100644 index 00000000..e4911b7f --- /dev/null +++ b/packages/text/src/internal/font-binding-wire.ts @@ -0,0 +1,429 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { LoadedFont } from '../loaded-font.js'; +import { bitmap, type BitmapData } from '../raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../raster/msdf.js'; +import { slug, type SlugData } from '../raster/slug-technique.js'; +import type { AnyRasterTechnique, RasterResourceId } from '../raster-technique.js'; +import { RenderWireIdentityRegistry, type FirstPartyTechniqueWireIds } from './render-policy-wire.js'; + +const MAX_U32 = 0xffff_ffff; +const ABSENT_PAGE = 0xffff; +const MISSING_RESOURCE = 0xffff_ffff; + +interface BindingResource { + readonly key: RasterResourceId; + readonly id: number; + readonly generation: number; + readonly kind: number; + readonly reference: number; +} + +interface FieldTable { + readonly rows: number; + readonly fields: readonly ((row: number) => number)[]; +} + +interface FontBindingDescriptor { + readonly techniqueId: number; + readonly programVariant: number; + readonly glyphCount: number; + readonly strikes: readonly number[]; + readonly resources: readonly BindingResource[]; + readonly resourceIndex: (row: number) => number; + readonly glyphF32: FieldTable; + readonly glyphU32: FieldTable; + readonly strikeF32: FieldTable; + readonly strikeU32: FieldTable; + readonly resourceF32: FieldTable; + readonly resourceU32: FieldTable; +} + +/** Compile one first-party loaded font into the Rust engine's field-major immutable binding. */ +export function firstPartyFontBindingBytes( + font: LoadedFont, + identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), +): Uint8Array { + const techniqueIds: FirstPartyTechniqueWireIds = { + bitmap: identities.resolve(bitmap.id), + msdf: identities.resolve(msdf.id), + slug: identities.resolve(slug.id), + }; + if (font.technique.id === bitmap.id && isBitmapData(font.data)) { + return compileBitmap(font.font.glyphCount, font.data, techniqueIds.bitmap, identities); + } + if (font.technique.id === msdf.id && isMsdfData(font.data)) { + return compileMsdf(font.font.glyphCount, font.data, techniqueIds.msdf, identities); + } + if (font.technique.id === slug.id && isSlugData(font.data)) { + return compileSlug(font.font.glyphCount, font.data, techniqueIds.slug, identities); + } + throw new TypeError(`no first-party font-binding compiler is registered for "${font.technique.id}"`); +} + +function isBitmapData(value: unknown): value is BitmapData { + return ( + isRecord(value) && + Array.isArray(value.strikes) && + value.strikes.length !== 0 && + value.strikes.every( + (strike) => + isRecord(strike) && + Number.isSafeInteger(strike.ppem) && + Number.isSafeInteger(strike.planeUnitsPerEm) && + strike.records instanceof Uint8Array && + Array.isArray(strike.pages), + ) + ); +} + +function isMsdfData(value: unknown): value is MsdfData { + return ( + isRecord(value) && + typeof value.resource === 'string' && + Number.isSafeInteger(value.planeUnitsPerEm) && + value.records instanceof Uint8Array && + Array.isArray(value.pages) + ); +} + +function isSlugData(value: unknown): value is SlugData { + return ( + isRecord(value) && + Number.isSafeInteger(value.planeUnitsPerEm) && + value.records instanceof Uint8Array && + Array.isArray(value.pages) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function compileBitmap( + glyphCount: number, + data: BitmapData, + techniqueId: number, + identities: RenderWireIdentityRegistry, +): Uint8Array { + const entries = data.strikes.flatMap((strike) => strike.pages.map((page) => page.resource)); + const { resources, indexFor } = bindingResources(entries, identities); + const views = data.strikes.map((strike) => recordView(strike.records)); + const rows = checkedProduct(glyphCount, data.strikes.length, 'bitmap strike rows'); + const strikeRecord = (row: number): { readonly view: DataView; readonly record: number; readonly strike: number } => { + const strike = Math.floor(row / glyphCount); + return { view: views[strike]!, record: (row % glyphCount) * 20, strike }; + }; + const atlas = (row: number, offset: number, dimension: 'width' | 'height'): number => { + const { view, record, strike } = strikeRecord(row); + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE + ? 0 + : view.getUint16(record + offset, true) / data.strikes[strike]!.pages[page]![dimension]; + }; + const span = (row: number, start: number, end: number, dimension: 'width' | 'height'): number => { + const { view, record, strike } = strikeRecord(row); + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE + ? 0 + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / + data.strikes[strike]!.pages[page]![dimension]; + }; + return compileBinding({ + techniqueId, + programVariant: 0, + glyphCount, + strikes: data.strikes.map((strike) => strike.ppem), + resources, + resourceIndex(row) { + const { view, record, strike } = strikeRecord(row); + const page = view.getUint16(record + 16, true); + return page === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.strikes[strike]!.pages[page]!.resource); + }, + glyphF32: emptyTable(glyphCount), + glyphU32: emptyTable(glyphCount), + strikeF32: { + rows, + fields: [ + (row) => { + const { view, record, strike } = strikeRecord(row); + return view.getInt16(record, true) / data.strikes[strike]!.planeUnitsPerEm; + }, + (row) => { + const { view, record, strike } = strikeRecord(row); + return view.getInt16(record + 6, true) / data.strikes[strike]!.planeUnitsPerEm; + }, + (row) => { + const { view, record, strike } = strikeRecord(row); + return ( + (view.getInt16(record + 4, true) - view.getInt16(record, true)) / data.strikes[strike]!.planeUnitsPerEm + ); + }, + (row) => { + const { view, record, strike } = strikeRecord(row); + return ( + (view.getInt16(record + 6, true) - view.getInt16(record + 2, true)) / data.strikes[strike]!.planeUnitsPerEm + ); + }, + (row) => atlas(row, 8, 'width'), + (row) => atlas(row, 10, 'height'), + (row) => span(row, 8, 12, 'width'), + (row) => span(row, 10, 14, 'height'), + ], + }, + strikeU32: emptyTable(rows), + resourceF32: emptyTable(resources.length), + resourceU32: emptyTable(resources.length), + }); +} + +function compileMsdf( + glyphCount: number, + data: MsdfData, + techniqueId: number, + identities: RenderWireIdentityRegistry, +): Uint8Array { + const { resources, indexFor } = bindingResources([data.resource], identities); + const view = recordView(data.records); + const rowRecord = (row: number): number => row * 20; + const pageAt = (row: number): number => view.getUint16(rowRecord(row) + 16, true); + const atlas = (row: number, offset: number, dimension: 'width' | 'height'): number => { + const page = pageAt(row); + return page === ABSENT_PAGE ? 0 : view.getUint16(rowRecord(row) + offset, true) / data.pages[page]![dimension]; + }; + const span = (row: number, start: number, end: number, dimension: 'width' | 'height'): number => { + const page = pageAt(row); + const record = rowRecord(row); + return page === ABSENT_PAGE + ? 0 + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / data.pages[page]![dimension]; + }; + return compileBinding({ + techniqueId, + programVariant: 0, + glyphCount, + strikes: [0], + resources, + resourceIndex(row) { + return pageAt(row) === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.resource); + }, + glyphF32: { + rows: glyphCount, + fields: [ + (row) => view.getInt16(rowRecord(row), true) / data.planeUnitsPerEm, + (row) => view.getInt16(rowRecord(row) + 6, true) / data.planeUnitsPerEm, + (row) => (view.getInt16(rowRecord(row) + 4, true) - view.getInt16(rowRecord(row), true)) / data.planeUnitsPerEm, + (row) => + (view.getInt16(rowRecord(row) + 6, true) - view.getInt16(rowRecord(row) + 2, true)) / data.planeUnitsPerEm, + (row) => atlas(row, 8, 'width'), + (row) => atlas(row, 10, 'height'), + (row) => span(row, 8, 12, 'width'), + (row) => span(row, 10, 14, 'height'), + (row) => atlas(row, 12, 'width'), + (row) => atlas(row, 14, 'height'), + ], + }, + glyphU32: { rows: glyphCount, fields: [(row) => pageAt(row)] }, + strikeF32: emptyTable(glyphCount), + strikeU32: emptyTable(glyphCount), + resourceF32: emptyTable(resources.length), + resourceU32: emptyTable(resources.length), + }); +} + +function compileSlug( + glyphCount: number, + data: SlugData, + techniqueId: number, + identities: RenderWireIdentityRegistry, +): Uint8Array { + const { resources, indexFor } = bindingResources( + data.pages.map((page) => page.resource), + identities, + ); + const view = recordView(data.records); + const record = (row: number): number => row * 40; + const pageAt = (row: number): number => view.getUint16(record(row) + 8, true); + const normalized = (row: number, offset: number): number => + view.getInt16(record(row) + offset, true) / data.planeUnitsPerEm; + const width = (row: number): number => normalized(row, 4) - normalized(row, 0); + const height = (row: number): number => normalized(row, 6) - normalized(row, 2); + const horizontalBands = (row: number): number => view.getUint16(record(row) + 10, true); + const verticalBands = (row: number): number => view.getUint16(record(row) + 12, true); + const bandScaleX = (row: number): number => (width(row) === 0 ? 0 : verticalBands(row) / width(row)); + const bandScaleY = (row: number): number => (height(row) === 0 ? 0 : horizontalBands(row) / height(row)); + return compileBinding({ + techniqueId, + programVariant: 0, + glyphCount, + strikes: [0], + resources, + resourceIndex(row) { + const page = pageAt(row); + return page === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.pages[page]!.resource); + }, + glyphF32: { + rows: glyphCount, + fields: [ + (row) => normalized(row, 0), + (row) => normalized(row, 6), + (row) => width(row), + (row) => height(row), + (row) => bandScaleX(row), + (row) => bandScaleY(row), + (row) => -normalized(row, 0) * bandScaleX(row), + (row) => -normalized(row, 2) * bandScaleY(row), + ], + }, + glyphU32: { + rows: glyphCount, + fields: [ + (row) => view.getUint32(record(row) + 16, true), + (row) => view.getUint32(record(row) + 24, true), + (row) => view.getUint32(record(row) + 28, true), + (row) => view.getUint32(record(row) + 32, true), + (row) => horizontalBands(row), + (row) => verticalBands(row), + ], + }, + strikeF32: emptyTable(glyphCount), + strikeU32: emptyTable(glyphCount), + resourceF32: emptyTable(resources.length), + resourceU32: emptyTable(resources.length), + }); +} + +function bindingResources( + keys: readonly RasterResourceId[], + identities: RenderWireIdentityRegistry, +): { + readonly resources: readonly BindingResource[]; + readonly indexFor: (key: RasterResourceId) => number; +} { + const byKey = new Map(); + const byId = new Map(); + for (const key of keys) { + if (byKey.has(key)) continue; + const id = identities.resolve(key); + const collision = byId.get(id); + if (collision !== undefined && collision !== key) { + throw new TypeError(`raster resource wire identity collision between "${collision}" and "${key}"`); + } + byId.set(id, key); + byKey.set(key, { key, id, generation: 1, kind: 1, reference: id }); + } + const resources = [...byKey.values()].sort((left, right) => left.id - right.id); + const indexes = new Map(resources.map((resource, index) => [resource.key, index])); + return { + resources, + indexFor(key) { + const index = indexes.get(key); + if (index === undefined) throw new TypeError(`font binding references unknown raster resource "${key}"`); + return index; + }, + }; +} + +function compileBinding(descriptor: FontBindingDescriptor): Uint8Array { + const request = textShaperAbi.layouts.fontBindingRequest; + const strike = textShaperAbi.layouts.fontBindingStrike; + const resource = textShaperAbi.layouts.fontBindingResource; + const tables = [ + ['glyphF32', descriptor.glyphF32], + ['glyphU32', descriptor.glyphU32], + ['strikeF32', descriptor.strikeF32], + ['strikeU32', descriptor.strikeU32], + ['resourceF32', descriptor.resourceF32], + ['resourceU32', descriptor.resourceU32], + ] as const; + let length: number = request.size; + const allocate = (count: number, stride: number, alignment: number): number => { + if (count === 0) return 0; + const offset = align(length, alignment); + length = checkedAdd(offset, checkedProduct(count, stride, 'font binding table'), 'font binding bytes'); + return offset; + }; + const strikesOffset = allocate(descriptor.strikes.length, strike.size, strike.alignment); + const resourcesOffset = allocate(descriptor.resources.length, resource.size, resource.alignment); + const resourceIndicesOffset = allocate( + checkedProduct(descriptor.glyphCount, descriptor.strikes.length, 'font resource rows'), + 4, + 4, + ); + const tableOffsets = tables.map(([, table]) => + allocate(checkedProduct(table.rows, table.fields.length, 'font binding fields'), 4, 4), + ); + const bytes = new Uint8Array(length); + const view = new DataView(bytes.buffer); + view.setUint32(request.abiVersion, textShaperAbi.version, true); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.techniqueId, descriptor.techniqueId, true); + view.setUint16(request.programVariant, descriptor.programVariant, true); + view.setUint32(request.glyphCount, descriptor.glyphCount, true); + view.setUint32(request.strikeCount, descriptor.strikes.length, true); + view.setUint32(request.resourceCount, descriptor.resources.length, true); + view.setUint32(request.strikesOffset, strikesOffset, true); + view.setUint32(request.resourcesOffset, resourcesOffset, true); + view.setUint32(request.resourceIndicesOffset, resourceIndicesOffset, true); + + for (const [index, ppem] of descriptor.strikes.entries()) { + view.setUint32(strikesOffset + index * strike.size + strike.ppem, ppem, true); + } + for (const [index, value] of descriptor.resources.entries()) { + const offset = resourcesOffset + index * resource.size; + view.setUint32(offset + resource.id, value.id, true); + view.setUint32(offset + resource.generation, value.generation, true); + view.setUint16(offset + resource.kind, value.kind, true); + view.setUint32(offset + resource.reference, value.reference, true); + } + const resourceRows = descriptor.glyphCount * descriptor.strikes.length; + for (let row = 0; row < resourceRows; row += 1) { + view.setUint32(resourceIndicesOffset + row * 4, descriptor.resourceIndex(row), true); + } + for (const [tableIndex, [name, table]] of tables.entries()) { + if (table.fields.length > 32) throw new RangeError(`${name} has more than 32 fields`); + view.setUint8(request[`${name}FieldCount`], table.fields.length); + view.setUint32(request[`${name}Offset`], tableOffsets[tableIndex]!, true); + for (const [fieldIndex, read] of table.fields.entries()) { + const fieldOffset = tableOffsets[tableIndex]! + fieldIndex * table.rows * 4; + for (let row = 0; row < table.rows; row += 1) { + const value = read(row); + if (name.endsWith('F32')) { + if (!Number.isFinite(value)) throw new TypeError(`${name} produced a nonfinite value`); + view.setFloat32(fieldOffset + row * 4, value, true); + } else { + view.setUint32(fieldOffset + row * 4, uint32(value, `${name} value`), true); + } + } + } + } + return bytes; +} + +function emptyTable(rows: number): FieldTable { + return { rows, fields: [] }; +} + +function recordView(bytes: Uint8Array): DataView { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); +} + +function align(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function uint32(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_U32) throw new RangeError(`${label} must be a u32`); + return value; +} diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts new file mode 100644 index 00000000..687b2cfa --- /dev/null +++ b/packages/text/src/internal/render-policy-wire.ts @@ -0,0 +1,509 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; + +const MAX_U32 = 0xffff_ffff; +const encoder = new TextEncoder(); + +type PolicyInputScope = keyof typeof textShaperAbi.policy.inputScopes; + +interface PolicyInput { + readonly scope: PolicyInputScope; + readonly field: number; +} + +interface PolicyBuffer { + readonly id: number; + readonly scalar: number; + readonly vectorWidth: number; + readonly alignment?: number; + readonly stride?: number; + readonly usage?: number; + readonly capacityClass?: number; +} + +interface PolicyOperation { + readonly opcode: number; + readonly target?: number; + readonly operand0?: number; + readonly operand1?: number; + readonly immediate0?: number; + readonly immediate1?: number; + readonly immediate2?: number; +} + +interface PolicyProgram { + readonly techniqueId: number; + readonly programId: number; + readonly capabilitySetId?: number; + readonly resourceKindMask?: number; + readonly semanticViewMask?: number; + readonly storageKeyMask?: number; + readonly drawKeyMask?: number; + readonly variant?: number; + readonly f32InputCount: number; + readonly u32InputCount: number; + readonly paintCapabilities?: number; + readonly compositingCapabilities?: number; + readonly allocationStrategy?: number; + readonly inputs: readonly PolicyInput[]; + readonly buffers: readonly PolicyBuffer[]; + readonly operations: readonly PolicyOperation[]; +} + +interface PolicyCapabilitySet { + readonly id: number; + readonly flags: number; + readonly maxBufferBytes: number; + readonly updateAlignment: number; + readonly coalesceGapBytes: number; + readonly rangeCallPenaltyBytes: number; + readonly maxBuffersPerDraw: number; + readonly maxResourcesPerDraw: number; + readonly maxIndirectDraws: number; + readonly fragmentationBudget: number; + readonly wholeBufferThresholdBasisPoints: number; +} + +interface PolicyDescriptor { + readonly capabilitySets: readonly PolicyCapabilitySet[]; + readonly programs: readonly PolicyProgram[]; +} + +/** Deterministic UTF-8 FNV-1a mapping used by both policy and font-binding compilers. */ +export function renderWireId(id: string): number { + if (typeof id !== 'string' || id.length === 0) throw new TypeError('render identity must be a nonempty string'); + let hash = 0x811c_9dc5; + for (const byte of encoder.encode(id)) hash = Math.imul(hash ^ byte, 0x0100_0193) >>> 0; + if (hash === 0) throw new RangeError('raster technique ID hashes to the reserved zero wire identity'); + return hash; +} + +/** Runtime-scoped collision proof for every string identity lowered into the shared u32 wire namespace. */ +export class RenderWireIdentityRegistry { + readonly #strings = new Map(); + + resolve(id: string): number { + const wireId = renderWireId(id); + const collision = this.#strings.get(wireId); + if (collision !== undefined && collision !== id) { + throw new TypeError(`render wire identity collision between "${collision}" and "${id}"`); + } + this.#strings.set(wireId, id); + return wireId; + } +} + +export interface FirstPartyTechniqueWireIds { + readonly bitmap: number; + readonly msdf: number; + readonly slug: number; +} + +export const firstPartyTechniqueWireIds: FirstPartyTechniqueWireIds = Object.freeze({ + bitmap: renderWireId('pmndrs.bitmap'), + msdf: renderWireId('pmndrs.msdf'), + slug: renderWireId('pmndrs.slug'), +}); + +/** Compiler-mapped Three policy covering every first-party raster technique in one registration. */ +export function firstPartyThreeRenderPolicyBytes( + identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), +): Uint8Array { + const bitmap = identities.resolve('pmndrs.bitmap'); + const msdf = identities.resolve('pmndrs.msdf'); + const slug = identities.resolve('pmndrs.slug'); + const programs = [bitmapProgram(bitmap, 1), msdfProgram(msdf, 2), slugProgram(slug, 3)]; + if (new Set(programs.map((program) => program.techniqueId)).size !== programs.length) { + throw new TypeError('first-party raster technique wire identities collide'); + } + return compilePolicy({ capabilitySets: [threeCapabilitySet()], programs }); +} + +function threeCapabilitySet(): PolicyCapabilitySet { + const flags = textShaperAbi.policy.capabilityFlags; + return { + id: 1, + flags: flags.storageBuffers | flags.aliasVec2 | flags.aliasVec4 | flags.orderedDirect | flags.stableIndirect, + maxBufferBytes: 64 * 1024 * 1024, + updateAlignment: 4, + coalesceGapBytes: 128, + rangeCallPenaltyBytes: 256, + maxBuffersPerDraw: 16, + maxResourcesPerDraw: 16, + maxIndirectDraws: 0, + fragmentationBudget: 8, + wholeBufferThresholdBasisPoints: 7_500, + }; +} + +function bitmapProgram(techniqueId: number, programId: number): PolicyProgram { + const context = programContext('strike', 8, 0); + const { loadF32, binary, storeF32 } = context; + loadF32(15); + binary('multiplyF32', 15, 7, 2); + binary('addF32', 16, 0, 15); + binary('multiplyF32', 17, 8, 2); + binary('subtractF32', 18, 1, 17); + binary('multiplyF32', 19, 9, 2); + binary('multiplyF32', 20, 10, 2); + stores(storeF32, [ + [1, [16, 18]], + [2, [19, 20]], + [3, [11, 12]], + [4, [13, 14]], + [5, [3, 4, 5, 6]], + ]); + return program(techniqueId, programId, context, floatBuffers([2, 2, 2, 2, 4])); +} + +function msdfProgram(techniqueId: number, programId: number): PolicyProgram { + const context = programContext('glyph', 10, 1); + const { operations, loadF32, loadU32, binary, constantF32, storeF32 } = context; + loadF32(17); + loadU32(17, 0); + binary('multiplyF32', 18, 7, 2); + binary('addF32', 19, 0, 18); + binary('multiplyF32', 20, 8, 2); + binary('subtractF32', 21, 1, 20); + binary('multiplyF32', 22, 9, 2); + binary('multiplyF32', 23, 10, 2); + operations.push({ opcode: textShaperAbi.policy.opcodes.convertU32ToF32, target: 24, operand0: 17 }); + constantF32(25, 0); + stores(storeF32, [ + [1, [19, 21, 22, 23]], + [2, [11, 12, 13, 14]], + [3, [11, 12, 15, 16]], + [4, [3, 4, 5, 6]], + [5, [25, 25, 25, 25]], + [6, [25, 25, 25, 25]], + [7, [25, 25, 25, 24]], + ]); + return program(techniqueId, programId, context, floatBuffers([4, 4, 4, 4, 4, 4, 4])); +} + +function slugProgram(techniqueId: number, programId: number): PolicyProgram { + const context = programContext('glyph', 8, 6, true); + const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; + loadF32(16); + for (let field = 0; field < 6; field += 1) loadU32(21 + field, field); + binary('multiplyF32', 16, 8, 2); + binary('addF32', 17, 0, 16); + binary('multiplyF32', 18, 9, 2); + binary('subtractF32', 19, 1, 18); + binary('multiplyF32', 20, 10, 2); + binary('multiplyF32', 27, 11, 2); + constantF32(28, 0); + constantU32(29, 0); + stores(storeF32, [ + [1, [17, 19, 20, 27]], + [2, [8, 9, 10, 11]], + [3, [12, 13, 14, 15]], + [4, [3, 4, 5, 6]], + [5, [7, 28, 28, 28]], + ]); + stores(storeU32, [ + [6, [21, 22, 23, 24]], + [7, [25, 26, 29, 29]], + ]); + return program(techniqueId, programId, context, [...floatBuffers([4, 4, 4, 4, 4]), ...u32Buffers([4, 4], 6)]); +} + +interface ProgramContext { + readonly inputs: PolicyInput[]; + readonly operations: PolicyOperation[]; + readonly f32InputCount: number; + readonly u32InputCount: number; + readonly loadF32: (count: number) => void; + readonly loadU32: (target: number, field: number) => void; + readonly binary: ( + name: 'addF32' | 'subtractF32' | 'multiplyF32', + target: number, + left: number, + right: number, + ) => void; + readonly constantF32: (target: number, value: number) => void; + readonly constantU32: (target: number, value: number) => void; + readonly storeF32: (buffer: number, lane: number, register: number) => void; + readonly storeU32: (buffer: number, lane: number, register: number) => void; +} + +function programContext( + bindingScope: PolicyInputScope, + bindingF32Count: number, + bindingU32Count: number, + inverseFontSize = false, +): ProgramContext { + const operations: PolicyOperation[] = []; + const semantic = textShaperAbi.engine.semanticF32Fields; + const inputs: PolicyInput[] = [ + { scope: 'semantic', field: semantic.inlineStart }, + { scope: 'semantic', field: semantic.blockStart }, + { scope: 'semantic', field: semantic.fontSize }, + { scope: 'semantic', field: semantic.foregroundRed }, + { scope: 'semantic', field: semantic.foregroundGreen }, + { scope: 'semantic', field: semantic.foregroundBlue }, + { scope: 'semantic', field: semantic.foregroundAlpha }, + ...(inverseFontSize ? [{ scope: 'semantic' as const, field: semantic.inverseFontSize }] : []), + ...Array.from({ length: bindingF32Count }, (_, field) => ({ scope: bindingScope, field })), + ...Array.from({ length: bindingU32Count }, (_, field) => ({ scope: bindingScope, field })), + ]; + return { + inputs, + operations, + f32InputCount: 7 + (inverseFontSize ? 1 : 0) + bindingF32Count, + u32InputCount: bindingU32Count, + loadF32(count) { + for (let field = 0; field < count; field += 1) { + operations.push({ opcode: textShaperAbi.policy.opcodes.loadF32, target: field, operand0: field }); + } + }, + loadU32(target, field) { + operations.push({ opcode: textShaperAbi.policy.opcodes.loadU32, target, operand0: field }); + }, + binary(name, target, left, right) { + operations.push({ opcode: textShaperAbi.policy.opcodes[name], target, operand0: left, operand1: right }); + }, + constantF32(target, value) { + operations.push({ opcode: textShaperAbi.policy.opcodes.constantF32, target, immediate0: f32Bits(value) }); + }, + constantU32(target, value) { + operations.push({ opcode: textShaperAbi.policy.opcodes.constantU32, target, immediate0: value }); + }, + storeF32(buffer, lane, register) { + operations.push({ + opcode: textShaperAbi.policy.opcodes.storeF32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + storeU32(buffer, lane, register) { + operations.push({ + opcode: textShaperAbi.policy.opcodes.storeU32, + operand0: register, + operand1: lane, + immediate0: buffer, + }); + }, + }; +} + +function program( + techniqueId: number, + programId: number, + context: ProgramContext, + buffers: readonly PolicyBuffer[], +): PolicyProgram { + const batch = textShaperAbi.policy.batchFields; + return { + techniqueId, + programId, + f32InputCount: context.f32InputCount, + u32InputCount: context.u32InputCount, + inputs: context.inputs, + buffers, + operations: context.operations, + storageKeyMask: batch.technique | batch.program | batch.resource, + drawKeyMask: + batch.technique | batch.program | batch.resource | batch.material | batch.clip | batch.depth | batch.order, + }; +} + +function stores( + write: (buffer: number, lane: number, register: number) => void, + groups: readonly (readonly [number, readonly number[]])[], +): void { + for (const [buffer, registers] of groups) { + for (const [lane, register] of registers.entries()) write(buffer, lane, register); + } +} + +function floatBuffers(widths: readonly number[]): PolicyBuffer[] { + return widths.map((vectorWidth, index) => ({ + id: index + 1, + scalar: textShaperAbi.policy.scalarTypes.f32, + vectorWidth, + })); +} + +function u32Buffers(widths: readonly number[], firstId: number): PolicyBuffer[] { + return widths.map((vectorWidth, index) => ({ + id: firstId + index, + scalar: textShaperAbi.policy.scalarTypes.u32, + vectorWidth, + })); +} + +function compilePolicy(descriptor: PolicyDescriptor): Uint8Array { + const request = textShaperAbi.layouts.policyRequest; + const capability = textShaperAbi.layouts.policyCapabilitySet; + const programLayout = textShaperAbi.layouts.policyProgram; + const bufferLayout = textShaperAbi.layouts.policyBuffer; + const operationLayout = textShaperAbi.layouts.policyOperation; + const inputLayout = textShaperAbi.layouts.policyInput; + const programs = descriptor.programs; + const bufferCount = sum(programs, (program) => program.buffers.length); + const operationCount = sum(programs, (program) => program.operations.length); + const inputCount = sum(programs, (program) => program.inputs.length); + const capabilitiesOffset = align(request.size, capability.alignment); + const programsOffset = align( + checkedAdd( + capabilitiesOffset, + checkedProduct(capability.size, descriptor.capabilitySets.length, 'policy capabilities'), + 'policy programs', + ), + programLayout.alignment, + ); + const buffersOffset = align( + checkedAdd( + programsOffset, + checkedProduct(programLayout.size, programs.length, 'policy programs'), + 'policy buffers', + ), + bufferLayout.alignment, + ); + const operationsOffset = align( + checkedAdd(buffersOffset, checkedProduct(bufferLayout.size, bufferCount, 'policy buffers'), 'policy operations'), + operationLayout.alignment, + ); + const inputsOffset = align( + checkedAdd( + operationsOffset, + checkedProduct(operationLayout.size, operationCount, 'policy operations'), + 'policy inputs', + ), + inputLayout.alignment, + ); + const byteLength = checkedAdd( + inputsOffset, + checkedProduct(inputLayout.size, inputCount, 'policy inputs'), + 'policy bytes', + ); + const bytes = new Uint8Array(byteLength); + const view = new DataView(bytes.buffer); + view.setUint32(request.byteLength, bytes.byteLength, true); + view.setUint32(request.capabilitySetsOffset, capabilitiesOffset, true); + view.setUint32(request.capabilitySetCount, descriptor.capabilitySets.length, true); + view.setUint32(request.programsOffset, programsOffset, true); + view.setUint32(request.programCount, programs.length, true); + view.setUint32(request.buffersOffset, buffersOffset, true); + view.setUint32(request.bufferCount, bufferCount, true); + view.setUint32(request.operationsOffset, operationsOffset, true); + view.setUint32(request.operationCount, operationCount, true); + view.setUint32(request.inputsOffset, inputsOffset, true); + view.setUint32(request.inputCount, inputCount, true); + + for (const [index, value] of descriptor.capabilitySets.entries()) { + const offset = capabilitiesOffset + index * capability.size; + view.setUint32(offset + capability.id, value.id, true); + view.setUint32(offset + capability.flags, value.flags, true); + view.setUint32(offset + capability.maxBufferBytes, value.maxBufferBytes, true); + view.setUint32(offset + capability.updateAlignment, value.updateAlignment, true); + view.setUint32(offset + capability.coalesceGapBytes, value.coalesceGapBytes, true); + view.setUint32(offset + capability.rangeCallPenaltyBytes, value.rangeCallPenaltyBytes, true); + view.setUint16(offset + capability.maxBuffersPerDraw, value.maxBuffersPerDraw, true); + view.setUint16(offset + capability.maxResourcesPerDraw, value.maxResourcesPerDraw, true); + view.setUint16(offset + capability.maxIndirectDraws, value.maxIndirectDraws, true); + view.setUint16(offset + capability.fragmentationBudget, value.fragmentationBudget, true); + view.setUint16(offset + capability.wholeBufferThresholdBasisPoints, value.wholeBufferThresholdBasisPoints, true); + } + + let bufferStart = 0; + let operationStart = 0; + let inputStart = 0; + for (const [index, value] of programs.entries()) { + const offset = programsOffset + index * programLayout.size; + view.setUint32(offset + programLayout.techniqueId, value.techniqueId, true); + view.setUint32(offset + programLayout.programId, value.programId, true); + view.setUint32(offset + programLayout.capabilitySetId, value.capabilitySetId ?? 0, true); + view.setUint32(offset + programLayout.resourceKindMask, value.resourceKindMask ?? 1, true); + view.setUint32(offset + programLayout.semanticViewMask, value.semanticViewMask ?? 0, true); + view.setUint32(offset + programLayout.storageKeyMask, value.storageKeyMask ?? 0, true); + view.setUint32(offset + programLayout.drawKeyMask, value.drawKeyMask ?? 0, true); + view.setUint32(offset + programLayout.paintCapabilities, value.paintCapabilities ?? 0, true); + view.setUint32(offset + programLayout.compositingCapabilities, value.compositingCapabilities ?? 0, true); + view.setUint32(offset + programLayout.bufferStart, bufferStart, true); + view.setUint32(offset + programLayout.operationStart, operationStart, true); + view.setUint16(offset + programLayout.variant, value.variant ?? 0, true); + view.setUint16(offset + programLayout.bufferCount, value.buffers.length, true); + view.setUint16(offset + programLayout.operationCount, value.operations.length, true); + view.setUint16( + offset + programLayout.allocationStrategy, + value.allocationStrategy ?? textShaperAbi.policy.allocationStrategies.orderedDirect, + true, + ); + view.setUint8(offset + programLayout.f32InputCount, value.f32InputCount); + view.setUint8(offset + programLayout.u32InputCount, value.u32InputCount); + view.setUint32(offset + programLayout.inputStart, inputStart, true); + view.setUint16(offset + programLayout.inputCount, value.inputs.length, true); + bufferStart += value.buffers.length; + operationStart += value.operations.length; + inputStart += value.inputs.length; + } + + let bufferIndex = 0; + let operationIndex = 0; + let inputIndex = 0; + for (const value of programs) { + for (const buffer of value.buffers) { + const offset = buffersOffset + bufferIndex * bufferLayout.size; + const scalarBytes = buffer.scalar === textShaperAbi.policy.scalarTypes.u16 ? 2 : 4; + view.setUint16(offset + bufferLayout.id, buffer.id, true); + view.setUint8(offset + bufferLayout.scalar, buffer.scalar); + view.setUint8(offset + bufferLayout.vectorWidth, buffer.vectorWidth); + view.setUint16(offset + bufferLayout.alignment, buffer.alignment ?? scalarBytes, true); + view.setUint16(offset + bufferLayout.stride, buffer.stride ?? scalarBytes * buffer.vectorWidth, true); + view.setUint32( + offset + bufferLayout.usage, + buffer.usage ?? textShaperAbi.policy.bufferUsage.storage | textShaperAbi.policy.bufferUsage.copyDst, + true, + ); + view.setUint16(offset + bufferLayout.capacityClass, buffer.capacityClass ?? 1, true); + bufferIndex += 1; + } + for (const operation of value.operations) { + const offset = operationsOffset + operationIndex * operationLayout.size; + view.setUint8(offset + operationLayout.opcode, operation.opcode); + view.setUint8(offset + operationLayout.target, operation.target ?? 0); + view.setUint8(offset + operationLayout.operand0, operation.operand0 ?? 0); + view.setUint8(offset + operationLayout.operand1, operation.operand1 ?? 0); + view.setUint32(offset + operationLayout.immediate0, operation.immediate0 ?? 0, true); + view.setUint32(offset + operationLayout.immediate1, operation.immediate1 ?? 0, true); + view.setUint32(offset + operationLayout.immediate2, operation.immediate2 ?? 0, true); + operationIndex += 1; + } + for (const input of value.inputs) { + const offset = inputsOffset + inputIndex * inputLayout.size; + view.setUint8(offset + inputLayout.scope, textShaperAbi.policy.inputScopes[input.scope]); + view.setUint8(offset + inputLayout.field, input.field); + inputIndex += 1; + } + } + return bytes; +} + +function sum(values: readonly T[], measure: (value: T) => number): number { + return values.reduce((total, value) => checkedAdd(total, measure(value), 'policy record count'), 0); +} + +function align(value: number, alignment: number): number { + return Math.ceil(value / alignment) * alignment; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds u32`); + return value; +} + +function f32Bits(value: number): number { + const bytes = new ArrayBuffer(4); + const view = new DataView(bytes); + view.setFloat32(0, value, true); + return view.getUint32(0, true); +} diff --git a/packages/text/src/internal/text-engine-host.ts b/packages/text/src/internal/text-engine-host.ts index 89f0543c..25bc694d 100644 --- a/packages/text/src/internal/text-engine-host.ts +++ b/packages/text/src/internal/text-engine-host.ts @@ -1,5 +1,6 @@ import { textShaperAbi } from '../generated/text-shaper-abi.js'; import { runtimeShaperEngineExports, type RuntimeShaper } from '../shaper.js'; +import { RenderWireIdentityRegistry } from './render-policy-wire.js'; const MAX_U32 = 0xffff_ffff; @@ -52,6 +53,7 @@ export class TextEngineStatusError extends Error { /** Internal lifecycle owner for retained policy, font-stack, and session state in a RuntimeShaper's Wasm instance. */ export class TextEngineHost { + readonly wireIdentities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(); readonly #exports; readonly #sessions = new Set(); readonly #policies = new Set(); diff --git a/packages/text/tests/integration/font-binding-wire.test.mjs b/packages/text/tests/integration/font-binding-wire.test.mjs new file mode 100644 index 00000000..a2727b3e --- /dev/null +++ b/packages/text/tests/integration/font-binding-wire.test.mjs @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { gunzipSync } from 'node:zlib'; + +import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; + +import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; +import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; +import { validateSlugArtifact } from '../../dist/bakers/slug-validator.js'; +import { firstPartyFontBindingBytes } from '../../dist/internal/font-binding-wire.js'; +import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; +import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; +import { slug, slugDescriptor } from '../../dist/raster/slug-technique.js'; +import { defineRasterResourceId } from '../../dist/raster-technique.js'; +import { techniqueProof } from '../../scripts/support/render-technique-proof.mjs'; + +const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('production first-party bindings preserve every proven field-major raster lane', async () => { + const abi = JSON.parse(await readFile(abiUrl, 'utf8')); + for (const name of ['bitmap', 'mtsdf', 'slug']) { + const { core, raster, loaded } = await fixture(name); + const actual = firstPartyFontBindingBytes(loaded); + const expected = techniqueProof(abi, name, raster).bindingBytes; + const strikeRows = core.glyphCount * (name === 'bitmap' ? raster.strikes.length : 1); + for (const [table, rows] of [ + ['glyphF32', core.glyphCount], + ['glyphU32', core.glyphCount], + ['strikeF32', strikeRows], + ['strikeU32', strikeRows], + ['resourceF32', resourceCount(actual, abi)], + ['resourceU32', resourceCount(actual, abi)], + ]) { + assert.deepEqual( + tableBytes(actual, abi, table, rows), + tableBytes(expected, abi, table, rows), + `${name} ${table}`, + ); + } + } +}); + +async function fixture(name) { + const files = { + bitmap: ['inter-bitmap-16.font.glb', false], + mtsdf: ['inter-mtsdf.font.glb.gz', true], + slug: ['inter-slug.font.glb.gz', true], + }; + const [file, compressed] = files[name]; + const stored = await readFile(new URL(file, fixtureRoot)); + const bytes = compressed ? gunzipSync(stored) : stored; + const core = await validateFontArtifact(bytes); + const identity = core.document.extensions.PMNDRS_font.rasters[0]; + const context = { + rasterKey: identity.rasterKey, + shapingHash: core.shapingHash, + glyphCount: core.glyphCount, + glyphIdWidth: 16, + }; + if (name === 'bitmap') { + const raster = await validateBitmapArtifact(bytes, { ...context, descriptor: bitmapDescriptor({ strikes: [16] }) }); + const data = { + strikes: raster.strikes.map((strike, strikeIndex) => ({ + ...strike, + pages: strike.pages.map((page, pageIndex) => ({ + ...page, + format: 'r8unorm', + resource: defineRasterResourceId(`test.bitmap.${strikeIndex}.${pageIndex}`), + })), + bindings: [], + })), + }; + return { core, raster, loaded: { font: core, technique: bitmap, data } }; + } + if (name === 'mtsdf') { + const raster = await validateMsdfArtifact(bytes, { ...context, descriptor: msdfDescriptor() }); + const extension = raster.document.extensions.PMNDRS_font_distance_field; + const data = { + resource: defineRasterResourceId('test.mtsdf'), + binding: {}, + emSize: extension.emSize, + pixelRange: extension.pixelRange, + planeUnitsPerEm: extension.planeUnitsPerEm, + records: raster.records, + pages: raster.pages, + }; + return { core, raster, loaded: { font: core, technique: msdf, data } }; + } + const raster = await validateSlugArtifact(bytes, { ...context, descriptor: slugDescriptor() }); + const extension = raster.document.extensions.PMNDRS_font_slug; + const data = { + planeUnitsPerEm: extension.planeUnitsPerEm, + records: raster.records, + pages: raster.pages.map((page, pageIndex) => ({ + ...page, + resource: defineRasterResourceId(`test.slug.${pageIndex}`), + })), + bindings: [], + }; + return { core, raster, loaded: { font: core, technique: slug, data } }; +} + +function resourceCount(bytes, abi) { + return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32( + abi.layouts.fontBindingRequest.resourceCount, + true, + ); +} + +function tableBytes(bytes, abi, name, rows) { + const request = abi.layouts.fontBindingRequest; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const count = view.getUint8(request[`${name}FieldCount`]); + const offset = view.getUint32(request[`${name}Offset`], true); + return offset === 0 ? new Uint8Array() : bytes.slice(offset, offset + count * rows * 4); +} diff --git a/packages/text/tests/integration/text-engine-host.test.mjs b/packages/text/tests/integration/text-engine-host.test.mjs index 50661947..787a229e 100644 --- a/packages/text/tests/integration/text-engine-host.test.mjs +++ b/packages/text/tests/integration/text-engine-host.test.mjs @@ -3,6 +3,10 @@ import { readFile } from 'node:fs/promises'; import test from 'node:test'; import { TextEngineHost } from '../../dist/internal/text-engine-host.js'; +import { + firstPartyTechniqueWireIds, + firstPartyThreeRenderPolicyBytes, +} from '../../dist/internal/render-policy-wire.js'; import { createRuntimeShaper } from '../../dist/shaper.js'; import { engineUpdateBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; @@ -58,3 +62,36 @@ test('production text-engine host publishes borrowed A/B plans through the runti assert.throws(() => session.update(firstRequest), /disposed/); shaper.dispose(); }); + +test('one deterministic Three policy registers Bitmap, MSDF, and Slug with material-directed draws', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + assert.deepEqual(firstPartyTechniqueWireIds, { + bitmap: 0x1775_3b8c, + msdf: 0xf9a7_e4fd, + slug: 0xf22c_7908, + }); + const bytes = firstPartyThreeRenderPolicyBytes(); + const request = abi.layouts.policyRequest; + const program = abi.layouts.policyProgram; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + assert.equal(view.getUint32(request.programCount, true), 3); + const programsOffset = view.getUint32(request.programsOffset, true); + const expectedTechniques = [ + firstPartyTechniqueWireIds.bitmap, + firstPartyTechniqueWireIds.msdf, + firstPartyTechniqueWireIds.slug, + ]; + for (const [index, techniqueId] of expectedTechniques.entries()) { + const offset = programsOffset + index * program.size; + assert.equal(view.getUint32(offset + program.techniqueId, true), techniqueId); + assert.equal(view.getUint32(offset + program.programId, true), index + 1); + assert.ok(view.getUint32(offset + program.drawKeyMask, true) & abi.policy.batchFields.material); + assert.equal(view.getUint32(offset + program.storageKeyMask, true) & abi.policy.batchFields.material, 0); + } + + const shaper = await createRuntimeShaper({ wasm }); + const host = new TextEngineHost(shaper); + host.registerPolicy(12, bytes); + host.dispose(); + shaper.dispose(); +}); From ded89997845aa93841479c1f24ea82a46a447dc4 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 20:26:11 -0400 Subject: [PATCH 052/128] feat(text): compile retained frame requests --- docs/log.md | 8 + docs/packages/text.md | 14 +- docs/planning/rust-layout-engine.md | 9 + .../text/src/internal/engine-frame-wire.ts | 609 ++++++++++++++++++ .../integration/engine-frame-wire.test.mjs | 228 +++++++ 5 files changed, 867 insertions(+), 1 deletion(-) create mode 100644 packages/text/src/internal/engine-frame-wire.ts create mode 100644 packages/text/tests/integration/engine-frame-wire.test.mjs diff --git a/docs/log.md b/docs/log.md index 2deb6a48..a618bb41 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Promoted complete frame-request serialization into production** — A package-internal compiler now lowers text + mutations; full style records; constraints; sequential rectangle or polygon regions; polygon exclusions; inline + objects; policy parameters; and revision/fence state into one compiler-mapped allocation. It performs no shaping, + layout, batching, or packing. The current benchmark request is byte-identical to its established helper, including a + surrogate pair, while a broad structural fixture covers language, OpenType features, material, word/letter spacing, + baseline shift, decoration, vertical mode, holes, and inline objects. Public-state normalization and a real rich-frame + Rust acceptance test remain part of the Three cutover. + - **Compiled the first production Three policy and raster bindings** — One deterministic policy now registers Bitmap, MTSDF, and Slug together against the retained Rust planner. Draw identity includes numeric `material_id`; storage identity excludes it, so a renderer may share physical buffers while splitting draws by material. Production binding diff --git a/docs/packages/text.md b/docs/packages/text.md index f5a43a15..26fac683 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:4987208b99eecd7f622a65d67ec9682da6dee9cb4d804b264fcbccf8197f37de' +source_digest: 'sha256:60839e34e40a58bf01bdc41b1754318b027e132d66d61e6dad0702f8da70b820' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -128,6 +128,9 @@ sources: - id: text-engine-host resource: ../../packages/text/src/internal/text-engine-host.ts title: Retained frame host + - id: engine-frame-wire + resource: ../../packages/text/src/internal/engine-frame-wire.ts + title: Complete retained-frame request compiler - id: raster-validation resource: ../../packages/text/src/internal/raster-artifact-validation.ts title: Shared standalone raster artifact validation @@ -739,6 +742,15 @@ resource IDs lower through deterministic UTF-8 FNV-1a into the wire's nonzero `u registry rejects any collision before Rust registration. The policy and binding bytes are still package-internal; public third-party policy authoring and Three render-plan consumption remain open. +Frame-request serialization is also production code rather than a benchmark helper. One final `Uint8Array` carries +text replacements; root and span style mutations; language and OpenType feature payloads; material, paint, decoration, +letter/word spacing and baseline shift; multiple constraints and sequential rectangle or polygon regions; exclusion +holes; inline objects; policy parameters; and publication-fence state. The compiler only validates fixed-width host +values and writes the generated ABI—it does not shape, lay out, batch, or pack text. Its current rectangle request is +byte-identical to the established benchmark helper, including surrogate-pair UTF-16. A broad structural fixture proves +all variable tables and payload offsets; acceptance of normalized rich public state by a real Rust session remains an +open Three-cutover gate. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 05d5b916..5ce061ba 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1205,6 +1205,15 @@ deterministically to nonzero `u32` values with UTF-8 FNV-1a, while a runtime-sco both domains. This is production policy/binding compilation, not the Three cutover: the public adapter still consumes legacy paragraph batches until frame-request compilation and render-plan lowering land. +Production frame serialization now covers the complete current request ABI in one final allocation: text mutations; +root and range styles; language and feature payloads; typography, paint, material, and decoration fields; constraints; +sequential rectangle or polygon regions; exclusion holes; inline objects; policy bytes; and revision/fence state. It +contains no shaping, layout, batching, or record-packing logic. For the existing rectangle stress case, the production +bytes are exactly equal to the established benchmark helper, including UTF-16 surrogate handling. A broad structural +fixture covers every variable table and the vertical/polygon/decorated lanes. The remaining proof is deliberately +scoped: normalize actual public Three state into this descriptor, submit a rich request to Rust, then lower the returned +plan; structural serialization alone is not that cutover. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/src/internal/engine-frame-wire.ts b/packages/text/src/internal/engine-frame-wire.ts new file mode 100644 index 00000000..19558f07 --- /dev/null +++ b/packages/text/src/internal/engine-frame-wire.ts @@ -0,0 +1,609 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; + +const MAX_U32 = 0xffff_ffff; +const encoder = new TextEncoder(); + +export interface TextEngineFrameLimits { + readonly maxClusters: number; + readonly maxLines: number; + readonly maxRegions: number; + readonly maxExclusions: number; + readonly maxInlineObjects: number; + readonly maxSlotsPerBand: number; + readonly maxOutputBytes: number; +} + +export interface TextEngineTextMutation { + readonly start: number; + readonly deleteCount: number; + readonly insert: string; +} + +export interface TextEngineFeature { + readonly tag: string; + readonly value: number; + readonly start: number; + readonly end: number; +} + +export interface TextEngineDecoration { + readonly style: 'none' | 'solid' | 'double' | 'dotted' | 'dashed' | 'wavy'; + readonly rgba: number; + readonly underline?: boolean; + readonly overline?: boolean; + readonly lineThrough?: boolean; + readonly skipInk?: boolean; + readonly thickness: number; + readonly offset: number; +} + +export interface TextEngineStyleValue { + readonly fontStackHandle?: number; + readonly materialId?: number; + readonly language?: string; + readonly features?: readonly TextEngineFeature[]; + readonly fontSize?: number; + readonly lineHeight?: number; + readonly letterSpacing?: number; + readonly wordSpacing?: number; + readonly baselineShift?: number; + readonly rasterPixelRatio?: number; + readonly direction?: 'auto' | 'ltr' | 'rtl'; + readonly foregroundRgba?: number; + readonly decoration?: TextEngineDecoration; +} + +export type TextEngineStyleMutation = + | { readonly opcode: 'remove'; readonly styleId: number } + | { + readonly opcode: 'upsert'; + readonly styleId: number; + readonly cascadeOrder: number; + readonly start: number; + readonly end: number; + readonly root?: boolean; + readonly value: TextEngineStyleValue; + }; + +export interface TextEngineConstraint { + readonly flowThreadId: number; + readonly geometryRevision: number; + readonly width: number; + readonly height: number; + readonly viewportBlockStart: number; + readonly viewportBlockEnd: number; + readonly resumeBlockOffset: number; + readonly maxLines: number; + readonly regionStart: number; + readonly resumeCluster: number; + readonly regionCount: number; + readonly resumeRegion: number; + readonly widthMode: 'unconstrained' | 'at-most' | 'exact'; + readonly heightMode: 'unconstrained' | 'at-most' | 'exact'; + readonly wrap: 'none' | 'word' | 'character'; + readonly align: 'start' | 'center' | 'end' | 'justify'; + readonly overflow: 'visible' | 'clip' | 'ellipsis'; + readonly blockAlign: 'start' | 'center' | 'end'; +} + +export interface TextEngineFlowVertex { + readonly inline: number; + readonly block: number; +} + +export interface TextEngineRegion { + readonly id: number; + readonly geometryRevision: number; + readonly shape: 'rectangle' | 'polygon'; + readonly vertices?: readonly TextEngineFlowVertex[]; + readonly exclusionStart: number; + readonly exclusionCount: number; + readonly writingMode: 'horizontal-tb' | 'vertical-rl' | 'vertical-lr'; + readonly textOrientation: 'mixed' | 'upright' | 'sideways'; + readonly inlineStart: number; + readonly blockStart: number; + readonly inlineEnd: number; + readonly blockEnd: number; + readonly clipInlineStart: number; + readonly clipBlockStart: number; + readonly clipInlineEnd: number; + readonly clipBlockEnd: number; +} + +export interface TextEngineExclusion { + readonly id: number; + readonly regionId: number; + readonly geometryRevision: number; + readonly shape: 'rectangle' | 'polygon'; + readonly vertices?: readonly TextEngineFlowVertex[]; + readonly wrapSide: 'both' | 'inline-start' | 'inline-end' | 'largest'; + readonly inlineStart: number; + readonly blockStart: number; + readonly inlineEnd: number; + readonly blockEnd: number; + readonly marginInline: number; + readonly marginBlock: number; +} + +export interface TextEngineInlineObject { + readonly id: number; + readonly contentRevision: number; + readonly textOffset: number; + readonly materialId: number; + readonly resourceId: number; + readonly resourceGeneration: number; + readonly inlineExtent: number; + readonly blockExtent: number; + readonly baselineOffset: number; + readonly marginInlineStart: number; + readonly marginInlineEnd: number; + readonly marginBlockStart: number; + readonly marginBlockEnd: number; + readonly baselineAlignment: 'alphabetic' | 'text-top' | 'middle' | 'text-bottom'; +} + +export interface TextEngineFrameUpdate { + readonly sessionId: number; + readonly policyHandle: number; + readonly capabilitySet: number; + readonly expectedEngineRevision: number; + readonly consumedPlanRevision: number; + readonly acknowledgedPublicationGeneration: number; + readonly semanticViewMask?: number; + readonly limits: TextEngineFrameLimits; + readonly textMutations?: readonly TextEngineTextMutation[]; + readonly styleMutations?: readonly TextEngineStyleMutation[]; + readonly constraints?: readonly TextEngineConstraint[]; + readonly regions?: readonly TextEngineRegion[]; + readonly exclusions?: readonly TextEngineExclusion[]; + readonly inlineObjects?: readonly TextEngineInlineObject[]; + readonly policyParameters?: Uint8Array; +} + +/** Serialize mutations and constraints only; shaping, layout, planning, and packing remain Rust-owned. */ +export function compileTextEngineFrameUpdate(frame: TextEngineFrameUpdate): Uint8Array { + const abi = textShaperAbi; + const request = abi.layouts.engineUpdateRequest; + const textMutations = frame.textMutations ?? []; + const styleMutations = frame.styleMutations ?? []; + const constraints = frame.constraints ?? []; + const regions = frame.regions ?? []; + const exclusions = frame.exclusions ?? []; + const inlineObjects = frame.inlineObjects ?? []; + let cursor: number = request.size; + const allocate = (count: number, stride: number, alignment: number, label: string): number => { + if (count === 0) return 0; + const offset = align(cursor, alignment); + cursor = checkedAdd(offset, checkedProduct(count, stride, label), label); + return offset; + }; + const textOffset = allocate(textMutations.length, abi.layouts.engineTextMutation.size, 4, 'text mutations'); + const styleOffset = allocate(styleMutations.length, abi.layouts.engineStyleMutation.size, 4, 'style mutations'); + const constraintOffset = allocate(constraints.length, abi.layouts.engineConstraint.size, 4, 'constraints'); + const regionOffset = allocate(regions.length, abi.layouts.engineRegion.size, 4, 'regions'); + const exclusionOffset = allocate(exclusions.length, abi.layouts.engineExclusion.size, 4, 'exclusions'); + const inlineObjectOffset = allocate(inlineObjects.length, abi.layouts.engineInlineObject.size, 4, 'inline objects'); + const textPayloads = textMutations.map((mutation) => allocate(mutation.insert.length, 2, 2, 'text mutation payload')); + const languageBytes = styleMutations.map((mutation) => + mutation.opcode === 'upsert' && mutation.value.language !== undefined + ? encoder.encode(mutation.value.language) + : new Uint8Array(), + ); + const languageOffsets = languageBytes.map((bytes) => allocate(bytes.length, 1, 1, 'style language')); + const featureOffsets = styleMutations.map((mutation) => + allocate( + mutation.opcode === 'upsert' ? (mutation.value.features?.length ?? 0) : 0, + abi.layouts.feature.size, + abi.layouts.feature.alignment, + 'style features', + ), + ); + const regionVertexOffsets = regions.map((region) => + allocate(region.vertices?.length ?? 0, abi.layouts.engineFlowVertex.size, 4, 'region vertices'), + ); + const exclusionVertexOffsets = exclusions.map((exclusion) => + allocate(exclusion.vertices?.length ?? 0, abi.layouts.engineFlowVertex.size, 4, 'exclusion vertices'), + ); + const policyParameters = frame.policyParameters ?? new Uint8Array(); + const policyParametersOffset = allocate(policyParameters.length, 1, 1, 'policy parameters'); + const bytes = new Uint8Array(cursor); + const view = new DataView(bytes.buffer); + + writeHeader(view, frame, bytes.length, { + textOffset, + styleOffset, + constraintOffset, + regionOffset, + exclusionOffset, + inlineObjectOffset, + policyParametersOffset, + }); + writeTextMutations(view, textOffset, textMutations, textPayloads); + writeStyleMutations(view, bytes, styleOffset, styleMutations, languageBytes, languageOffsets, featureOffsets); + writeConstraints(view, constraintOffset, constraints); + writeRegions(view, regionOffset, regions, regionVertexOffsets); + writeExclusions(view, exclusionOffset, exclusions, exclusionVertexOffsets); + writeInlineObjects(view, inlineObjectOffset, inlineObjects); + bytes.set(policyParameters, policyParametersOffset); + return bytes; +} + +interface HeaderOffsets { + readonly textOffset: number; + readonly styleOffset: number; + readonly constraintOffset: number; + readonly regionOffset: number; + readonly exclusionOffset: number; + readonly inlineObjectOffset: number; + readonly policyParametersOffset: number; +} + +function writeHeader(view: DataView, frame: TextEngineFrameUpdate, byteLength: number, offsets: HeaderOffsets): void { + const layout = textShaperAbi.layouts.engineUpdateRequest; + const limits = frame.limits; + for (const [field, value] of [ + ['abiVersion', textShaperAbi.version], + ['byteLength', byteLength], + ['sessionId', frame.sessionId], + ['expectedEngineRevision', frame.expectedEngineRevision], + ['consumedPlanRevision', frame.consumedPlanRevision], + ['acknowledgedPublicationGeneration', frame.acknowledgedPublicationGeneration], + ['policyHandle', frame.policyHandle], + ['capabilitySet', frame.capabilitySet], + ['semanticViewMask', frame.semanticViewMask ?? 0], + ['maxClusters', limits.maxClusters], + ['maxLines', limits.maxLines], + ['maxRegions', limits.maxRegions], + ['maxExclusions', limits.maxExclusions], + ['maxInlineObjects', limits.maxInlineObjects], + ['maxSlotsPerBand', limits.maxSlotsPerBand], + ['maxOutputBytes', limits.maxOutputBytes], + ['textMutationsOffset', offsets.textOffset], + ['textMutationCount', frame.textMutations?.length ?? 0], + ['styleMutationsOffset', offsets.styleOffset], + ['styleMutationCount', frame.styleMutations?.length ?? 0], + ['constraintsOffset', offsets.constraintOffset], + ['constraintCount', frame.constraints?.length ?? 0], + ['regionsOffset', offsets.regionOffset], + ['regionCount', frame.regions?.length ?? 0], + ['exclusionsOffset', offsets.exclusionOffset], + ['exclusionCount', frame.exclusions?.length ?? 0], + ['inlineObjectsOffset', offsets.inlineObjectOffset], + ['inlineObjectCount', frame.inlineObjects?.length ?? 0], + ['policyParametersOffset', offsets.policyParametersOffset], + ['policyParametersLength', frame.policyParameters?.length ?? 0], + ] as const) { + view.setUint32(layout[field], u32(value, field), true); + } +} + +function writeTextMutations( + view: DataView, + tableOffset: number, + mutations: readonly TextEngineTextMutation[], + payloadOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineTextMutation; + for (const [index, mutation] of mutations.entries()) { + const offset = tableOffset + index * layout.size; + const payloadOffset = payloadOffsets[index]!; + view.setUint8(offset + layout.opcode, textShaperAbi.engine.textMutationOpcodes.replaceUtf16); + view.setUint8(offset + layout.encoding, textShaperAbi.engine.textEncodings.utf16Le); + view.setUint32(offset + layout.textStart, u32(mutation.start, 'text mutation start'), true); + view.setUint32(offset + layout.deleteCount, u32(mutation.deleteCount, 'text mutation delete count'), true); + view.setUint32(offset + layout.insertOffset, payloadOffset, true); + view.setUint32(offset + layout.insertCount, u32(mutation.insert.length, 'text mutation insert count'), true); + for (let unit = 0; unit < mutation.insert.length; unit += 1) { + view.setUint16(payloadOffset + unit * 2, mutation.insert.charCodeAt(unit), true); + } + } +} + +function writeStyleMutations( + view: DataView, + bytes: Uint8Array, + tableOffset: number, + mutations: readonly TextEngineStyleMutation[], + languages: readonly Uint8Array[], + languageOffsets: readonly number[], + featureOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineStyleMutation; + for (const [index, mutation] of mutations.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.styleId, u32(mutation.styleId, 'style ID'), true); + if (mutation.opcode === 'remove') { + view.setUint8(offset + layout.opcode, textShaperAbi.engine.styleMutationOpcodes.remove); + continue; + } + const value = mutation.value; + const fields = textShaperAbi.engine.styleFields; + const fieldMask = + present(value.fontStackHandle, fields.fontStack) | + present(value.materialId, fields.material) | + present(value.language, fields.language) | + present(value.features, fields.features) | + present(value.fontSize, fields.fontSize) | + present(value.lineHeight, fields.lineHeight) | + present(value.letterSpacing, fields.letterSpacing) | + present(value.wordSpacing, fields.wordSpacing) | + present(value.baselineShift, fields.baselineShift) | + present(value.rasterPixelRatio, fields.rasterPixelRatio) | + present(value.direction, fields.direction) | + present(value.foregroundRgba, fields.foreground) | + present(value.decoration, fields.decoration); + view.setUint8(offset + layout.opcode, textShaperAbi.engine.styleMutationOpcodes.upsert); + view.setUint8(offset + layout.direction, direction(value.direction)); + view.setUint8(offset + layout.flags, mutation.root === true ? textShaperAbi.engine.styleFlags.root : 0); + view.setUint32(offset + layout.cascadeOrder, u32(mutation.cascadeOrder, 'style cascade order'), true); + view.setUint32(offset + layout.fieldMask, fieldMask, true); + view.setUint32(offset + layout.textStart, u32(mutation.start, 'style start'), true); + view.setUint32(offset + layout.textEnd, u32(mutation.end, 'style end'), true); + optionalU32(view, offset + layout.fontStackHandle, value.fontStackHandle, 'font stack handle'); + optionalU32(view, offset + layout.materialId, value.materialId, 'material ID'); + const language = languages[index]!; + view.setUint32(offset + layout.languageOffset, languageOffsets[index]!, true); + view.setUint16(offset + layout.languageLength, u16(language.length, 'language byte length'), true); + bytes.set(language, languageOffsets[index]!); + const features = value.features ?? []; + const featureOffset = featureOffsets[index]!; + view.setUint16(offset + layout.featureCount, u16(features.length, 'feature count'), true); + view.setUint32(offset + layout.featuresOffset, featureOffset, true); + writeFeatures(view, featureOffset, features); + optionalF32(view, offset + layout.fontSize, value.fontSize, 'font size'); + optionalF32(view, offset + layout.lineHeight, value.lineHeight, 'line height'); + optionalF32(view, offset + layout.letterSpacing, value.letterSpacing, 'letter spacing'); + optionalF32(view, offset + layout.wordSpacing, value.wordSpacing, 'word spacing'); + optionalF32(view, offset + layout.baselineShift, value.baselineShift, 'baseline shift'); + optionalF32(view, offset + layout.rasterPixelRatio, value.rasterPixelRatio, 'raster pixel ratio'); + optionalU32(view, offset + layout.foregroundRgba, value.foregroundRgba, 'foreground RGBA'); + writeDecoration(view, offset, value.decoration); + } +} + +function writeFeatures(view: DataView, tableOffset: number, features: readonly TextEngineFeature[]): void { + const layout = textShaperAbi.layouts.feature; + for (const [index, feature] of features.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.tag, tag(feature.tag), true); + view.setUint32(offset + layout.value, u32(feature.value, 'feature value'), true); + view.setUint32(offset + layout.start, u32(feature.start, 'feature start'), true); + view.setUint32(offset + layout.end, u32(feature.end, 'feature end'), true); + } +} + +function writeDecoration(view: DataView, offset: number, decoration: TextEngineDecoration | undefined): void { + if (decoration === undefined) return; + const layout = textShaperAbi.layouts.engineStyleMutation; + const styles = textShaperAbi.engine.decorationStyles; + const flags = textShaperAbi.engine.decorationFlags; + view.setUint8(offset + layout.decorationStyle, styles[decoration.style]); + view.setUint32(offset + layout.decorationRgba, u32(decoration.rgba, 'decoration RGBA'), true); + view.setUint32( + offset + layout.decorationFlags, + (decoration.underline === true ? flags.underline : 0) | + (decoration.overline === true ? flags.overline : 0) | + (decoration.lineThrough === true ? flags.lineThrough : 0) | + (decoration.skipInk === true ? flags.skipInk : 0), + true, + ); + view.setFloat32(offset + layout.decorationThickness, finite(decoration.thickness, 'decoration thickness'), true); + view.setFloat32(offset + layout.decorationOffset, finite(decoration.offset, 'decoration offset'), true); +} + +function writeConstraints(view: DataView, tableOffset: number, constraints: readonly TextEngineConstraint[]): void { + const layout = textShaperAbi.layouts.engineConstraint; + const engine = textShaperAbi.engine; + for (const [index, value] of constraints.entries()) { + const offset = tableOffset + index * layout.size; + for (const [field, number] of [ + ['flowThreadId', value.flowThreadId], + ['geometryRevision', value.geometryRevision], + ['maxLines', value.maxLines], + ['regionStart', value.regionStart], + ['resumeCluster', value.resumeCluster], + ] as const) { + view.setUint32(offset + layout[field], u32(number, field), true); + } + view.setUint16(offset + layout.regionCount, u16(value.regionCount, 'constraint region count'), true); + view.setUint16(offset + layout.resumeRegion, u16(value.resumeRegion, 'constraint resume region'), true); + for (const [field, number] of [ + ['width', value.width], + ['height', value.height], + ['viewportBlockStart', value.viewportBlockStart], + ['viewportBlockEnd', value.viewportBlockEnd], + ['resumeBlockOffset', value.resumeBlockOffset], + ] as const) { + view.setFloat32(offset + layout[field], finite(number, field), true); + } + view.setUint8(offset + layout.widthMode, axisMode(value.widthMode)); + view.setUint8(offset + layout.heightMode, axisMode(value.heightMode)); + view.setUint8(offset + layout.wrap, engine.wrapModes[value.wrap]); + view.setUint8(offset + layout.align, engine.inlineAlignments[value.align]); + view.setUint8(offset + layout.overflow, engine.overflowModes[value.overflow]); + view.setUint8(offset + layout.blockAlign, engine.blockAlignments[value.blockAlign]); + } +} + +function writeRegions( + view: DataView, + tableOffset: number, + regions: readonly TextEngineRegion[], + vertexOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineRegion; + for (const [index, value] of regions.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.id, u32(value.id, 'region ID'), true); + view.setUint32(offset + layout.geometryRevision, u32(value.geometryRevision, 'region geometry revision'), true); + view.setUint32(offset + layout.verticesOffset, vertexOffsets[index]!, true); + view.setUint16(offset + layout.vertexCount, u16(value.vertices?.length ?? 0, 'region vertex count'), true); + view.setUint16(offset + layout.exclusionStart, u16(value.exclusionStart, 'region exclusion start'), true); + view.setUint16(offset + layout.exclusionCount, u16(value.exclusionCount, 'region exclusion count'), true); + view.setUint8(offset + layout.shape, textShaperAbi.engine.flowShapeKinds[value.shape]); + view.setUint8(offset + layout.writingMode, writingMode(value.writingMode)); + view.setUint8(offset + layout.textOrientation, textOrientation(value.textOrientation)); + writeBounds(view, offset, layout, value); + writeVertices(view, vertexOffsets[index]!, value.vertices ?? []); + } +} + +function writeExclusions( + view: DataView, + tableOffset: number, + exclusions: readonly TextEngineExclusion[], + vertexOffsets: readonly number[], +): void { + const layout = textShaperAbi.layouts.engineExclusion; + for (const [index, value] of exclusions.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.id, u32(value.id, 'exclusion ID'), true); + view.setUint32(offset + layout.regionId, u32(value.regionId, 'exclusion region ID'), true); + view.setUint32(offset + layout.geometryRevision, u32(value.geometryRevision, 'exclusion geometry revision'), true); + view.setUint32(offset + layout.verticesOffset, vertexOffsets[index]!, true); + view.setUint16(offset + layout.vertexCount, u16(value.vertices?.length ?? 0, 'exclusion vertex count'), true); + view.setUint8(offset + layout.shape, textShaperAbi.engine.flowShapeKinds[value.shape]); + view.setUint8(offset + layout.wrapSide, exclusionWrap(value.wrapSide)); + writeBounds(view, offset, layout, value); + view.setFloat32(offset + layout.marginInline, finite(value.marginInline, 'exclusion inline margin'), true); + view.setFloat32(offset + layout.marginBlock, finite(value.marginBlock, 'exclusion block margin'), true); + writeVertices(view, vertexOffsets[index]!, value.vertices ?? []); + } +} + +function writeBounds( + view: DataView, + offset: number, + layout: Record, + value: TextEngineRegion | TextEngineExclusion, +): void { + for (const field of ['inlineStart', 'blockStart', 'inlineEnd', 'blockEnd'] as const) { + view.setFloat32(offset + layout[field]!, finite(value[field], field), true); + } + if ('clipInlineStart' in value) { + view.setFloat32(offset + layout.clipInlineStart!, finite(value.clipInlineStart, 'clipInlineStart'), true); + view.setFloat32(offset + layout.clipBlockStart!, finite(value.clipBlockStart, 'clipBlockStart'), true); + view.setFloat32(offset + layout.clipInlineEnd!, finite(value.clipInlineEnd, 'clipInlineEnd'), true); + view.setFloat32(offset + layout.clipBlockEnd!, finite(value.clipBlockEnd, 'clipBlockEnd'), true); + } +} + +function writeVertices(view: DataView, tableOffset: number, vertices: readonly TextEngineFlowVertex[]): void { + const layout = textShaperAbi.layouts.engineFlowVertex; + for (const [index, vertex] of vertices.entries()) { + const offset = tableOffset + index * layout.size; + view.setFloat32(offset + layout.inline, finite(vertex.inline, 'vertex inline'), true); + view.setFloat32(offset + layout.block, finite(vertex.block, 'vertex block'), true); + } +} + +function writeInlineObjects(view: DataView, tableOffset: number, objects: readonly TextEngineInlineObject[]): void { + const layout = textShaperAbi.layouts.engineInlineObject; + for (const [index, value] of objects.entries()) { + const offset = tableOffset + index * layout.size; + for (const [field, number] of [ + ['id', value.id], + ['contentRevision', value.contentRevision], + ['textOffset', value.textOffset], + ['materialId', value.materialId], + ['resourceId', value.resourceId], + ['resourceGeneration', value.resourceGeneration], + ] as const) { + view.setUint32(offset + layout[field], u32(number, `inline object ${field}`), true); + } + for (const [field, number] of [ + ['inlineExtent', value.inlineExtent], + ['blockExtent', value.blockExtent], + ['baselineOffset', value.baselineOffset], + ['marginInlineStart', value.marginInlineStart], + ['marginInlineEnd', value.marginInlineEnd], + ['marginBlockStart', value.marginBlockStart], + ['marginBlockEnd', value.marginBlockEnd], + ] as const) { + view.setFloat32(offset + layout[field], finite(number, `inline object ${field}`), true); + } + view.setUint8(offset + layout.baselineAlignment, inlineBaseline(value.baselineAlignment)); + } +} + +function present(value: unknown, bit: number): number { + return value === undefined ? 0 : bit; +} + +function optionalU32(view: DataView, offset: number, value: number | undefined, label: string): void { + if (value !== undefined) view.setUint32(offset, u32(value, label), true); +} + +function optionalF32(view: DataView, offset: number, value: number | undefined, label: string): void { + if (value !== undefined) view.setFloat32(offset, finite(value, label), true); +} + +function direction(value: TextEngineStyleValue['direction']): number { + return value === undefined || value === 'auto' ? 0 : value === 'ltr' ? 1 : 2; +} + +function axisMode(value: TextEngineConstraint['widthMode']): number { + const modes = textShaperAbi.engine.axisModes; + return value === 'at-most' ? modes.atMost : modes[value]; +} + +function writingMode(value: TextEngineRegion['writingMode']): number { + const modes = textShaperAbi.engine.writingModes; + return value === 'horizontal-tb' ? modes.horizontalTb : value === 'vertical-rl' ? modes.verticalRl : modes.verticalLr; +} + +function textOrientation(value: TextEngineRegion['textOrientation']): number { + return textShaperAbi.engine.textOrientations[value]; +} + +function exclusionWrap(value: TextEngineExclusion['wrapSide']): number { + const sides = textShaperAbi.engine.exclusionWrapSides; + return value === 'inline-start' ? sides.inlineStart : value === 'inline-end' ? sides.inlineEnd : sides[value]; +} + +function inlineBaseline(value: TextEngineInlineObject['baselineAlignment']): number { + const baselines = textShaperAbi.engine.inlineObjectBaselines; + return value === 'text-top' ? baselines.textTop : value === 'text-bottom' ? baselines.textBottom : baselines[value]; +} + +function tag(value: string): number { + if (value.length !== 4) throw new RangeError('feature tag must contain exactly four bytes'); + let packed = 0; + for (let index = 0; index < 4; index += 1) { + const byte = value.charCodeAt(index); + if (byte < 0x20 || byte > 0x7e) throw new RangeError('feature tag must contain printable ASCII bytes'); + packed = (packed << 8) | byte; + } + return packed >>> 0; +} + +function finite(value: number, label: string): number { + if (!Number.isFinite(value)) throw new RangeError(`${label} must be finite`); + return value; +} + +function u16(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff) throw new RangeError(`${label} must be a u16`); + return value; +} + +function u32(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value < 0 || value > MAX_U32) throw new RangeError(`${label} must be a u32`); + return value; +} + +function align(value: number, alignment: number): number { + return checkedAdd(value, (alignment - (value % alignment)) % alignment, 'aligned frame offset'); +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds the frame ABI`); + return value; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value > MAX_U32) throw new RangeError(`${label} exceeds the frame ABI`); + return value; +} diff --git a/packages/text/tests/integration/engine-frame-wire.test.mjs b/packages/text/tests/integration/engine-frame-wire.test.mjs new file mode 100644 index 00000000..ffaf6181 --- /dev/null +++ b/packages/text/tests/integration/engine-frame-wire.test.mjs @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; +import { engineFrameUpdateBytes } from '../support/engine-abi.mjs'; + +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); + +test('production frame compiler preserves the established benchmark request bytes', async () => { + const abi = JSON.parse(await readFile(abiUrl, 'utf8')); + const text = 'A😀B'; + const units = Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)); + const limits = { maxClusters: 8, maxLines: 8, maxOutputBytes: 65_536 }; + const expected = engineFrameUpdateBytes(abi, { + sessionId: 5, + policyHandle: 11, + fontStackHandle: 7, + textMutation: { start: 0, deleteCount: 0, insert: units }, + style: { textEnd: text.length, fontSize: 24, lineHeight: 1.2, rasterPixelRatio: 2 }, + geometry: { width: 320, height: 180, maxLines: 8, revision: 9 }, + limits, + }); + const actual = compileTextEngineFrameUpdate({ + sessionId: 5, + policyHandle: 11, + capabilitySet: 1, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + acknowledgedPublicationGeneration: 0, + limits: { + ...limits, + maxRegions: 1, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 1, + }, + textMutations: [{ start: 0, deleteCount: 0, insert: text }], + styleMutations: [ + { + opcode: 'upsert', + styleId: 1, + cascadeOrder: 0, + start: 0, + end: text.length, + root: true, + value: { fontStackHandle: 7, fontSize: 24, lineHeight: 1.2, rasterPixelRatio: 2 }, + }, + ], + constraints: [ + { + flowThreadId: 1, + geometryRevision: 0, + width: 320, + height: 180, + viewportBlockStart: 0, + viewportBlockEnd: 180, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 0, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'exact', + heightMode: 'exact', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + ], + regions: [ + { + id: 1, + geometryRevision: 9, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 320, + blockEnd: 180, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 320, + clipBlockEnd: 180, + }, + ], + }); + assert.deepEqual(actual, expected); +}); + +test('production frame compiler carries full style, polygon, exclusion, and inline-object payloads', async () => { + const abi = JSON.parse(await readFile(abiUrl, 'utf8')); + const bytes = compileTextEngineFrameUpdate({ + sessionId: 1, + policyHandle: 2, + capabilitySet: 1, + expectedEngineRevision: 3, + consumedPlanRevision: 4, + acknowledgedPublicationGeneration: 5, + semanticViewMask: 6, + limits: { + maxClusters: 32, + maxLines: 16, + maxRegions: 2, + maxExclusions: 2, + maxInlineObjects: 2, + maxSlotsPerBand: 3, + maxOutputBytes: 1_048_576, + }, + styleMutations: [ + { + opcode: 'upsert', + styleId: 9, + cascadeOrder: 2, + start: 1, + end: 5, + value: { + fontStackHandle: 7, + materialId: 8, + language: 'ja', + features: [{ tag: 'kern', value: 1, start: 1, end: 5 }], + fontSize: 18, + lineHeight: 1.25, + letterSpacing: 0.5, + wordSpacing: 1.5, + baselineShift: -2, + rasterPixelRatio: 2, + direction: 'rtl', + foregroundRgba: 0x1122_3344, + decoration: { + style: 'solid', + rgba: 0x5566_7788, + underline: true, + lineThrough: true, + skipInk: true, + thickness: 1, + offset: 2, + }, + }, + }, + ], + constraints: [], + regions: [ + { + id: 1, + geometryRevision: 1, + shape: 'polygon', + vertices: [ + { inline: 0, block: 0 }, + { inline: 100, block: 0 }, + { inline: 100, block: 100 }, + ], + exclusionStart: 0, + exclusionCount: 1, + writingMode: 'vertical-rl', + textOrientation: 'upright', + inlineStart: 0, + blockStart: 0, + inlineEnd: 100, + blockEnd: 100, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 100, + clipBlockEnd: 100, + }, + ], + exclusions: [ + { + id: 2, + regionId: 1, + geometryRevision: 1, + shape: 'polygon', + vertices: [ + { inline: 20, block: 20 }, + { inline: 40, block: 20 }, + { inline: 30, block: 40 }, + ], + wrapSide: 'largest', + inlineStart: 20, + blockStart: 20, + inlineEnd: 40, + blockEnd: 40, + marginInline: 2, + marginBlock: 3, + }, + ], + inlineObjects: [ + { + id: 3, + contentRevision: 1, + textOffset: 4, + materialId: 8, + resourceId: 10, + resourceGeneration: 1, + inlineExtent: 12, + blockExtent: 14, + baselineOffset: 2, + marginInlineStart: 1, + marginInlineEnd: 1, + marginBlockStart: 0, + marginBlockEnd: 0, + baselineAlignment: 'alphabetic', + }, + ], + policyParameters: Uint8Array.of(7, 8, 9), + }); + const request = abi.layouts.engineUpdateRequest; + const header = new DataView(bytes.buffer, bytes.byteOffset, request.size); + assert.equal(header.getUint32(request.byteLength, true), bytes.byteLength); + assert.equal(header.getUint32(request.styleMutationCount, true), 1); + assert.equal(header.getUint32(request.regionCount, true), 1); + assert.equal(header.getUint32(request.exclusionCount, true), 1); + assert.equal(header.getUint32(request.inlineObjectCount, true), 1); + assert.equal(header.getUint32(request.policyParametersLength, true), 3); + assert.deepEqual(bytes.slice(header.getUint32(request.policyParametersOffset, true)), Uint8Array.of(7, 8, 9)); + const styleOffset = header.getUint32(request.styleMutationsOffset, true); + const style = abi.layouts.engineStyleMutation; + const styleView = new DataView(bytes.buffer, bytes.byteOffset + styleOffset, style.size); + assert.equal(styleView.getUint8(style.direction), 2); + assert.equal(styleView.getUint16(style.languageLength, true), 2); + assert.equal(styleView.getUint16(style.featureCount, true), 1); + assert.equal(styleView.getUint32(style.materialId, true), 8); + assert.equal(styleView.getUint32(style.decorationFlags, true), 13); +}); From 0855236499c4e6b179474c5b8981c12583f1313e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 20:40:58 -0400 Subject: [PATCH 053/128] feat(text): separate shaping and render font identities --- docs/log.md | 8 ++ docs/packages/text.md | 12 +- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 11 ++ .../rust/shaper/src/engine/cluster_state.rs | 8 ++ .../rust/shaper/src/engine/policy_gather.rs | 10 +- .../rust/shaper/src/engine/positioning.rs | 4 + .../rust/shaper/src/engine/shaping_state.rs | 3 + packages/text/rust/shaper/src/engine/state.rs | 111 +++++++++++++++--- packages/text/rust/shaper/src/wasm.rs | 14 ++- .../scripts/benchmark-rust-layout-engine.mjs | 2 +- .../text/src/internal/text-engine-host.ts | 10 +- packages/text/src/shaper.ts | 7 +- .../integration/shaper-registration.test.mjs | 66 +++++++++-- 14 files changed, 227 insertions(+), 40 deletions(-) diff --git a/docs/log.md b/docs/log.md index a618bb41..975dbb05 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Separated shaping-font identity from render-binding identity** — Rust font stacks now contain loaded-font binding + handles, and each binding names its shared shaping-font handle. Shaping, metrics, and extents continue through the + retained font once; policy gather follows the selected binding, so the same face may carry multiple raster techniques + and fallback preserves its technique into the emitted plan. A compiled-Wasm Inter → Devanagari fallback emits the + second technique, and another fixture registers two techniques against one shaping font. The unchanged + 25,515-positioned/21,805-renderable resize medians are 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug; 6–7% run RSD does + not establish a regression from 4.120/4.646/5.622 ms. Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes. + - **Promoted complete frame-request serialization into production** — A package-internal compiler now lowers text mutations; full style records; constraints; sequential rectangle or polygon regions; polygon exclusions; inline objects; policy parameters; and revision/fence state into one compiler-mapped allocation. It performs no shaping, diff --git a/docs/packages/text.md b/docs/packages/text.md index 26fac683..e9cb2ce3 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:60839e34e40a58bf01bdc41b1754318b027e132d66d61e6dad0702f8da70b820' +source_digest: 'sha256:d75f5786f818b1e6db813d28197024af3a3fb6bede937e94712c141547c287fe' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -751,6 +751,16 @@ byte-identical to the established benchmark helper, including surrogate-pair UTF all variable tables and payload offsets; acceptance of normalized rich public state by a real Rust session remains an open Three-cutover gate. +Rust now distinguishes a loaded-font render binding from the shaping font whose retained SFNT, plans, metrics, and +extents it reuses. Engine font stacks contain binding handles; each binding points to one shaping handle and carries its +own technique/resources. Fallback records preserve both identities through shaping and cluster aggregation, positioning +uses the shaping identity, and policy gather uses the binding identity. This permits one face to register several raster +techniques without duplicating shaping data and permits a fallback glyph to emit a different technique in the same +render plan. Compiled-Wasm tests prove both cases. The additional retained `u32` cluster lane changes optimized Wasm to +1,070,580 raw / 402,114 gzip / 319,662 Brotli bytes. Current 8-warmup/31-sample resize medians are +4.217/4.791/5.633 milliseconds for Bitmap/MTSDF/Slug; their 6–7% RSD does not distinguish the small movement from the +preceding 4.120/4.646/5.622-millisecond checkpoint. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 45e4544e..beef00a3 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -269,6 +269,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-202 | Positioned layout admits a conservative trivial-LTR lane only when every retained bidi level is even and no shaping run carries a direction override. That lane traverses logical clusters directly and does not fill line-level, visual-cluster, or visual-level scratch; any odd level or override retains the complete UAX #9 L1/L2 path. Exact positioning tests cover the admitted and rejected predicates, while the mixed-direction package golden remains unchanged. On the unchanged 25,515-positioned/21,805-renderable workload, two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 ms; two optimized runs measured 4.849 and 4.878 ms with the same single 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 ms. The sub-4 ms gate remains open. Optimized Wasm is 1,065,857 / 403,525 / 318,137 raw/gzip/Brotli bytes; the six-byte Brotli increase is the meaningful compressed-size comparison, while gzip changed materially from code-layout interaction. | Accepted | | D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | | D-204 | The first production Three policy registers Bitmap, MTSDF, and Slug together. Technique, program, and resource identify storage; material, clip, depth, and order additionally identify draws, allowing material-directed draw splits without forcing duplicate physical storage. Public string technique and raster-resource identities lower to deterministic nonzero `u32` wire IDs with UTF-8 FNV-1a, and one runtime-scoped registry rejects collisions across the shared namespace before registration. First-party font bindings compile validated raster data directly into one field-major request allocation, preserve every bitmap strike, sort resources by wire ID, and remap record references accordingly. Exact real-fixture tests compare every production field against the established renderer-parity tables, and the combined policy registers in compiled Wasm. Three render-plan consumption and public third-party policy authoring remain open. | Accepted | +| D-205 | A render-binding handle identifies one loaded font/technique/resource combination independently from its shaping-font handle. Rust font stacks contain binding handles; each binding names the retained shaping font whose SFNT, plans, metrics, and extents it reuses. Fallback and cluster state retain both identities: shaping and positioning use the shaping handle, while policy gather uses the binding handle. Compiled Wasm proves two techniques can bind one shaping font and an Inter-to-Devanagari fallback emits the second binding's distinct technique in one Rust plan. The additional retained `u32` cluster lane measures 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug resize at 8 warmups/31 samples; 6–7% RSD does not distinguish this from D-203's 4.120/4.646/5.622 ms. Optimized Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes, +607 / -3,774 / +104 bytes from D-203. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 5ce061ba..1c6f7d52 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1214,6 +1214,17 @@ fixture covers every variable table and the vertical/polygon/decorated lanes. Th scoped: normalize actual public Three state into this descriptor, submit a rich request to Rust, then lower the returned plan; structural serialization alone is not that cutover. +Render-binding identity is now independent from shaping-font identity. A registered binding handle names one loaded +font/technique/resource combination and points to the retained shaping handle it shares; font stacks contain binding +handles. Fallback carries both identities through the shape and cluster arenas, metrics/extents use the shaping handle, +and policy gather uses the binding handle. This makes same-face multi-technique registration and mixed-technique +fallback representable without retaining the SFNT twice. Compiled Wasm proves Inter missing Devanagari advances to a +second binding and emits that binding's different technique in the Rust plan. The cost is one additional retained `u32` +cluster lane. On the unchanged 25,515-positioned/21,805-renderable workload, 8-warmup/31-sample column-resize medians +are 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug versus 4.120/4.646/5.622 ms at D-203; 6–7% RSD and lower run minima do +not establish a regression. Optimized Wasm is 1,070,580 raw / 402,114 gzip / 319,662 Brotli bytes, +607 / -3,774 / +104 +bytes from D-203. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index 34f6827e..35e79bd1 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -25,6 +25,7 @@ pub(crate) struct ClusterArena { pub flags: Vec, pub style_indexes: Vec, pub source_runs: Vec, + pub binding_handles: Vec, pub font_handles: Vec, pub stable_ids: Vec, pub glyph_starts: Vec, @@ -53,6 +54,7 @@ impl ClusterArena { reserve(&mut self.flags, capacity)?; reserve(&mut self.style_indexes, capacity)?; reserve(&mut self.source_runs, capacity)?; + reserve(&mut self.binding_handles, capacity)?; reserve(&mut self.font_handles, capacity)?; reserve(&mut self.stable_ids, capacity)?; reserve(&mut self.glyph_starts, capacity)?; @@ -116,6 +118,7 @@ impl ClusterArena { self.style_indexes .push(u32::try_from(style_index).map_err(|_| EngineError::ResultTooLarge)?); self.source_runs.push(NO_SOURCE_RUN); + self.binding_handles.push(0); self.font_handles.push(0); self.stable_ids.push( *text_unit_ids @@ -199,6 +202,7 @@ impl ClusterArena { self.flags.clear(); self.style_indexes.clear(); self.source_runs.clear(); + self.binding_handles.clear(); self.font_handles.clear(); self.stable_ids.clear(); self.glyph_starts.clear(); @@ -261,11 +265,14 @@ impl ClusterArena { .ok_or(EngineError::InvalidRequest)?; let cluster_index = self.cluster_at(cluster)?; let source_slot = &mut self.source_runs[cluster_index]; + let binding_slot = &mut self.binding_handles[cluster_index]; let font_slot = &mut self.font_handles[cluster_index]; if *source_slot == NO_SOURCE_RUN { *source_slot = shaped_run.source_run; + *binding_slot = shaped_run.binding_handle; *font_slot = shaped_run.font_handle; } else if *source_slot != shaped_run.source_run + || *binding_slot != shaped_run.binding_handle || *font_slot != shaped_run.font_handle { return Err(EngineError::InvalidRequest); @@ -437,6 +444,7 @@ mod tests { let mut shape = ShapeArena { runs: vec![ShapedRun { source_run: 0, + binding_handle: 19, font_handle: 9, text_start: 0, text_end: 3, diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index 11b891f8..5316da2b 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -18,6 +18,7 @@ pub const DEFAULT_GATHER_RECORD_CAPACITY: usize = 32_768; pub struct LayoutGlyph { pub stable_id: u32, pub content_revision: u32, + pub binding_handle: u32, pub font_handle: u32, pub glyph_id: u32, pub semantic_id: u32, @@ -132,12 +133,12 @@ impl PolicyGatherWorkspace { let mut cached_program = None; for glyph_index in 0..input.glyphs.len() { let glyph = input.glyphs[glyph_index]; - let binding = if cached_font_handle == Some(glyph.font_handle) { + let binding = if cached_font_handle == Some(glyph.binding_handle) { cached_binding.ok_or(GatherError::FontBindingMissing)? } else { - let binding = - binding_for_font(glyph.font_handle).ok_or(GatherError::FontBindingMissing)?; - cached_font_handle = Some(glyph.font_handle); + let binding = binding_for_font(glyph.binding_handle) + .ok_or(GatherError::FontBindingMissing)?; + cached_font_handle = Some(glyph.binding_handle); cached_binding = Some(binding); binding }; @@ -723,6 +724,7 @@ mod tests { LayoutGlyph { stable_id, content_revision: 1, + binding_handle: 9, font_handle: 9, glyph_id, semantic_id: 1, diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index 776b1228..1af6d68f 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -234,6 +234,7 @@ impl PositionedGlyphArena { .ok_or(EngineError::InvalidRequest)? .style; let font_handle = clusters.font_handles[cluster]; + let binding_handle = clusters.binding_handles[cluster]; let metrics = metrics_for(font_handle).ok_or(EngineError::InvalidRequest)?; if font_handle == 0 || metrics.units_per_em == 0 { return Err(EngineError::InvalidRequest); @@ -296,6 +297,7 @@ impl PositionedGlyphArena { .get(adjacency) .ok_or(EngineError::InvalidRequest)?, content_revision: 0, + binding_handle, font_handle, glyph_id, semantic_id: clusters.stable_ids[cluster], @@ -424,6 +426,7 @@ impl PositionedGlyphArena { let old = previous.glyphs[previous_slot]; if next.stable_id != old.stable_id || next.font_handle != old.font_handle + || next.binding_handle != old.binding_handle || next.glyph_id != old.glyph_id || next.semantic_id != old.semantic_id || next.material_id != old.material_id @@ -710,6 +713,7 @@ mod tests { flags: vec![CLUSTER_SAFE_BEFORE, CLUSTER_SAFE_BEFORE, CLUSTER_HARD_BREAK], style_indexes: vec![0, 0, 0], source_runs: vec![0, 0, u32::MAX], + binding_handles: vec![11, 11, 0], font_handles: vec![1, 1, 0], stable_ids: vec![10, 20, 30], glyph_starts: vec![0, 1, 2], diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs index 8fe03271..e61ca24b 100644 --- a/packages/text/rust/shaper/src/engine/shaping_state.rs +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -28,6 +28,7 @@ pub(crate) struct ShapingRunArena { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ShapedRun { pub source_run: u32, + pub binding_handle: u32, pub font_handle: u32, pub text_start: u32, pub text_end: u32, @@ -239,6 +240,7 @@ impl ShapeArena { &mut self, source_run: usize, font_handle: u32, + binding_handle: u32, text_start: u32, text_end: u32, shaped: &harfrust::GlyphBuffer, @@ -251,6 +253,7 @@ impl ShapeArena { .map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?; self.runs.push(ShapedRun { source_run: u32::try_from(source_run).map_err(|_| crate::STATUS_RESULT_TOO_LARGE)?, + binding_handle, font_handle, text_start, text_end, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 134dc729..d0ed10b2 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -52,6 +52,7 @@ pub struct TextEngine { struct RegisteredFontBinding { handle: u32, + shaping_handle: u32, binding: FontRenderBinding, } @@ -66,6 +67,7 @@ struct FallbackSpan { text_start: u32, text_end: u32, font_index: u16, + binding_handle: u32, font_handle: u32, } @@ -153,10 +155,11 @@ impl TextEngine { pub fn register_font_binding( &mut self, handle: u32, + shaping_handle: u32, shaping_glyph_count: u32, binding: FontRenderBinding, ) -> Result<(), EngineError> { - if handle == 0 || binding.glyph_count() != shaping_glyph_count { + if handle == 0 || shaping_handle == 0 || binding.glyph_count() != shaping_glyph_count { return Err(EngineError::InvalidRequest); } if let Some(existing) = self @@ -164,7 +167,7 @@ impl TextEngine { .iter() .find(|registered| registered.handle == handle) { - return if existing.binding == binding { + return if existing.shaping_handle == shaping_handle && existing.binding == binding { Ok(()) } else { Err(EngineError::HandleConflict) @@ -173,8 +176,11 @@ impl TextEngine { self.font_bindings .try_reserve(1) .map_err(|_| EngineError::ResultTooLarge)?; - self.font_bindings - .push(RegisteredFontBinding { handle, binding }); + self.font_bindings.push(RegisteredFontBinding { + handle, + shaping_handle, + binding, + }); Ok(()) } @@ -195,6 +201,22 @@ impl TextEngine { .map(|binding| &binding.binding) } + fn registered_font_binding(&self, handle: u32) -> Option<&RegisteredFontBinding> { + self.font_bindings + .iter() + .find(|binding| binding.handle == handle) + } + + pub fn shaping_handle_for_binding(&self, handle: u32) -> Option { + self.registered_font_binding(handle) + .map(|binding| binding.shaping_handle) + } + + pub fn dispose_bindings_for_shaping_font(&mut self, shaping_handle: u32) { + self.font_bindings + .retain(|binding| binding.shaping_handle != shaping_handle); + } + pub fn font_binding_count(&self) -> u32 { self.font_bindings.len().try_into().unwrap_or(u32::MAX) } @@ -263,12 +285,21 @@ impl TextEngine { self.font_stacks.len().try_into().unwrap_or(u32::MAX) } - pub fn references_font(&self, handle: u32) -> bool { + pub fn references_binding(&self, handle: u32) -> bool { self.font_stacks .iter() .any(|stack| stack.fonts.contains(&handle)) } + pub fn references_shaping_font(&self, shaping_handle: u32) -> bool { + self.font_stacks.iter().any(|stack| { + stack + .fonts + .iter() + .any(|handle| self.shaping_handle_for_binding(*handle) == Some(shaping_handle)) + }) + } + pub fn register_policy( &mut self, handle: u32, @@ -531,7 +562,7 @@ impl TextEngine { return Err(error); } if let Some(shaper) = shaper.as_deref_mut() { - if let Err(error) = session.prepare_shape(shaper, font_stacks) { + if let Err(error) = session.prepare_shape(shaper, font_stacks, font_bindings) { session.abort_text(); session.abort_styles(); session.abort_unicode(); @@ -569,6 +600,7 @@ impl TextEngine { && let Err(error) = session.prepare_flow_layout( shaper, font_stacks, + font_bindings, request.limits.max_lines, request.limits.max_slots_per_band, ) @@ -1006,6 +1038,7 @@ impl EngineSession { &mut self, shaper: &mut ShaperRegistry, font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], ) -> Result<(), EngineError> { self.abort_shape(); if !self.shaping_runs_prepared { @@ -1025,7 +1058,8 @@ impl EngineSession { let mut max_stack_depth = 0usize; for (index, run) in runs.iter().copied().enumerate() { let stack = find_font_stack(font_stacks, run.style.font_stack_handle)?; - let font_handle = *stack.fonts.first().ok_or(EngineError::FontStackMissing)?; + let binding_handle = *stack.fonts.first().ok_or(EngineError::FontStackMissing)?; + let font_handle = find_font_binding(font_bindings, binding_handle)?.shaping_handle; max_stack_depth = max_stack_depth.max(stack.fonts.len()); push_fallback_span( &mut self.pending_fallback_spans, @@ -1034,6 +1068,7 @@ impl EngineSession { text_start: run.text_start, text_end: run.text_end, font_index: 0, + binding_handle, font_handle, }, )?; @@ -1063,6 +1098,7 @@ impl EngineSession { output.append( source_index, span.font_handle, + span.binding_handle, span.text_start, span.text_end, shaped, @@ -1094,7 +1130,7 @@ impl EngineSession { .font_stack_handle; let stack = find_font_stack(font_stacks, stack_handle)?; let next_font_index = span.font_index.checked_add(1); - let next_font = + let next_binding = next_font_index.and_then(|index| stack.fonts.get(usize::from(index)).copied()); let mut cursor = span.text_start; let mut record_index = cluster_index; @@ -1103,8 +1139,11 @@ impl EngineSession { break; } if record.missing - && let (Some(font_index), Some(font_handle)) = (next_font_index, next_font) + && let (Some(font_index), Some(binding_handle)) = + (next_font_index, next_binding) { + let font_handle = + find_font_binding(font_bindings, binding_handle)?.shaping_handle; let cluster_start = record.cluster.max(cursor); let cluster_end = self .fallback_cluster_scratch @@ -1135,6 +1174,7 @@ impl EngineSession { text_start: cluster_start, text_end: cluster_end, font_index, + binding_handle, font_handle, ..span }, @@ -1303,6 +1343,7 @@ impl EngineSession { &mut self, shaper: &ShaperRegistry, font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], max_lines: u32, max_slots_per_band: u32, ) -> Result<(), EngineError> { @@ -1335,6 +1376,12 @@ impl EngineSession { .binary_search_by_key(&stack_handle, |stack| stack.handle) .ok() .and_then(|index| font_stacks[index].fonts.first().copied()) + .and_then(|handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| binding.shaping_handle) + }) }, )?; self.flow_layout_prepared = true; @@ -1547,6 +1594,16 @@ fn find_font_stack( .ok_or(EngineError::FontStackMissing) } +fn find_font_binding( + font_bindings: &[RegisteredFontBinding], + handle: u32, +) -> Result<&RegisteredFontBinding, EngineError> { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .ok_or(EngineError::FontStackMissing) +} + fn push_fallback_span( spans: &mut Vec, span: FallbackSpan, @@ -1555,6 +1612,7 @@ fn push_fallback_span( && previous.source_run == span.source_run && previous.text_end == span.text_start && previous.font_index == span.font_index + && previous.binding_handle == span.binding_handle && previous.font_handle == span.font_handle { previous.text_end = span.text_end; @@ -1715,14 +1773,14 @@ mod tests { assert_eq!(engine.register_font_stack(7, &[9, 4, 12]), Ok(())); assert_eq!(engine.register_font_stack(7, &[9, 4, 12]), Ok(())); assert_eq!(engine.font_stack(7), Ok(&[9, 4, 12][..])); - assert!(engine.references_font(4)); + assert!(engine.references_binding(4)); assert_eq!(engine.font_stack_count(), 1); assert_eq!( engine.register_font_stack(7, &[9, 12]), Err(EngineError::HandleConflict) ); assert_eq!(engine.dispose_font_stack(7), Ok(())); - assert!(!engine.references_font(4)); + assert!(!engine.references_binding(4)); assert_eq!( engine.dispose_font_stack(7), Err(EngineError::FontStackMissing) @@ -1734,6 +1792,7 @@ mod tests { let shape = ShapeArena { runs: vec![crate::engine::shaping_state::ShapedRun { source_run: 7, + binding_handle: 11, font_handle: 11, text_start: 0, text_end: 6, @@ -1773,23 +1832,41 @@ mod tests { } #[test] - fn font_bindings_are_owned_once_per_font_and_match_shaping_coverage() { + fn binding_identity_is_distinct_from_shared_shaping_font_identity() { let mut engine = TextEngine::default(); let binding = render_binding(3, 7); assert_eq!( - engine.register_font_binding(11, 4, binding.clone()), + engine.register_font_binding(11, 101, 4, binding.clone()), Err(EngineError::InvalidRequest) ); - assert_eq!(engine.register_font_binding(11, 3, binding.clone()), Ok(())); - assert_eq!(engine.register_font_binding(11, 3, binding), Ok(())); + assert_eq!( + engine.register_font_binding(11, 101, 3, binding.clone()), + Ok(()) + ); + assert_eq!(engine.register_font_binding(11, 101, 3, binding), Ok(())); assert_eq!(engine.font_binding_count(), 1); assert_eq!(engine.font_binding(11).unwrap().technique(), TechniqueId(7)); assert_eq!( - engine.register_font_binding(11, 3, render_binding(3, 8)), + engine.register_font_binding(12, 101, 3, render_binding(3, 8)), + Ok(()) + ); + assert_eq!(engine.font_binding_count(), 2); + assert_eq!( + engine + .registered_font_binding(12) + .map(|binding| binding.shaping_handle), + Some(101) + ); + assert_eq!( + engine.register_font_binding(11, 101, 3, render_binding(3, 8)), + Err(EngineError::HandleConflict) + ); + assert_eq!( + engine.register_font_binding(11, 102, 3, render_binding(3, 7)), Err(EngineError::HandleConflict) ); engine.dispose_font_binding(11); - assert_eq!(engine.font_binding_count(), 0); + assert_eq!(engine.font_binding_count(), 1); } #[test] diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index fc87a0ec..258f4818 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -87,12 +87,12 @@ pub unsafe extern "C" fn pmndrs_text_shaper_register_font( #[unsafe(no_mangle)] pub extern "C" fn pmndrs_text_shaper_dispose_font(handle: u32) -> u32 { with_state(|state| { - if state.engine.references_font(handle) { + if state.engine.references_shaping_font(handle) { STATUS_FONT_IN_USE } else { let status = state.registry.dispose_font(handle); if status == STATUS_OK { - state.engine.dispose_font_binding(handle); + state.engine.dispose_bindings_for_shaping_font(handle); } status } @@ -133,7 +133,10 @@ pub unsafe extern "C" fn pmndrs_text_engine_register_font_stack( } for bytes in bytes.chunks_exact(4) { let font = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); - if !state.registry.contains_font(font) { + let Some(shaping_handle) = state.engine.shaping_handle_for_binding(font) else { + return crate::STATUS_FONT_MISSING; + }; + if !state.registry.contains_font(shaping_handle) { return crate::STATUS_FONT_MISSING; } fonts.push(font); @@ -161,11 +164,12 @@ pub extern "C" fn pmndrs_text_engine_font_stack_count() -> u32 { #[unsafe(no_mangle)] pub unsafe extern "C" fn pmndrs_text_engine_register_font_binding( handle: u32, + shaping_handle: u32, pointer: u32, length: u32, ) -> u32 { with_state(|state| { - let Some(glyph_count) = state.registry.glyph_count(handle) else { + let Some(glyph_count) = state.registry.glyph_count(shaping_handle) else { return crate::STATUS_FONT_MISSING; }; let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { @@ -177,7 +181,7 @@ pub unsafe extern "C" fn pmndrs_text_engine_register_font_binding( }; match state .engine - .register_font_binding(handle, glyph_count, binding) + .register_font_binding(handle, shaping_handle, glyph_count, binding) { Ok(()) => STATUS_OK, Err(error) => engine_status(error), diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index 1af6a929..d15de2ea 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -248,7 +248,7 @@ function registerFont() { function registerBinding() { const bytes = technique.bindingBytes; const pointer = copyIntoAllocation(memory, fn.allocate, bytes); - requireStatus(fn.registerFontBinding(fontHandle, pointer, bytes.byteLength), 'register font binding'); + requireStatus(fn.registerFontBinding(fontHandle, fontHandle, pointer, bytes.byteLength), 'register font binding'); fn.deallocate(pointer, bytes.byteLength); } diff --git a/packages/text/src/internal/text-engine-host.ts b/packages/text/src/internal/text-engine-host.ts index 25bc694d..3a181e82 100644 --- a/packages/text/src/internal/text-engine-host.ts +++ b/packages/text/src/internal/text-engine-host.ts @@ -64,11 +64,15 @@ export class TextEngineHost { this.#exports = runtimeShaperEngineExports(shaper); } - registerFontBinding(fontHandle: number, bytes: Uint8Array): void { + registerFontBinding(bindingHandle: number, shapingFontHandle: number, bytes: Uint8Array): void { this.#assertActive(); - uint32Handle(fontHandle, 'font handle'); + uint32Handle(bindingHandle, 'font binding handle'); + uint32Handle(shapingFontHandle, 'shaping font handle'); this.#withBytes(bytes, (pointer, length) => - requireStatus(this.#exports.registerFontBinding(fontHandle, pointer, length), 'register font binding'), + requireStatus( + this.#exports.registerFontBinding(bindingHandle, shapingFontHandle, pointer, length), + 'register font binding', + ), ); } diff --git a/packages/text/src/shaper.ts b/packages/text/src/shaper.ts index 53f577fb..477747b9 100644 --- a/packages/text/src/shaper.ts +++ b/packages/text/src/shaper.ts @@ -234,7 +234,12 @@ interface ShaperExports { readonly analyzeBidi: (pointer: number, length: number) => number; readonly resultPointer: () => number; readonly resultLength: () => number; - readonly registerFontBinding: (fontHandle: number, pointer: number, length: number) => number; + readonly registerFontBinding: ( + bindingHandle: number, + shapingFontHandle: number, + pointer: number, + length: number, + ) => number; readonly registerFontStack: (handle: number, pointer: number, count: number) => number; readonly disposeFontStack: (handle: number) => number; readonly registerPolicy: (handle: number, pointer: number, length: number) => number; diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 3e7efefa..9cc19832 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; import { createRuntimeShaper, FontRegistry } from '@pmndrs/text'; import { createFontBaker } from '@pmndrs/text-font-baker'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; -import { fontBindingBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; +import { fontBindingBytes, renderPolicyBytes, renderPolicyBytesFromPrograms } from '../support/engine-abi.mjs'; const fixtureDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); const shaperWasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); @@ -130,13 +130,18 @@ test('compiled Wasm retains ordered font stacks and prevents dangling font dispo }); const binding = copyToWasm(memory, fn.allocate, bindingBytes); assert.equal(fn.fontBindingCount(), 0); - assert.equal(fn.registerFontBinding(101, binding.pointer, binding.length), abi.status.ok); - assert.equal(fn.registerFontBinding(101, binding.pointer, binding.length), abi.status.ok); + assert.equal(fn.registerFontBinding(101, 101, binding.pointer, binding.length), abi.status.ok); + assert.equal(fn.registerFontBinding(101, 101, binding.pointer, binding.length), abi.status.ok); assert.equal(fn.fontBindingCount(), 1); new DataView(memory.buffer).setUint32(binding.pointer + abi.layouts.fontBindingRequest.techniqueId, 2, true); - assert.equal(fn.registerFontBinding(101, binding.pointer, binding.length), abi.status.policyConflict); + assert.equal(fn.registerFontBinding(101, 101, binding.pointer, binding.length), abi.status.policyConflict); + assert.equal( + fn.registerFontBinding(102, 101, binding.pointer, binding.length), + abi.status.ok, + 'one shaping font may carry another independently selectable raster binding', + ); fn.deallocate(binding.pointer, binding.length); - assert.equal(fn.fontBindingCount(), 1, 'binding state must not borrow the registration allocation'); + assert.equal(fn.fontBindingCount(), 2, 'binding state must not borrow the registration allocation'); const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(101, 0, 0, 0)); assert.equal(fn.registerFontStack(17, stack.pointer, 1), abi.status.ok); @@ -239,27 +244,39 @@ test('text_update advances missing clusters through an ordered font stack', asyn assert.equal(fn.initialize(), abi.status.ok); registerValidatedFont({ abi, fn, memory }, 101, inter); registerValidatedFont({ abi, fn, memory }, 202, devanagari); + registerSimpleBinding({ abi, fn, memory }, 1001, 101, inter, 71, 1); + registerSimpleBinding({ abi, fn, memory }, 1002, 202, devanagari, 72, 2); - const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(101, 0, 0, 0, 202, 0, 0, 0)); + const stack = copyToWasm(memory, fn.allocate, Uint8Array.of(0xe9, 3, 0, 0, 0xea, 3, 0, 0)); assert.equal(fn.registerFontStack(17, stack.pointer, 2), abi.status.ok); fn.deallocate(stack.pointer, stack.length); - const policyBytes = renderPolicyBytes(abi); + const policyBytes = twoTechniquePolicyBytes(abi); const policy = copyToWasm(memory, fn.allocate, policyBytes); assert.equal(fn.registerPolicy(23, policy.pointer, policy.length), abi.status.ok); fn.deallocate(policy.pointer, policy.length); - assert.equal(fn.createSession(29, 512, abi.layouts.engineResult.size, 0), abi.status.ok); + assert.equal(fn.createSession(29, 2048, 64 * 1024, 0), abi.status.ok); const update = engineStyleUpdateBytes(abi, { sessionId: 29, policyHandle: 23, fontStackHandle: 17, text: [0x0915], + geometry: true, }); const requestPointer = fn.requestPointer(29); new Uint8Array(memory.buffer, requestPointer, update.byteLength).set(update); const resultPointer = fn.textUpdate(29, requestPointer, update.byteLength); const result = new DataView(memory.buffer, resultPointer, abi.layouts.engineResult.size); assert.equal(result.getUint32(abi.layouts.engineResult.status, true), abi.status.ok); + const primitivesOffset = result.getUint32(abi.layouts.engineResult.primitivesOffset, true); + assert.equal( + new DataView(memory.buffer).getUint32( + resultPointer + primitivesOffset + abi.layouts.enginePrimitive.techniqueId, + true, + ), + 2, + 'the fallback glyph must retain its own raster technique in the Rust render plan', + ); assert.equal( fn.planCount(), 2, @@ -308,6 +325,39 @@ function registerValidatedFont({ abi, fn, memory }, handle, validated) { for (const allocation of allocations) fn.deallocate(allocation.pointer, allocation.length); } +function registerSimpleBinding({ abi, fn, memory }, bindingHandle, shapingHandle, validated, resourceId, techniqueId) { + const glyphCount = validated.glyphExtents.byteLength / 8; + const bytes = fontBindingBytes(abi, { + techniqueId, + glyphCount, + strikes: [0], + resources: [{ id: resourceId, generation: 1, kind: 1, reference: resourceId }], + resourceIndices: new Array(glyphCount).fill(0), + glyphF32: [new Array(glyphCount).fill(1)], + }); + const allocation = copyToWasm(memory, fn.allocate, bytes); + assert.equal( + fn.registerFontBinding(bindingHandle, shapingHandle, allocation.pointer, allocation.length), + abi.status.ok, + ); + fn.deallocate(allocation.pointer, allocation.length); +} + +function twoTechniquePolicyBytes(abi) { + const program = (techniqueId, programId) => ({ + techniqueId, + programId, + f32InputCount: 1, + u32InputCount: 0, + buffers: [{ id: 1, scalar: abi.policy.scalarTypes.f32, vectorWidth: 1 }], + operations: [ + { opcode: abi.policy.opcodes.loadF32, target: 0, operand0: 0 }, + { opcode: abi.policy.opcodes.storeF32, operand0: 0, immediate0: 1 }, + ], + }); + return renderPolicyBytesFromPrograms(abi, [program(1, 1), program(2, 2)]); +} + function engineStyleUpdateBytes( abi, { From f18f53cc17a31c62c41a2c62826453ff1bb9a463 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 20:52:15 -0400 Subject: [PATCH 054/128] feat(text): own render engine in Three --- docs/log.md | 7 + docs/packages/text.md | 13 +- docs/planning/rust-layout-engine.md | 7 + .../text/src/internal/render-policy-wire.ts | 8 +- .../text/src/internal/text-engine-host.ts | 8 ++ packages/text/src/three/engine-runtime.ts | 123 ++++++++++++++++++ .../integration/three-engine-runtime.test.mjs | 105 +++++++++++++++ 7 files changed, 266 insertions(+), 5 deletions(-) create mode 100644 packages/text/src/three/engine-runtime.ts create mode 100644 packages/text/tests/integration/three-engine-runtime.test.mjs diff --git a/docs/log.md b/docs/log.md index 975dbb05..9388291e 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Added a lazy Three-owned engine coordinator** — The renderer-neutral runtime does not statically import first-party + raster programs. On first Three use, a runtime-scoped coordinator registers the all-technique policy, compiles loaded + font bindings, allocates session handles, and reference-counts exact ordered font-stack handles. A real Inter fixture + binds Bitmap and MTSDF to the same retained shaping font, proves identical stack acquisition shares one handle, + reversed fallback order does not, last release retires the stack, and retired handles are not immediately reused. The + coordinator remains outside the public Three graph until batch/session render-plan consumption lands. + - **Separated shaping-font identity from render-binding identity** — Rust font stacks now contain loaded-font binding handles, and each binding names its shared shaping-font handle. Shaping, metrics, and extents continue through the retained font once; policy gather follows the selected binding, so the same face may carry multiple raster techniques diff --git a/docs/packages/text.md b/docs/packages/text.md index e9cb2ce3..976fc443 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:d75f5786f818b1e6db813d28197024af3a3fb6bede937e94712c141547c287fe' +source_digest: 'sha256:d7194a0ca57aa340e386cefe1b7fbf553b7e38cc75c6646b17130a8e83f38ead' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -131,6 +131,9 @@ sources: - id: engine-frame-wire resource: ../../packages/text/src/internal/engine-frame-wire.ts title: Complete retained-frame request compiler + - id: three-engine-runtime + resource: ../../packages/text/src/three/engine-runtime.ts + title: Lazy Three engine coordinator - id: raster-validation resource: ../../packages/text/src/internal/raster-artifact-validation.ts title: Shared standalone raster artifact validation @@ -761,6 +764,14 @@ render plan. Compiled-Wasm tests prove both cases. The additional retained `u32` 4.217/4.791/5.633 milliseconds for Bitmap/MTSDF/Slug; their 6–7% RSD does not distinguish the small movement from the preceding 4.120/4.646/5.622-millisecond checkpoint. +First-party engine ownership remains in the Three integration rather than `TextRuntime`, preserving the +renderer-neutral core graph. A lazy runtime-scoped coordinator registers the complete Three policy once, assigns +binding and session handles, and reference-counts ordered stack handles. Exact binding-handle sequences share a stack; +reversed fallback order does not. Last release disposes the Rust stack, and monotonic allocation avoids immediately +reusing a retired identity. A real-fixture integration binds Bitmap and MTSDF to one retained Inter shaping font and +proves these lifecycle rules. This coordinator is not imported by the public Three entry until the batch/session +render-plan cutover, so this checkpoint alone changes no shipping entry graph. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 1c6f7d52..2e1f71c7 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1225,6 +1225,13 @@ are 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug versus 4.120/4.646/5.622 ms at D- not establish a regression. Optimized Wasm is 1,070,580 raw / 402,114 gzip / 319,662 Brotli bytes, +607 / -3,774 / +104 bytes from D-203. +The production Three ownership layer is lazy and runtime-scoped. It keeps the all-technique policy and first-party +binding compilers out of renderer-neutral `TextRuntime`, allocates monotonic binding/stack/session handles, and +reference-counts exact ordered stack sequences. Last release disposes the Rust stack; reverse fallback order has a +distinct identity; retired handles are not immediately reused. A real Inter fixture registers Bitmap and MTSDF against +one shaping handle and proves the lifecycle in compiled Wasm. The coordinator is not yet referenced by the public Three +entry, so this slice establishes cold ownership without claiming the batch/session cutover or a shipping graph change. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index 687b2cfa..e4a45294 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -152,7 +152,7 @@ function bitmapProgram(techniqueId: number, programId: number): PolicyProgram { [4, [13, 14]], [5, [3, 4, 5, 6]], ]); - return program(techniqueId, programId, context, floatBuffers([2, 2, 2, 2, 4])); + return createProgram(techniqueId, programId, context, floatBuffers([2, 2, 2, 2, 4])); } function msdfProgram(techniqueId: number, programId: number): PolicyProgram { @@ -177,7 +177,7 @@ function msdfProgram(techniqueId: number, programId: number): PolicyProgram { [6, [25, 25, 25, 25]], [7, [25, 25, 25, 24]], ]); - return program(techniqueId, programId, context, floatBuffers([4, 4, 4, 4, 4, 4, 4])); + return createProgram(techniqueId, programId, context, floatBuffers([4, 4, 4, 4, 4, 4, 4])); } function slugProgram(techniqueId: number, programId: number): PolicyProgram { @@ -204,7 +204,7 @@ function slugProgram(techniqueId: number, programId: number): PolicyProgram { [6, [21, 22, 23, 24]], [7, [25, 26, 29, 29]], ]); - return program(techniqueId, programId, context, [...floatBuffers([4, 4, 4, 4, 4]), ...u32Buffers([4, 4], 6)]); + return createProgram(techniqueId, programId, context, [...floatBuffers([4, 4, 4, 4, 4]), ...u32Buffers([4, 4], 6)]); } interface ProgramContext { @@ -287,7 +287,7 @@ function programContext( }; } -function program( +function createProgram( techniqueId: number, programId: number, context: ProgramContext, diff --git a/packages/text/src/internal/text-engine-host.ts b/packages/text/src/internal/text-engine-host.ts index 3a181e82..3424e6e1 100644 --- a/packages/text/src/internal/text-engine-host.ts +++ b/packages/text/src/internal/text-engine-host.ts @@ -91,6 +91,14 @@ export class TextEngineHost { this.#fontStacks.add(handle); } + disposeFontStack(handle: number): void { + this.#assertActive(); + uint32Handle(handle, 'font stack handle'); + if (!this.#fontStacks.has(handle)) throw new Error(`font stack ${handle} is not owned by this text engine host`); + requireStatus(this.#exports.disposeFontStack(handle), 'dispose font stack'); + this.#fontStacks.delete(handle); + } + registerPolicy(handle: number, bytes: Uint8Array): void { this.#assertActive(); uint32Handle(handle, 'policy handle'); diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts new file mode 100644 index 00000000..3d31d032 --- /dev/null +++ b/packages/text/src/three/engine-runtime.ts @@ -0,0 +1,123 @@ +import type { LoadedFont } from '../loaded-font.js'; +import type { AnyRasterTechnique } from '../raster-technique.js'; +import type { TextRuntime } from '../text-runtime.js'; +import { firstPartyFontBindingBytes } from '../internal/font-binding-wire.js'; +import { firstPartyThreeRenderPolicyBytes } from '../internal/render-policy-wire.js'; +import { TextEngineHost, type TextEngineSession, type TextEngineSessionOptions } from '../internal/text-engine-host.js'; + +const POLICY_HANDLE = 1; +const MAX_U32 = 0xffff_ffff; +const coordinators = new WeakMap(); + +export interface ThreeTextEngineStackLease { + readonly handle: number; + release(): void; +} + +interface RetainedStack { + readonly handle: number; + references: number; +} + +/** Three-owned cold registrations shared by every text batch using one renderer-neutral runtime. */ +export class ThreeTextEngineCoordinator { + readonly host: TextEngineHost; + readonly #bindingHandles = new WeakMap, number>(); + readonly #stacks = new Map(); + #nextBindingHandle = 1; + #nextStackHandle = 1; + #nextSessionHandle = 1; + #disposed = false; + + constructor(runtime: Pick) { + this.host = new TextEngineHost(runtime.shaper); + this.host.registerPolicy(POLICY_HANDLE, firstPartyThreeRenderPolicyBytes(this.host.wireIdentities)); + } + + get policyHandle(): number { + return POLICY_HANDLE; + } + + acquireFontStack( + fonts: readonly [LoadedFont, ...LoadedFont[]], + ): ThreeTextEngineStackLease { + this.#assertActive(); + const bindingHandles = fonts.map((font) => this.#bindingHandle(font)); + const key = bindingHandles.join(','); + let retained = this.#stacks.get(key); + if (retained === undefined) { + retained = { handle: this.#allocateStackHandle(), references: 0 }; + this.host.registerFontStack(retained.handle, bindingHandles); + this.#stacks.set(key, retained); + } + retained.references += 1; + let released = false; + return { + handle: retained.handle, + release: () => { + if (released) return; + released = true; + retained.references -= 1; + if (retained.references !== 0) return; + this.#stacks.delete(key); + this.host.disposeFontStack(retained.handle); + }, + }; + } + + createSession(options: Omit): TextEngineSession { + this.#assertActive(); + return this.host.createSession({ ...options, handle: this.#allocateSessionHandle() }); + } + + dispose(): void { + if (this.#disposed) return; + this.host.dispose(); + this.#stacks.clear(); + this.#disposed = true; + } + + #bindingHandle(font: LoadedFont): number { + if (font.disposed) throw new TypeError('cannot register a disposed loaded font with the Three text engine'); + const existing = this.#bindingHandles.get(font); + if (existing !== undefined) return existing; + const handle = this.#allocateBindingHandle(); + this.host.registerFontBinding(handle, font.font.handle, firstPartyFontBindingBytes(font, this.host.wireIdentities)); + this.#bindingHandles.set(font, handle); + return handle; + } + + #allocateBindingHandle(): number { + return allocateHandle(this.#nextBindingHandle, (next) => (this.#nextBindingHandle = next), 'font binding'); + } + + #allocateStackHandle(): number { + return allocateHandle(this.#nextStackHandle, (next) => (this.#nextStackHandle = next), 'font stack'); + } + + #allocateSessionHandle(): number { + return allocateHandle(this.#nextSessionHandle, (next) => (this.#nextSessionHandle = next), 'text session'); + } + + #assertActive(): void { + if (this.#disposed) throw new Error('Three text engine coordinator is disposed'); + } +} + +/** Resolve the lazy Three-owned coordinator without pulling renderer policies into the core runtime graph. */ +export function threeTextEngineCoordinator(runtime: TextRuntime): ThreeTextEngineCoordinator { + let coordinator = coordinators.get(runtime); + if (coordinator === undefined) { + coordinator = new ThreeTextEngineCoordinator(runtime); + coordinators.set(runtime, coordinator); + } + return coordinator; +} + +function allocateHandle(current: number, setNext: (next: number) => void, label: string): number { + if (!Number.isSafeInteger(current) || current <= 0 || current > MAX_U32) { + throw new RangeError(`${label} handles are exhausted`); + } + setNext(current === MAX_U32 ? MAX_U32 + 1 : current + 1); + return current; +} diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs new file mode 100644 index 00000000..99963c87 --- /dev/null +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; +import { gunzipSync } from 'node:zlib'; + +import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; + +import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; +import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; +import { FontRegistry } from '../../dist/loader.js'; +import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; +import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; +import { defineRasterResourceId } from '../../dist/raster-technique.js'; +import { createRuntimeShaper } from '../../dist/shaper.js'; +import { ThreeTextEngineCoordinator } from '../../dist/three/engine-runtime.js'; + +const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', import.meta.url); +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); + +test('Three coordinator shares shaping data across technique bindings and reference-counts stack handles', async () => { + const [bitmapBytes, compressedMsdf, wasm] = await Promise.all([ + readFile(new URL('inter-bitmap-16.font.glb', fixtureRoot)), + readFile(new URL('inter-mtsdf.font.glb.gz', fixtureRoot)), + readFile(wasmUrl), + ]); + const msdfBytes = gunzipSync(compressedMsdf); + const [bitmapCore, msdfCore] = await Promise.all([ + validateFontArtifact(bitmapBytes), + validateFontArtifact(msdfBytes), + ]); + assert.equal(bitmapCore.shapingHash, msdfCore.shapingHash); + const registry = new FontRegistry(); + const registered = await registry.registerAsset(bitmapBytes); + const shaper = await createRuntimeShaper({ registry, wasm }); + shaper.registerFont(registered); + const bitmapRaster = await validateBitmapArtifact(bitmapBytes, { + descriptor: bitmapDescriptor({ strikes: [16] }), + rasterKey: bitmapCore.document.extensions.PMNDRS_font.rasters[0].rasterKey, + shapingHash: bitmapCore.shapingHash, + glyphCount: bitmapCore.glyphCount, + glyphIdWidth: 16, + }); + const msdfRaster = await validateMsdfArtifact(msdfBytes, { + descriptor: msdfDescriptor(), + rasterKey: msdfCore.document.extensions.PMNDRS_font.rasters[0].rasterKey, + shapingHash: msdfCore.shapingHash, + glyphCount: msdfCore.glyphCount, + glyphIdWidth: 16, + }); + const bitmapFont = { + runtime: undefined, + font: registered, + technique: bitmap, + raster: undefined, + data: { + strikes: bitmapRaster.strikes.map((strike, strikeIndex) => ({ + ...strike, + pages: strike.pages.map((page, pageIndex) => ({ + ...page, + format: 'r8unorm', + resource: defineRasterResourceId(`coordinator.bitmap.${strikeIndex}.${pageIndex}`), + })), + bindings: [], + })), + }, + disposed: false, + }; + const extension = msdfRaster.document.extensions.PMNDRS_font_distance_field; + const msdfFont = { + runtime: undefined, + font: registered, + technique: msdf, + raster: undefined, + data: { + resource: defineRasterResourceId('coordinator.mtsdf'), + binding: {}, + emSize: extension.emSize, + pixelRange: extension.pixelRange, + planeUnitsPerEm: extension.planeUnitsPerEm, + records: msdfRaster.records, + pages: msdfRaster.pages, + }, + disposed: false, + }; + const coordinator = new ThreeTextEngineCoordinator({ shaper }); + const first = coordinator.acquireFontStack([bitmapFont, msdfFont]); + const shared = coordinator.acquireFontStack([bitmapFont, msdfFont]); + const reversed = coordinator.acquireFontStack([msdfFont, bitmapFont]); + assert.equal(shared.handle, first.handle); + assert.notEqual(reversed.handle, first.handle, 'fallback order is part of stack identity'); + first.release(); + first.release(); + const stillShared = coordinator.acquireFontStack([bitmapFont, msdfFont]); + assert.equal(stillShared.handle, shared.handle, 'one outstanding lease must retain the stack'); + shared.release(); + stillShared.release(); + const replacement = coordinator.acquireFontStack([bitmapFont, msdfFont]); + assert.notEqual(replacement.handle, first.handle, 'a retired stack handle is not immediately reused'); + replacement.release(); + reversed.release(); + coordinator.dispose(); + assert.throws(() => coordinator.acquireFontStack([bitmapFont]), /disposed/); + shaper.dispose(); + registered.dispose(); +}); From 84170bc4254f45d76f50181438c8f93c1107b1eb Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 21:03:12 -0400 Subject: [PATCH 055/128] feat(text): read render plans in place --- docs/log.md | 6 + docs/packages/text.md | 12 +- docs/planning/rust-layout-engine.md | 6 + .../text/src/internal/render-plan-view.ts | 152 ++++++++++++++++++ .../integration/three-engine-runtime.test.mjs | 90 +++++++++++ 5 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 packages/text/src/internal/render-plan-view.ts diff --git a/docs/log.md b/docs/log.md index 9388291e..e0350c41 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-08 +- **Bound Three plan consumption directly to Wasm publication memory** — A reusable package-internal reader validates + every Rust-emitted render-plan table and reads its fixed records in place. It retains one `DataView` across ordinary + A/B publications and replaces it only after `memory.grow()`, so it does not materialize per-glyph JavaScript objects. + A real compiled-Wasm Three fixture now shapes and lays out Inter in one update and observes nonempty resource, buffer, + patch, primitive, and draw tables through that reader. GPU resource realization remains the next cutover slice. + - **Added a lazy Three-owned engine coordinator** — The renderer-neutral runtime does not statically import first-party raster programs. On first Three use, a runtime-scoped coordinator registers the all-technique policy, compiles loaded font bindings, allocates session handles, and reference-counts exact ordered font-stack handles. A real Inter fixture diff --git a/docs/packages/text.md b/docs/packages/text.md index 976fc443..961dabb1 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:d7194a0ca57aa340e386cefe1b7fbf553b7e38cc75c6646b17130a8e83f38ead' +source_digest: 'sha256:b5fd776878341789eab7d0278040cd126f038f96c47f98a28b47de8d73f4747b' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -128,6 +128,9 @@ sources: - id: text-engine-host resource: ../../packages/text/src/internal/text-engine-host.ts title: Retained frame host + - id: render-plan-view + resource: ../../packages/text/src/internal/render-plan-view.ts + title: Zero-copy render-plan reader - id: engine-frame-wire resource: ../../packages/text/src/internal/engine-frame-wire.ts title: Complete retained-frame request compiler @@ -772,6 +775,13 @@ reusing a retired identity. A real-fixture integration binds Bitmap and MTSDF to proves these lifecycle rules. This coordinator is not imported by the public Three entry until the batch/session render-plan cutover, so this checkpoint alone changes no shipping entry graph. +Three render-plan consumption begins with a reusable validated view over the borrowed Wasm publication. The view reads +fixed Rust resource, buffer, patch, primitive, and draw records without converting glyphs or tables into JavaScript +objects. Its `DataView` spans the complete Wasm memory and is replaced only when the memory buffer identity changes; +ordinary A/B publication swaps update only the base offset. A real compiled-Wasm fixture shapes and lays out Inter in +one frame and proves every renderer table is nonempty and directly addressable. GPU buffer and draw realization remain +open, so this checkpoint claims neither public Three cutover nor end-to-end rendering performance. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 2e1f71c7..3f34481d 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1232,6 +1232,12 @@ distinct identity; retired handles are not immediately reused. A real Inter fixt one shaping handle and proves the lifecycle in compiled Wasm. The coordinator is not yet referenced by the public Three entry, so this slice establishes cold ownership without claiming the batch/session cutover or a shipping graph change. +The first consumption slice retains a validated plan view instead of decoding Rust records into host objects. One +`DataView` covers the Wasm memory buffer and survives normal A/B slot changes; only `memory.grow()` replaces it. Table +offsets, counts, strides, alignment, and publication bounds are validated once per publication, after which the Three +lowerer can apply patches and draws by fixed offsets. A real compiled-Wasm fixture produces nonempty resource, buffer, +patch, primitive, and draw tables through this view. GPU realization and the public Three import remain open. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/src/internal/render-plan-view.ts b/packages/text/src/internal/render-plan-view.ts new file mode 100644 index 00000000..dc972756 --- /dev/null +++ b/packages/text/src/internal/render-plan-view.ts @@ -0,0 +1,152 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { TextEnginePublication } from './text-engine-host.js'; + +export interface RenderPlanTable { + readonly offset: number; + readonly count: number; + readonly stride: number; +} + +type TableName = + | 'semantics' + | 'resources' + | 'buffers' + | 'patches' + | 'primitives' + | 'draws' + | 'retirements' + | 'diagnostics'; + +const resultLayout = textShaperAbi.layouts.engineResult; +const tableLayouts = { + semantics: { + offset: resultLayout.semanticsOffset, + count: resultLayout.semanticsCount, + record: textShaperAbi.layouts.engineSemantic, + }, + resources: { + offset: resultLayout.resourcesOffset, + count: resultLayout.resourceCount, + record: textShaperAbi.layouts.engineResource, + }, + buffers: { + offset: resultLayout.buffersOffset, + count: resultLayout.bufferCount, + record: textShaperAbi.layouts.engineBuffer, + }, + patches: { + offset: resultLayout.patchesOffset, + count: resultLayout.patchCount, + record: textShaperAbi.layouts.enginePatch, + }, + primitives: { + offset: resultLayout.primitivesOffset, + count: resultLayout.primitiveCount, + record: textShaperAbi.layouts.enginePrimitive, + }, + draws: { offset: resultLayout.drawsOffset, count: resultLayout.drawCount, record: textShaperAbi.layouts.engineDraw }, + retirements: { + offset: resultLayout.retirementsOffset, + count: resultLayout.retirementCount, + record: textShaperAbi.layouts.engineRetirement, + }, + diagnostics: { + offset: resultLayout.diagnosticsOffset, + count: resultLayout.diagnosticCount, + record: textShaperAbi.layouts.engineDiagnostic, + }, +} as const; + +/** Reusable zero-copy reader over one borrowed Rust render-plan publication. */ +export class TextEngineRenderPlanView { + #memoryBuffer: ArrayBuffer | undefined; + #view: DataView | undefined; + #baseOffset = 0; + #byteLength = 0; + + bind(publication: TextEnginePublication): this { + const bytes = publication.bytes; + if (bytes.buffer !== publication.memoryBuffer) { + throw new TypeError('text-engine publication bytes do not belong to the reported Wasm memory'); + } + if (this.#memoryBuffer !== publication.memoryBuffer) { + this.#memoryBuffer = publication.memoryBuffer; + this.#view = new DataView(publication.memoryBuffer); + } + this.#baseOffset = bytes.byteOffset; + this.#byteLength = bytes.byteLength; + if (this.#byteLength < resultLayout.size || this.u32(resultLayout.byteLength) !== this.#byteLength) { + throw new RangeError('text-engine publication header has an invalid byte length'); + } + for (const name of Object.keys(tableLayouts) as TableName[]) this.table(name); + return this; + } + + table(name: TableName): RenderPlanTable { + const layout = tableLayouts[name]; + const offset = this.u32(layout.offset); + const count = this.u32(layout.count); + if (count === 0) { + if (offset !== 0) throw new RangeError(`empty text-engine ${name} table has a nonzero offset`); + return { offset: 0, count: 0, stride: layout.record.size }; + } + if (offset % layout.record.alignment !== 0) throw new RangeError(`text-engine ${name} table is misaligned`); + const byteLength = checkedProduct(count, layout.record.size, `${name} table`); + if (offset < resultLayout.size || offset + byteLength > this.#byteLength) { + throw new RangeError(`text-engine ${name} table is outside the publication`); + } + return { offset, count, stride: layout.record.size }; + } + + record(table: RenderPlanTable, index: number): number { + if (!Number.isSafeInteger(index) || index < 0 || index >= table.count) { + throw new RangeError('text-engine render-plan record index is outside its table'); + } + return table.offset + index * table.stride; + } + + u8(offset: number): number { + this.#assertRange(offset, 1); + return this.#view!.getUint8(this.#baseOffset + offset); + } + + u16(offset: number): number { + this.#assertRange(offset, 2); + return this.#view!.getUint16(this.#baseOffset + offset, true); + } + + u32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view!.getUint32(this.#baseOffset + offset, true); + } + + f32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view!.getFloat32(this.#baseOffset + offset, true); + } + + bytes(offset: number, byteLength: number): Uint8Array { + this.#assertRange(offset, byteLength); + return new Uint8Array(this.#memoryBuffer!, this.#baseOffset + offset, byteLength); + } + + #assertRange(offset: number, byteLength: number): void { + if ( + this.#view === undefined || + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(byteLength) || + offset < 0 || + byteLength < 0 || + offset + byteLength > this.#byteLength + ) { + throw new RangeError('text-engine render-plan read is outside the publication'); + } + } +} + +function checkedProduct(left: number, right: number, label: string): number { + const value = left * right; + if (!Number.isSafeInteger(value)) + throw new RangeError(`${label} byte length exceeds JavaScript's safe integer range`); + return value; +} diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 99963c87..7650cc0f 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -7,6 +7,8 @@ import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; +import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; +import { TextEngineRenderPlanView } from '../../dist/internal/render-plan-view.js'; import { FontRegistry } from '../../dist/loader.js'; import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; @@ -88,6 +90,94 @@ test('Three coordinator shares shaping data across technique bindings and refere const reversed = coordinator.acquireFontStack([msdfFont, bitmapFont]); assert.equal(shared.handle, first.handle); assert.notEqual(reversed.handle, first.handle, 'fallback order is part of stack identity'); + const session = coordinator.createSession({ requestCapacity: 4_096, resultCapacity: 1024 * 1024, textCapacity: 16 }); + const publication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + acknowledgedPublicationGeneration: 0, + limits: { + maxClusters: 16, + maxLines: 8, + maxRegions: 1, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + textMutations: [{ start: 0, deleteCount: 0, insert: 'abc' }], + styleMutations: [ + { + opcode: 'upsert', + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: 7, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + ], + constraints: [ + { + flowThreadId: 1, + geometryRevision: 1, + width: 256, + height: 128, + viewportBlockStart: 0, + viewportBlockEnd: 128, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 0, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'at-most', + heightMode: 'at-most', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + ], + regions: [ + { + id: 1, + geometryRevision: 1, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 256, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 256, + clipBlockEnd: 128, + }, + ], + }), + ); + const plan = new TextEngineRenderPlanView().bind(publication); + for (const name of ['resources', 'buffers', 'patches', 'primitives', 'draws']) { + assert.ok(plan.table(name).count > 0, `${name} must come from the Rust publication`); + } + const patches = plan.table('patches'); + const firstPatch = plan.record(patches, 0); + assert.ok(plan.u16(firstPatch) > 0); + assert.throws(() => plan.record(patches, patches.count), /outside its table/); + session.dispose(); first.release(); first.release(); const stillShared = coordinator.acquireFontStack([bitmapFont, msdfFont]); From 6c728be8b96cce52be184d2cdf06eb8efdf74d67 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 21:15:42 -0400 Subject: [PATCH 056/128] feat(text): gather batched paragraph plans --- docs/log.md | 9 ++ docs/packages/text.md | 12 ++- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 10 +++ .../rust/shaper/src/engine/policy_gather.rs | 84 ++++++++++++++++++- 5 files changed, 112 insertions(+), 4 deletions(-) diff --git a/docs/log.md b/docs/log.md index e0350c41..00bee165 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Preserved multi-paragraph Three batching in the Rust session design** — Existing `TextGroup` batches independent + paragraphs, while the current Rust session's multiple constraints all flow the same prose. The cutover therefore uses + one group/session containing stable-ID paragraph states and one shared planner/publication, rather than one Wasm call + and buffer set per `Text`. The policy gather workspace now appends independent positioned SoA inputs after one total + reservation, with an exact two-layout proof and no allocation inside append. Paragraph-keyed frame mutation and + transactional session state remain the next Rust slice. Adjacent rebuilt-Wasm Bitmap runs measured 4.083 ms before + and 4.078 ms after for full-column resize; that does not establish a speed change and does rule out a visible + regression in this run. Wasm changes by +105/+40/+252 raw/gzip/Brotli bytes. + - **Bound Three plan consumption directly to Wasm publication memory** — A reusable package-internal reader validates every Rust-emitted render-plan table and reads its fixed records in place. It retains one `DataView` across ordinary A/B publications and replaces it only after `memory.grow()`, so it does not materialize per-glyph JavaScript objects. diff --git a/docs/packages/text.md b/docs/packages/text.md index 961dabb1..47058807 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:b5fd776878341789eab7d0278040cd126f038f96c47f98a28b47de8d73f4747b' +source_digest: 'sha256:6b2f912f6c6b1e60d9ca2f562b6ee335054f48bdd55222d2eba9656d9907bdf6' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -782,6 +782,16 @@ ordinary A/B publication swaps update only the base offset. A real compiled-Wasm one frame and proves every renderer table is nonempty and directly addressable. GPU buffer and draw realization remain open, so this checkpoint claims neither public Three cutover nor end-to-end rendering performance. +The public Three batch semantics require multiple independent paragraphs in one Rust session and one plan publication. +Multiple flow constraints inside one paragraph remain sequential/alternate regions for that prose; they are not a +substitute for separate `Text` state. The Rust gather workspace now supports a one-reservation `begin` followed by +allocation-free appends of independent positioned SoA inputs. A focused test proves two layouts retain exact order and +semantic fields in the combined plan input. Paragraph-keyed wire records, retained child state, session-wide stable ID +allocation, and atomic shared-plan commit remain in progress; no per-text-session shortcut is shipped. Adjacent +8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and +6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / +319,914 raw/gzip/Brotli bytes, +105 / +40 / +252 from D-205. + The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index beef00a3..bf81a0b7 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -270,6 +270,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | | D-204 | The first production Three policy registers Bitmap, MTSDF, and Slug together. Technique, program, and resource identify storage; material, clip, depth, and order additionally identify draws, allowing material-directed draw splits without forcing duplicate physical storage. Public string technique and raster-resource identities lower to deterministic nonzero `u32` wire IDs with UTF-8 FNV-1a, and one runtime-scoped registry rejects collisions across the shared namespace before registration. First-party font bindings compile validated raster data directly into one field-major request allocation, preserve every bitmap strike, sort resources by wire ID, and remap record references accordingly. Exact real-fixture tests compare every production field against the established renderer-parity tables, and the combined policy registers in compiled Wasm. Three render-plan consumption and public third-party policy authoring remain open. | Accepted | | D-205 | A render-binding handle identifies one loaded font/technique/resource combination independently from its shaping-font handle. Rust font stacks contain binding handles; each binding names the retained shaping font whose SFNT, plans, metrics, and extents it reuses. Fallback and cluster state retain both identities: shaping and positioning use the shaping handle, while policy gather uses the binding handle. Compiled Wasm proves two techniques can bind one shaping font and an Inter-to-Devanagari fallback emits the second binding's distinct technique in one Rust plan. The additional retained `u32` cluster lane measures 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug resize at 8 warmups/31 samples; 6–7% RSD does not distinguish this from D-203's 4.120/4.646/5.622 ms. Optimized Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes, +607 / -3,774 / +104 bytes from D-203. | Accepted | +| D-206 | One Rust engine session corresponds to one renderer batch such as `TextGroup`, not one `Text`. A session retains multiple stable-ID paragraph states and publishes one shared render plan; paragraph mutations and removal are keyed explicitly, and each paragraph owns its text, styles, constraints, and sequential region flow. Glyph and content identities allocate from session-wide monotonic namespaces so one planner cannot alias equal paragraph-local ordinals. Rust appends each paragraph's positioned SoA directly into one pre-reserved policy-gather workspace; TypeScript never concatenates prose, merges glyph arrays, or makes one Wasm call per text. This preserves the accepted batch lifecycle and single frame crossing while keeping exclusions as one-call paragraph geometry. The append kernel has an exact two-layout allocation-free proof; paragraph-keyed wire/state integration remains in progress. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 3f34481d..433b9f90 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1238,6 +1238,16 @@ offsets, counts, strides, alignment, and publication bounds are validated once p lowerer can apply patches and draws by fixed offsets. A real compiled-Wasm fixture produces nonempty resource, buffer, patch, primitive, and draw tables through this view. GPU realization and the public Three import remain open. +The Three cutover must also preserve the existing batch meaning: one `TextGroup` contains multiple independent +paragraphs. Current Rust constraints describe multiple region flows over one retained prose stream, so assigning one +session to each `Text` would multiply boundary calls and physical buffers rather than preserve batching. One engine +session therefore retains paragraph-keyed child state and one shared planner/publication. Stable glyph/content IDs come +from session-wide monotonic namespaces, and Rust appends each child's positioned SoA into one pre-reserved gather +workspace. The append kernel is allocation-free after `begin(total_records)` and has an exact two-layout proof; +paragraph-keyed mutation/removal and atomic child commit remain the next implementation slice. Adjacent rebuilt-Wasm +Bitmap column-resize medians are 4.083 and 4.078 ms at 8 warmups/31 samples with 5.8%/6.1% RSD, so this slice makes no +speedup claim and shows no material regression. Optimized Wasm is 1,070,685 / 402,154 / 319,914 raw/gzip/Brotli bytes. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index 5316da2b..c2242b40 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -109,6 +109,34 @@ impl PolicyGatherWorkspace { } pub fn gather<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + force_all_inputs: bool, + binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result<(), GatherError> { + self.begin(policy, input.glyphs.len())?; + self.append( + policy, + capability_set, + input, + force_all_inputs, + binding_for_font, + ) + } + + pub fn begin( + &mut self, + policy: &ValidatedPolicy, + record_capacity: usize, + ) -> Result<(), GatherError> { + self.reserve_policy(policy, record_capacity)?; + self.clear(); + Ok(()) + } + + pub fn append<'binding>( &mut self, policy: &ValidatedPolicy, capability_set: CapabilitySetId, @@ -117,8 +145,20 @@ impl PolicyGatherWorkspace { mut binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, ) -> Result<(), GatherError> { validate_semantic_shape(input)?; - self.reserve_policy(policy, input.glyphs.len())?; - self.clear(); + let required = self.glyphs.len().saturating_add(input.glyphs.len()); + if self.glyphs.capacity() < required + || self.semantic_change_masks.capacity() < required + || self + .f32_fields + .iter() + .any(|field| field.capacity() < required) + || self + .u32_fields + .iter() + .any(|field| field.capacity() < required) + { + return Err(GatherError::AllocationFailed); + } let semantic_changes = if input.semantic_change_masks.len() == input.glyphs.len() { input .semantic_change_masks @@ -350,7 +390,6 @@ impl AlignedField { unsafe { core::slice::from_raw_parts(self.blocks.as_ptr().cast::(), self.len) } } - #[cfg(test)] fn capacity(&self) -> usize { self.blocks.capacity() * 4 } @@ -624,6 +663,45 @@ mod tests { assert_eq!(workspace.capacities(), capacities); } + #[test] + fn appends_independent_layouts_into_one_plan_input() { + let binding = binding(); + let policy = policy(); + let first = [layout_glyph(1, 0)]; + let second = [layout_glyph(2, 1)]; + let first_x = [10.0]; + let second_x = [20.0]; + let first_kind = [100]; + let second_kind = [200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace.begin(&policy, 2).unwrap(); + for input in [ + LayoutPlanInput { + glyphs: &first, + semantic_change_masks: &[], + semantic_f32: &[&first_x], + semantic_u32: &[&first_kind], + }, + LayoutPlanInput { + glyphs: &second, + semantic_change_masks: &[], + semantic_f32: &[&second_x], + semantic_u32: &[&second_kind], + }, + ] { + workspace + .append(&policy, CAPABILITY, input, true, |_| Some(&binding)) + .unwrap(); + } + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.glyphs.len(), 2); + assert_eq!(input.glyphs[0].stable_id, 1); + assert_eq!(input.glyphs[1].stable_id, 2); + assert_eq!(input.f32_fields[0], &[10.0, 20.0]); + assert_eq!(input.u32_fields[0], &[100, 200]); + } + #[test] fn changed_gather_reads_only_inputs_reaching_changed_buffers() { let binding = binding(); From a44e331db699923b1f4683dbacd24669b60948c3 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 21:33:29 -0400 Subject: [PATCH 057/128] feat(text): key frame records by paragraph --- docs/packages/text.md | 12 +- docs/planning/decision-register.md | 2 +- packages/text/rust/shaper/src/abi_contract.rs | 35 ++++-- .../shaper/src/engine/flow_composition.rs | 1 + packages/text/rust/shaper/src/engine/frame.rs | 1 + .../rust/shaper/src/engine/semantic_wire.rs | 84 +++++++++++-- packages/text/rust/shaper/src/engine/state.rs | 117 +++++++++++++++++- .../rust/shaper/src/engine/style_state.rs | 2 +- .../text/src/generated/text-shaper-abi.ts | 11 +- .../text/src/internal/engine-frame-wire.ts | 10 +- .../integration/engine-frame-wire.test.mjs | 11 +- .../integration/three-engine-runtime.test.mjs | 4 +- packages/text/tests/support/engine-abi.d.mts | 4 + packages/text/tests/support/engine-abi.mjs | 4 + 14 files changed, 271 insertions(+), 27 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index 47058807..5251e02a 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6b2f912f6c6b1e60d9ca2f562b6ee335054f48bdd55222d2eba9656d9907bdf6' +source_digest: 'sha256:4774636e57780e33b8013d420aa2b0af9f0be235428fd4d73fd1d13d9e403086' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -786,8 +786,14 @@ The public Three batch semantics require multiple independent paragraphs in one Multiple flow constraints inside one paragraph remain sequential/alternate regions for that prose; they are not a substitute for separate `Text` state. The Rust gather workspace now supports a one-reservation `begin` followed by allocation-free appends of independent positioned SoA inputs. A focused test proves two layouts retain exact order and -semantic fields in the combined plan input. Paragraph-keyed wire records, retained child state, session-wide stable ID -allocation, and atomic shared-plan commit remain in progress; no per-text-session shortcut is shipped. Adjacent +semantic fields in the combined plan input. Text, style, constraint, and inline-object records now carry an explicit +nonzero paragraph ID through the generated Rust/TypeScript ABI. The transitional single-paragraph session rejects mixed +IDs within a transaction and rejects rebinding across transactions, so keyed records cannot silently mutate one shared +state before the retained-child cutover. Text replacements reuse their former reserved word; style, constraint, and +inline-object records each grow by four bytes. The optimized Wasm measures 1,070,673 raw / 402,177 gzip / 319,722 +Brotli bytes, versus 1,070,685 / 402,154 / 319,914 at the preceding batching checkpoint; this does not establish a +material size change. Retained child state, session-wide stable ID allocation, and atomic shared-plan commit remain in +progress; no per-text-session shortcut is shipped. Adjacent 8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and 6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / 319,914 raw/gzip/Brotli bytes, +105 / +40 / +252 from D-205. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index bf81a0b7..915d0dea 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -270,7 +270,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | | D-204 | The first production Three policy registers Bitmap, MTSDF, and Slug together. Technique, program, and resource identify storage; material, clip, depth, and order additionally identify draws, allowing material-directed draw splits without forcing duplicate physical storage. Public string technique and raster-resource identities lower to deterministic nonzero `u32` wire IDs with UTF-8 FNV-1a, and one runtime-scoped registry rejects collisions across the shared namespace before registration. First-party font bindings compile validated raster data directly into one field-major request allocation, preserve every bitmap strike, sort resources by wire ID, and remap record references accordingly. Exact real-fixture tests compare every production field against the established renderer-parity tables, and the combined policy registers in compiled Wasm. Three render-plan consumption and public third-party policy authoring remain open. | Accepted | | D-205 | A render-binding handle identifies one loaded font/technique/resource combination independently from its shaping-font handle. Rust font stacks contain binding handles; each binding names the retained shaping font whose SFNT, plans, metrics, and extents it reuses. Fallback and cluster state retain both identities: shaping and positioning use the shaping handle, while policy gather uses the binding handle. Compiled Wasm proves two techniques can bind one shaping font and an Inter-to-Devanagari fallback emits the second binding's distinct technique in one Rust plan. The additional retained `u32` cluster lane measures 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug resize at 8 warmups/31 samples; 6–7% RSD does not distinguish this from D-203's 4.120/4.646/5.622 ms. Optimized Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes, +607 / -3,774 / +104 bytes from D-203. | Accepted | -| D-206 | One Rust engine session corresponds to one renderer batch such as `TextGroup`, not one `Text`. A session retains multiple stable-ID paragraph states and publishes one shared render plan; paragraph mutations and removal are keyed explicitly, and each paragraph owns its text, styles, constraints, and sequential region flow. Glyph and content identities allocate from session-wide monotonic namespaces so one planner cannot alias equal paragraph-local ordinals. Rust appends each paragraph's positioned SoA directly into one pre-reserved policy-gather workspace; TypeScript never concatenates prose, merges glyph arrays, or makes one Wasm call per text. This preserves the accepted batch lifecycle and single frame crossing while keeping exclusions as one-call paragraph geometry. The append kernel has an exact two-layout allocation-free proof; paragraph-keyed wire/state integration remains in progress. | Accepted | +| D-206 | One Rust engine session corresponds to one renderer batch such as `TextGroup`, not one `Text`. A session retains multiple stable-ID paragraph states and publishes one shared render plan; paragraph mutations and removal are keyed explicitly, and each paragraph owns its text, styles, constraints, and sequential region flow. Glyph and content identities allocate from session-wide monotonic namespaces so one planner cannot alias equal paragraph-local ordinals. Rust appends each paragraph's positioned SoA directly into one pre-reserved policy-gather workspace; TypeScript never concatenates prose, merges glyph arrays, or makes one Wasm call per text. This preserves the accepted batch lifecycle and single frame crossing while keeping exclusions as one-call paragraph geometry. The append kernel has an exact two-layout allocation-free proof. Text, style, constraint, and inline-object records now carry nonzero paragraph IDs through the generated ABI; the transitional single-child state rejects mixed IDs within a transaction and rebinding across transactions. The optimized Wasm is size-neutral within compression/code-layout noise at 1,070,673 raw / 402,177 gzip / 319,722 Brotli bytes versus 1,070,685 / 402,154 / 319,914 before the keyed wire. Retained child state and shared-plan commit remain in progress. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 0508019b..3ac8f5c9 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -252,7 +252,7 @@ struct EngineTextMutationRecord { delete_count: u32, insert_offset: u32, insert_count: u32, - reserved1: u32, + paragraph_id: u32, } #[repr(C)] @@ -283,6 +283,7 @@ struct EngineStyleMutationRecord { decoration_flags: u32, decoration_thickness: f32, decoration_offset: f32, + paragraph_id: u32, } #[repr(C)] @@ -306,6 +307,7 @@ struct EngineConstraintRecord { overflow: u8, block_align: u8, flags: u16, + paragraph_id: u32, } #[repr(C)] @@ -374,6 +376,7 @@ struct EngineInlineObjectRecord { baseline_alignment: u8, flags: u8, reserved0: u16, + paragraph_id: u32, } #[repr(C, align(16))] @@ -1167,9 +1170,9 @@ field_offset!( insert_count ); field_offset!( - ENGINE_TEXT_MUTATION_RESERVED1, + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, EngineTextMutationRecord, - reserved1 + paragraph_id ); field_offset!( ENGINE_STYLE_MUTATION_OPCODE, @@ -1301,6 +1304,11 @@ field_offset!( EngineStyleMutationRecord, decoration_offset ); +field_offset!( + ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + EngineStyleMutationRecord, + paragraph_id +); field_offset!( ENGINE_CONSTRAINT_FLOW_THREAD_ID, EngineConstraintRecord, @@ -1372,6 +1380,11 @@ field_offset!( block_align ); field_offset!(ENGINE_CONSTRAINT_FLAGS, EngineConstraintRecord, flags); +field_offset!( + ENGINE_CONSTRAINT_PARAGRAPH_ID, + EngineConstraintRecord, + paragraph_id +); field_offset!(ENGINE_FLOW_VERTEX_INLINE, EngineFlowVertexRecord, inline); field_offset!(ENGINE_FLOW_VERTEX_BLOCK, EngineFlowVertexRecord, block); field_offset!(ENGINE_REGION_ID, EngineRegionRecord, id); @@ -1548,6 +1561,11 @@ field_offset!( EngineInlineObjectRecord, reserved0 ); +field_offset!( + ENGINE_INLINE_OBJECT_PARAGRAPH_ID, + EngineInlineObjectRecord, + paragraph_id +); field_offset!(ENGINE_RESULT_ABI_VERSION, EngineResultHeader, abi_version); field_offset!(ENGINE_RESULT_BYTE_LENGTH, EngineResultHeader, byte_length); field_offset!(ENGINE_RESULT_STATUS, EngineResultHeader, status); @@ -2109,7 +2127,7 @@ pub fn json() -> String { "deleteCount": ENGINE_TEXT_MUTATION_DELETE_COUNT, "insertOffset": ENGINE_TEXT_MUTATION_INSERT_OFFSET, "insertCount": ENGINE_TEXT_MUTATION_INSERT_COUNT, - "reserved1": ENGINE_TEXT_MUTATION_RESERVED1 + "paragraphId": ENGINE_TEXT_MUTATION_PARAGRAPH_ID }, "engineStyleMutation": { "size": ENGINE_STYLE_MUTATION_RECORD_SIZE, @@ -2139,7 +2157,8 @@ pub fn json() -> String { "decorationRgba": ENGINE_STYLE_MUTATION_DECORATION_RGBA, "decorationFlags": ENGINE_STYLE_MUTATION_DECORATION_FLAGS, "decorationThickness": ENGINE_STYLE_MUTATION_DECORATION_THICKNESS, - "decorationOffset": ENGINE_STYLE_MUTATION_DECORATION_OFFSET + "decorationOffset": ENGINE_STYLE_MUTATION_DECORATION_OFFSET, + "paragraphId": ENGINE_STYLE_MUTATION_PARAGRAPH_ID }, "engineConstraint": { "size": ENGINE_CONSTRAINT_RECORD_SIZE, @@ -2162,7 +2181,8 @@ pub fn json() -> String { "align": ENGINE_CONSTRAINT_ALIGN, "overflow": ENGINE_CONSTRAINT_OVERFLOW, "blockAlign": ENGINE_CONSTRAINT_BLOCK_ALIGN, - "flags": ENGINE_CONSTRAINT_FLAGS + "flags": ENGINE_CONSTRAINT_FLAGS, + "paragraphId": ENGINE_CONSTRAINT_PARAGRAPH_ID }, "engineFlowVertex": { "size": ENGINE_FLOW_VERTEX_RECORD_SIZE, @@ -2230,7 +2250,8 @@ pub fn json() -> String { "marginBlockEnd": ENGINE_INLINE_OBJECT_MARGIN_BLOCK_END, "baselineAlignment": ENGINE_INLINE_OBJECT_BASELINE_ALIGNMENT, "flags": ENGINE_INLINE_OBJECT_FLAGS, - "reserved0": ENGINE_INLINE_OBJECT_RESERVED0 + "reserved0": ENGINE_INLINE_OBJECT_RESERVED0, + "paragraphId": ENGINE_INLINE_OBJECT_PARAGRAPH_ID }, "engineResult": { "size": ENGINE_RESULT_HEADER_SIZE, diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index 04615f2f..ac5083cc 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -573,6 +573,7 @@ mod tests { fn constraint() -> FlowConstraint { FlowConstraint { + paragraph_id: 1, flow_thread_id: 1, width: 10.0, height: 100.0, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index ca31085c..e7e7c31c 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -132,6 +132,7 @@ pub(crate) struct PreparedUpdate { pub(super) policy_handle: u32, pub(super) capability_set: u32, pub(super) policy_fingerprint: u64, + pub(super) paragraph_id: Option, } impl PreparedUpdate { diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 8b48f3d8..72a03f25 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -6,8 +6,8 @@ use crate::{ self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, - ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_RESERVED0, - ENGINE_TEXT_MUTATION_RESERVED1, ENGINE_TEXT_MUTATION_TEXT_START, + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_RESERVED0, ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, bidi::{DIRECTION_AUTO, DIRECTION_LTR, DIRECTION_RTL}, @@ -41,6 +41,7 @@ pub(crate) struct TextMutationBatch<'a> { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct TextMutation<'a> { + pub paragraph_id: u32, pub text_start: u32, pub delete_count: u32, pub insert_utf16_le: &'a [u8], @@ -54,12 +55,13 @@ pub(crate) struct StyleMutationBatch<'a> { #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) enum StyleMutation<'a> { - Remove { style_id: u32 }, + Remove { paragraph_id: u32, style_id: u32 }, Upsert(StyleValue<'a>), } #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct StyleValue<'a> { + pub paragraph_id: u32, pub style_id: u32, pub cascade_order: u32, pub field_mask: u32, @@ -96,6 +98,7 @@ pub(crate) struct GeometryBatch<'a> { #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct FlowConstraint { + pub paragraph_id: u32, pub flow_thread_id: u32, pub width: f32, pub height: f32, @@ -202,9 +205,24 @@ impl GeometryBatch<'_> { self.inline_objects.len() / abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize } + pub(crate) fn paragraph_id(self, index: usize) -> Option { + if index < self.constraint_count() { + let record = record_at(self.constraints, abi::ENGINE_CONSTRAINT_RECORD_SIZE, index)?; + return read_u32(record, abi::ENGINE_CONSTRAINT_PARAGRAPH_ID).ok(); + } + let inline_index = index.checked_sub(self.constraint_count())?; + let record = record_at( + self.inline_objects, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + inline_index, + )?; + read_u32(record, abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID).ok() + } + pub(crate) fn constraint(self, index: usize) -> Option { let record = record_at(self.constraints, abi::ENGINE_CONSTRAINT_RECORD_SIZE, index)?; Some(FlowConstraint { + paragraph_id: read_u32(record, abi::ENGINE_CONSTRAINT_PARAGRAPH_ID).ok()?, flow_thread_id: read_u32(record, abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID).ok()?, width: read_f32(record, abi::ENGINE_CONSTRAINT_WIDTH).ok()?, height: read_f32(record, abi::ENGINE_CONSTRAINT_HEIGHT).ok()?, @@ -373,12 +391,17 @@ impl<'a> TextMutationBatch<'a> { .ok()? }; Some(TextMutation { + paragraph_id: read_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID).ok()?, text_start: read_u32(record, ENGINE_TEXT_MUTATION_TEXT_START).ok()?, delete_count: read_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT).ok()?, insert_utf16_le, }) } + pub(crate) fn paragraph_id(self, index: usize) -> Option { + self.get(index).map(|mutation| mutation.paragraph_id) + } + pub(crate) fn validate_disjoint_geometry(self, geometry: GeometryBatch<'_>) -> Result<(), u32> { if !self.records.is_empty() && geometry.overlaps_range(byte_range(self.request, self.records)?)? @@ -433,8 +456,12 @@ impl<'a> StyleMutationBatch<'a> { let start = index.checked_mul(stride)?; let record = self.records.get(start..start.checked_add(stride)?)?; let style_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_STYLE_ID).ok()?; + let paragraph_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID).ok()?; if record[abi::ENGINE_STYLE_MUTATION_OPCODE] == STYLE_MUTATION_REMOVE { - return Some(StyleMutation::Remove { style_id }); + return Some(StyleMutation::Remove { + paragraph_id, + style_id, + }); } let language_length = u32::from(read_u16(record, abi::ENGINE_STYLE_MUTATION_LANGUAGE_LENGTH).ok()?); @@ -465,6 +492,7 @@ impl<'a> StyleMutationBatch<'a> { .ok()? }; Some(StyleMutation::Upsert(StyleValue { + paragraph_id, style_id, cascade_order: read_u32(record, abi::ENGINE_STYLE_MUTATION_CASCADE_ORDER).ok()?, field_mask: read_u32(record, abi::ENGINE_STYLE_MUTATION_FIELD_MASK).ok()?, @@ -495,6 +523,13 @@ impl<'a> StyleMutationBatch<'a> { })) } + pub(crate) fn paragraph_id(self, index: usize) -> Option { + match self.get(index)? { + StyleMutation::Remove { paragraph_id, .. } + | StyleMutation::Upsert(StyleValue { paragraph_id, .. }) => Some(paragraph_id), + } + } + pub(crate) fn feature(value: StyleValue<'_>, index: usize) -> Option { let stride = abi::FEATURE_RECORD_SIZE as usize; let start = index.checked_mul(stride)?; @@ -608,13 +643,17 @@ fn style_payload_ranges(request: &[u8], record: &[u8]) -> Result<[Option<(usize, fn validate_style_record(request: &[u8], record: &[u8]) -> Result<(), u32> { let opcode = byte(record, abi::ENGINE_STYLE_MUTATION_OPCODE)?; let style_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_STYLE_ID)?; - if style_id == 0 { + let paragraph_id = read_u32(record, abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID)?; + if style_id == 0 || paragraph_id == 0 { return Err(STATUS_INVALID_REQUEST); } if opcode == STYLE_MUTATION_REMOVE { for (index, value) in record.iter().copied().enumerate() { let identity = index == abi::ENGINE_STYLE_MUTATION_OPCODE || (abi::ENGINE_STYLE_MUTATION_STYLE_ID..abi::ENGINE_STYLE_MUTATION_STYLE_ID + 4) + .contains(&index) + || (abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID + ..abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID + 4) .contains(&index); if !identity && value != 0 { return Err(STATUS_INVALID_REQUEST); @@ -880,7 +919,7 @@ pub(crate) fn parse_text_mutations( if record[ENGINE_TEXT_MUTATION_OPCODE] != TEXT_MUTATION_REPLACE_UTF16 || record[ENGINE_TEXT_MUTATION_ENCODING] != TEXT_ENCODING_UTF16_LE || read_u16(record, ENGINE_TEXT_MUTATION_RESERVED0)? != 0 - || read_u32(record, ENGINE_TEXT_MUTATION_RESERVED1)? != 0 + || read_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID)? == 0 { return Err(STATUS_INVALID_REQUEST); } @@ -1042,6 +1081,9 @@ fn validate_constraints( .chunks_exact(abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize) .enumerate() { + if read_u32(record, abi::ENGINE_CONSTRAINT_PARAGRAPH_ID)? == 0 { + return Err(STATUS_INVALID_REQUEST); + } let flow_thread_id = read_u32(record, abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID)?; if flow_thread_id == 0 || prior_u32_duplicate( @@ -1253,7 +1295,8 @@ fn validate_inline_objects(records: &[u8]) -> Result<(), u32> { .enumerate() { let id = read_u32(record, abi::ENGINE_INLINE_OBJECT_ID)?; - if id == 0 + if read_u32(record, abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID)? == 0 + || id == 0 || prior_u32_duplicate( records, abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, @@ -1562,6 +1605,11 @@ mod tests { STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_STYLE_ID, 7, ); + write_u32( + &mut removal, + STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); assert!(parse_style_mutations(&removal, STYLE_OFFSET as u32, 1).is_ok()); removal[STYLE_OFFSET + abi::ENGINE_STYLE_MUTATION_DIRECTION] = DIRECTION_LTR; assert!(parse_style_mutations(&removal, STYLE_OFFSET as u32, 1).is_err()); @@ -1600,6 +1648,11 @@ mod tests { bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_UPSERT; bytes[record + abi::ENGINE_STYLE_MUTATION_FLAGS] = STYLE_FLAG_ROOT; write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, 7); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); write_u32( &mut bytes, record + abi::ENGINE_STYLE_MUTATION_CASCADE_ORDER, @@ -1675,6 +1728,7 @@ mod tests { let record = &mut bytes[record_offset as usize..payload_offset as usize]; record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, 1); write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, 2); write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, 1); write_u32(record, ENGINE_TEXT_MUTATION_INSERT_OFFSET, payload_offset); @@ -1689,6 +1743,7 @@ mod tests { assert_eq!( batch.get(0), Some(TextMutation { + paragraph_id: 1, text_start: 2, delete_count: 1, insert_utf16_le: &[0x61, 0x00, 0x3d, 0xd8], @@ -1862,6 +1917,11 @@ mod tests { TEXT_MUTATION_REPLACE_UTF16; cross_section_alias[mutation_offset + ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32( + &mut cross_section_alias, + mutation_offset + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + 1, + ); write_u32( &mut cross_section_alias, mutation_offset + ENGINE_TEXT_MUTATION_INSERT_OFFSET, @@ -1932,6 +1992,11 @@ mod tests { fn valid_geometry_bytes() -> Vec { let mut bytes = vec![0; GEOMETRY_LENGTH]; + write_u32( + &mut bytes, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_PARAGRAPH_ID, + 1, + ); write_u32( &mut bytes, CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_FLOW_THREAD_ID, @@ -2026,6 +2091,11 @@ mod tests { 40.0, ); + write_u32( + &mut bytes, + INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID, + 1, + ); write_u32(&mut bytes, INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_ID, 3); write_u32( &mut bytes, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index d0ed10b2..c144bc0e 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -80,6 +80,7 @@ struct ClusterRecord { #[derive(Default)] struct EngineSession { + paragraph_id: Option, revision: SessionRevision, acknowledged_publication_generation: u32, policy_binding: Option, @@ -485,6 +486,7 @@ impl TextEngine { if !request.limits.all_nonzero() { return Err(EngineError::InvalidRequest); } + let paragraph_id = request_paragraph_id(request)?; let policy = self .policies .get(&request.policy_handle) @@ -503,6 +505,12 @@ impl TextEngine { .sessions .get_mut(&request.session_id) .ok_or(EngineError::SessionMissing)?; + if session.paragraph_id.is_some() + && paragraph_id.is_some() + && session.paragraph_id != paragraph_id + { + return Err(EngineError::InvalidRequest); + } if session.policy_binding.is_some_and(|binding| { binding.handle != request.policy_handle || binding.fingerprint != policy_fingerprint }) { @@ -707,6 +715,7 @@ impl TextEngine { policy_handle: request.policy_handle, capability_set: request.capability_set, policy_fingerprint, + paragraph_id, }) } @@ -779,6 +788,9 @@ impl TextEngine { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, }); + if session.paragraph_id.is_none() { + session.paragraph_id = prepared.paragraph_id; + } session.revision = prepared.next; Ok(CommittedUpdate { session_id: prepared.session_id, @@ -1679,6 +1691,37 @@ fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineErro Ok(()) } +fn request_paragraph_id(request: UpdateRequest<'_>) -> Result, EngineError> { + let mut paragraph_id = None; + for index in 0..request.text_mutations.len() { + merge_paragraph_id(&mut paragraph_id, request.text_mutations.paragraph_id(index))?; + } + for index in 0..request.style_mutations.len() { + merge_paragraph_id(&mut paragraph_id, request.style_mutations.paragraph_id(index))?; + } + let geometry_count = request + .geometry + .constraint_count() + .checked_add(request.geometry.inline_object_count()) + .ok_or(EngineError::InvalidRequest)?; + for index in 0..geometry_count { + merge_paragraph_id(&mut paragraph_id, request.geometry.paragraph_id(index))?; + } + Ok(paragraph_id) +} + +fn merge_paragraph_id( + current: &mut Option, + candidate: Option, +) -> Result<(), EngineError> { + let candidate = candidate.ok_or(EngineError::InvalidRequest)?; + if current.is_some_and(|value| value != candidate) { + return Err(EngineError::InvalidRequest); + } + *current = Some(candidate); + Ok(()) +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum TextMutationError { Invalid, @@ -1712,7 +1755,8 @@ mod tests { abi_contract::{ self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, - ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, bidi::DIRECTION_RTL, @@ -2182,6 +2226,66 @@ mod tests { assert!(engine.session_text(4).unwrap().is_empty()); } + #[test] + fn single_paragraph_session_rejects_mixed_and_rebound_paragraph_ids() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + + let mut mixed_bytes = text_mutation_bytes(&[(0, 0, &[0x61]), (1, 0, &[0x62])]); + let second = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + write_u32( + &mut mixed_bytes, + second + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + 2, + ); + let mixed = parse_text_mutations( + &mixed_bytes, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + 2, + ) + .unwrap(); + let mut request = update(0, 0, 0); + request.text_mutations = mixed; + assert_eq!( + engine.prepare_update(request, 1), + Err(EngineError::InvalidRequest) + ); + + let initial_bytes = text_mutation_bytes(&[(0, 0, &[0x61])]); + let mut initial = update(0, 0, 0); + initial.text_mutations = parse_text_mutations( + &initial_bytes, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + 1, + ) + .unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let mut rebound_bytes = text_mutation_bytes(&[(1, 0, &[0x62])]); + write_u32( + &mut rebound_bytes, + ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + 2, + ); + let mut rebound = update(1, 1, 1); + rebound.text_mutations = parse_text_mutations( + &rebound_bytes, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, + 1, + ) + .unwrap(); + assert_eq!( + engine.prepare_update(rebound, 2), + Err(EngineError::InvalidRequest) + ); + assert_eq!(engine.session_text(4).unwrap(), &[0x61]); + } + #[test] fn a_committed_session_rejects_rebinding_its_policy_identity() { let mut engine = TextEngine::default(); @@ -2341,6 +2445,11 @@ mod tests { bytes[record + abi::ENGINE_STYLE_MUTATION_OPCODE] = STYLE_MUTATION_UPSERT; bytes[record + abi::ENGINE_STYLE_MUTATION_FLAGS] = STYLE_FLAG_ROOT; write_u32(&mut bytes, record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, 1); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); write_u32( &mut bytes, record + abi::ENGINE_STYLE_MUTATION_FIELD_MASK, @@ -2390,6 +2499,11 @@ mod tests { record + abi::ENGINE_STYLE_MUTATION_STYLE_ID, style_id, ); + write_u32( + &mut bytes, + record + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + 1, + ); bytes } @@ -2408,6 +2522,7 @@ mod tests { let record = &mut bytes[start..end]; record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, 1); write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, text_start); write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, delete_count); if !insert.is_empty() { diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs index f3d2de4e..38b89f27 100644 --- a/packages/text/rust/shaper/src/engine/style_state.rs +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -262,7 +262,7 @@ impl StyleArena { .get(request_index) .ok_or(EngineError::InvalidRequest)?; let style_id = match mutation { - StyleMutation::Remove { style_id } => style_id, + StyleMutation::Remove { style_id, .. } => style_id, StyleMutation::Upsert(value) => value.style_id, }; scratch.push(MutationKey { diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 127587af..f78b0ab2 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -272,12 +272,13 @@ export const textShaperAbi = { "heightMode": 45, "maxLines": 28, "overflow": 48, + "paragraphId": 52, "regionCount": 40, "regionStart": 32, "resumeBlockOffset": 24, "resumeCluster": 36, "resumeRegion": 42, - "size": 52, + "size": 56, "viewportBlockEnd": 20, "viewportBlockStart": 16, "width": 8, @@ -355,10 +356,11 @@ export const textShaperAbi = { "marginInlineEnd": 40, "marginInlineStart": 36, "materialId": 12, + "paragraphId": 56, "reserved0": 54, "resourceGeneration": 20, "resourceId": 16, - "size": 56, + "size": 60, "textOffset": 8 }, "enginePatch": { @@ -523,8 +525,9 @@ export const textShaperAbi = { "lineHeight": 48, "materialId": 28, "opcode": 0, + "paragraphId": 88, "rasterPixelRatio": 64, - "size": 88, + "size": 92, "styleId": 4, "textEnd": 20, "textStart": 16, @@ -537,8 +540,8 @@ export const textShaperAbi = { "insertCount": 16, "insertOffset": 12, "opcode": 0, + "paragraphId": 20, "reserved0": 2, - "reserved1": 20, "size": 24, "textStart": 4 }, diff --git a/packages/text/src/internal/engine-frame-wire.ts b/packages/text/src/internal/engine-frame-wire.ts index 19558f07..cae388cc 100644 --- a/packages/text/src/internal/engine-frame-wire.ts +++ b/packages/text/src/internal/engine-frame-wire.ts @@ -14,6 +14,7 @@ export interface TextEngineFrameLimits { } export interface TextEngineTextMutation { + readonly paragraphId: number; readonly start: number; readonly deleteCount: number; readonly insert: string; @@ -54,9 +55,10 @@ export interface TextEngineStyleValue { } export type TextEngineStyleMutation = - | { readonly opcode: 'remove'; readonly styleId: number } + | { readonly opcode: 'remove'; readonly paragraphId: number; readonly styleId: number } | { readonly opcode: 'upsert'; + readonly paragraphId: number; readonly styleId: number; readonly cascadeOrder: number; readonly start: number; @@ -66,6 +68,7 @@ export type TextEngineStyleMutation = }; export interface TextEngineConstraint { + readonly paragraphId: number; readonly flowThreadId: number; readonly geometryRevision: number; readonly width: number; @@ -126,6 +129,7 @@ export interface TextEngineExclusion { } export interface TextEngineInlineObject { + readonly paragraphId: number; readonly id: number; readonly contentRevision: number; readonly textOffset: number; @@ -289,6 +293,7 @@ function writeTextMutations( const payloadOffset = payloadOffsets[index]!; view.setUint8(offset + layout.opcode, textShaperAbi.engine.textMutationOpcodes.replaceUtf16); view.setUint8(offset + layout.encoding, textShaperAbi.engine.textEncodings.utf16Le); + view.setUint32(offset + layout.paragraphId, u32(mutation.paragraphId, 'paragraph ID'), true); view.setUint32(offset + layout.textStart, u32(mutation.start, 'text mutation start'), true); view.setUint32(offset + layout.deleteCount, u32(mutation.deleteCount, 'text mutation delete count'), true); view.setUint32(offset + layout.insertOffset, payloadOffset, true); @@ -311,6 +316,7 @@ function writeStyleMutations( const layout = textShaperAbi.layouts.engineStyleMutation; for (const [index, mutation] of mutations.entries()) { const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.paragraphId, u32(mutation.paragraphId, 'paragraph ID'), true); view.setUint32(offset + layout.styleId, u32(mutation.styleId, 'style ID'), true); if (mutation.opcode === 'remove') { view.setUint8(offset + layout.opcode, textShaperAbi.engine.styleMutationOpcodes.remove); @@ -396,6 +402,7 @@ function writeConstraints(view: DataView, tableOffset: number, constraints: read const engine = textShaperAbi.engine; for (const [index, value] of constraints.entries()) { const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.paragraphId, u32(value.paragraphId, 'paragraph ID'), true); for (const [field, number] of [ ['flowThreadId', value.flowThreadId], ['geometryRevision', value.geometryRevision], @@ -501,6 +508,7 @@ function writeInlineObjects(view: DataView, tableOffset: number, objects: readon const layout = textShaperAbi.layouts.engineInlineObject; for (const [index, value] of objects.entries()) { const offset = tableOffset + index * layout.size; + view.setUint32(offset + layout.paragraphId, u32(value.paragraphId, 'paragraph ID'), true); for (const [field, number] of [ ['id', value.id], ['contentRevision', value.contentRevision], diff --git a/packages/text/tests/integration/engine-frame-wire.test.mjs b/packages/text/tests/integration/engine-frame-wire.test.mjs index ffaf6181..ef9eee5f 100644 --- a/packages/text/tests/integration/engine-frame-wire.test.mjs +++ b/packages/text/tests/integration/engine-frame-wire.test.mjs @@ -35,10 +35,11 @@ test('production frame compiler preserves the established benchmark request byte maxInlineObjects: 1, maxSlotsPerBand: 1, }, - textMutations: [{ start: 0, deleteCount: 0, insert: text }], + textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: text }], styleMutations: [ { opcode: 'upsert', + paragraphId: 1, styleId: 1, cascadeOrder: 0, start: 0, @@ -49,6 +50,7 @@ test('production frame compiler preserves the established benchmark request byte ], constraints: [ { + paragraphId: 1, flowThreadId: 1, geometryRevision: 0, width: 320, @@ -114,6 +116,7 @@ test('production frame compiler carries full style, polygon, exclusion, and inli styleMutations: [ { opcode: 'upsert', + paragraphId: 3, styleId: 9, cascadeOrder: 2, start: 1, @@ -190,6 +193,7 @@ test('production frame compiler carries full style, polygon, exclusion, and inli ], inlineObjects: [ { + paragraphId: 3, id: 3, contentRevision: 1, textOffset: 4, @@ -220,9 +224,14 @@ test('production frame compiler carries full style, polygon, exclusion, and inli const styleOffset = header.getUint32(request.styleMutationsOffset, true); const style = abi.layouts.engineStyleMutation; const styleView = new DataView(bytes.buffer, bytes.byteOffset + styleOffset, style.size); + assert.equal(styleView.getUint32(style.paragraphId, true), 3); assert.equal(styleView.getUint8(style.direction), 2); assert.equal(styleView.getUint16(style.languageLength, true), 2); assert.equal(styleView.getUint16(style.featureCount, true), 1); assert.equal(styleView.getUint32(style.materialId, true), 8); assert.equal(styleView.getUint32(style.decorationFlags, true), 13); + const inlineObjectOffset = header.getUint32(request.inlineObjectsOffset, true); + const inlineObject = abi.layouts.engineInlineObject; + const inlineObjectView = new DataView(bytes.buffer, bytes.byteOffset + inlineObjectOffset, inlineObject.size); + assert.equal(inlineObjectView.getUint32(inlineObject.paragraphId, true), 3); }); diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 7650cc0f..e5237cc7 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -108,10 +108,11 @@ test('Three coordinator shares shaping data across technique bindings and refere maxSlotsPerBand: 2, maxOutputBytes: 1024 * 1024, }, - textMutations: [{ start: 0, deleteCount: 0, insert: 'abc' }], + textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: 'abc' }], styleMutations: [ { opcode: 'upsert', + paragraphId: 1, styleId: 1, cascadeOrder: 0, start: 0, @@ -128,6 +129,7 @@ test('Three coordinator shares shaping data across technique bindings and refere ], constraints: [ { + paragraphId: 1, flowThreadId: 1, geometryRevision: 1, width: 256, diff --git a/packages/text/tests/support/engine-abi.d.mts b/packages/text/tests/support/engine-abi.d.mts index fc3a7014..ef7327df 100644 --- a/packages/text/tests/support/engine-abi.d.mts +++ b/packages/text/tests/support/engine-abi.d.mts @@ -5,6 +5,7 @@ export interface EngineUpdateFields { readonly consumedPlanRevision: number; readonly acknowledgedPublicationGeneration?: number; readonly textMutations?: readonly { + readonly paragraphId?: number; readonly start: number; readonly deleteCount: number; readonly insert: readonly number[]; @@ -22,17 +23,20 @@ export interface EngineFrameUpdateFields { readonly consumedPlanRevision?: number; readonly acknowledgedPublicationGeneration?: number; readonly textMutation?: { + readonly paragraphId?: number; readonly start: number; readonly deleteCount: number; readonly insert: readonly number[]; }; readonly style?: { + readonly paragraphId?: number; readonly textEnd: number; readonly fontSize: number; readonly lineHeight: number; readonly rasterPixelRatio: number; }; readonly geometry?: { + readonly paragraphId?: number; readonly width: number; readonly height: number; readonly maxLines: number; diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index e48b7b39..fd586a22 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -96,6 +96,7 @@ export function engineUpdateBytes( const record = mutationsOffset + index * mutationLayout.size; view.setUint8(record + mutationLayout.opcode, abi.engine.textMutationOpcodes.replaceUtf16); view.setUint8(record + mutationLayout.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(record + mutationLayout.paragraphId, mutation.paragraphId ?? 1, true); view.setUint32(record + mutationLayout.textStart, mutation.start, true); view.setUint32(record + mutationLayout.deleteCount, mutation.deleteCount, true); if (mutation.insert.length > 0) { @@ -170,6 +171,7 @@ export function engineFrameUpdateBytes( if (textMutation !== undefined) { view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); view.setUint8(textRecordOffset + textRecord.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(textRecordOffset + textRecord.paragraphId, textMutation.paragraphId ?? 1, true); view.setUint32(textRecordOffset + textRecord.textStart, textMutation.start, true); view.setUint32(textRecordOffset + textRecord.deleteCount, textMutation.deleteCount, true); view.setUint32(textRecordOffset + textRecord.insertOffset, textPayloadOffset, true); @@ -182,6 +184,7 @@ export function engineFrameUpdateBytes( if (style !== undefined) { view.setUint8(styleRecordOffset + styleRecord.opcode, abi.engine.styleMutationOpcodes.upsert); view.setUint8(styleRecordOffset + styleRecord.flags, abi.engine.styleFlags.root); + view.setUint32(styleRecordOffset + styleRecord.paragraphId, style.paragraphId ?? 1, true); view.setUint32(styleRecordOffset + styleRecord.styleId, 1, true); view.setUint32( styleRecordOffset + styleRecord.fieldMask, @@ -199,6 +202,7 @@ export function engineFrameUpdateBytes( } if (geometry !== undefined) { + view.setUint32(constraintOffset + constraint.paragraphId, geometry.paragraphId ?? 1, true); view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); view.setFloat32(constraintOffset + constraint.width, geometry.width, true); view.setFloat32(constraintOffset + constraint.height, geometry.height, true); From 07d2037f732a0ab0677285fb7adaeeaa44bbfffc Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 21:45:23 -0400 Subject: [PATCH 058/128] feat(text): declare ordered paragraph lifecycle --- docs/packages/text.md | 10 +- docs/planning/decision-register.md | 2 + packages/text/rust/shaper/src/abi_contract.rs | 78 ++++++- packages/text/rust/shaper/src/engine/frame.rs | 7 +- .../text/rust/shaper/src/engine/frame_wire.rs | 24 ++- .../rust/shaper/src/engine/semantic_wire.rs | 201 ++++++++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 22 +- .../text/src/generated/text-shaper-abi.ts | 18 +- .../text/src/internal/engine-frame-wire.ts | 36 ++++ .../integration/engine-frame-wire.test.mjs | 4 + .../integration/three-engine-runtime.test.mjs | 2 + packages/text/tests/support/engine-abi.d.mts | 1 + packages/text/tests/support/engine-abi.mjs | 14 ++ 13 files changed, 408 insertions(+), 11 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index 5251e02a..33e16bf7 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:4774636e57780e33b8013d420aa2b0af9f0be235428fd4d73fd1d13d9e403086' +source_digest: 'sha256:5588a018d8d9306184385c082648762af3d5dbc164a5cb70494e139cab364134' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -793,7 +793,13 @@ state before the retained-child cutover. Text replacements reuse their former re inline-object records each grow by four bytes. The optimized Wasm measures 1,070,673 raw / 402,177 gzip / 319,722 Brotli bytes, versus 1,070,685 / 402,154 / 319,914 at the preceding batching checkpoint; this does not establish a material size change. Retained child state, session-wide stable ID allocation, and atomic shared-plan commit remain in -progress; no per-text-session shortcut is shipped. Adjacent +progress; no per-text-session shortcut is shipped. Paragraph identity is deliberately separate from presentation +order. A compact 12-byte paragraph-control record now declares an upsert's explicit batch order or removes a retained +paragraph; the decoder rejects duplicate IDs, duplicate declared orders, noncanonical removals, forged overlaps, and a +count above the frame's paragraph limit. The production frame compiler emits this table before all paragraph-owned +semantic records. The reachable validation path changes optimized Wasm to 1,073,123 raw / 403,431 gzip / 320,917 +Brotli bytes (+2,450 / +1,254 / +1,195 over the keyed-record checkpoint). The retained-state consumer is the next +checkpoint, so removal is currently rejected before mutation rather than falsely accepted. Adjacent 8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and 6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / 319,914 raw/gzip/Brotli bytes, +105 / +40 / +252 from D-205. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 915d0dea..ea096ee1 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -272,6 +272,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-205 | A render-binding handle identifies one loaded font/technique/resource combination independently from its shaping-font handle. Rust font stacks contain binding handles; each binding names the retained shaping font whose SFNT, plans, metrics, and extents it reuses. Fallback and cluster state retain both identities: shaping and positioning use the shaping handle, while policy gather uses the binding handle. Compiled Wasm proves two techniques can bind one shaping font and an Inter-to-Devanagari fallback emits the second binding's distinct technique in one Rust plan. The additional retained `u32` cluster lane measures 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug resize at 8 warmups/31 samples; 6–7% RSD does not distinguish this from D-203's 4.120/4.646/5.622 ms. Optimized Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes, +607 / -3,774 / +104 bytes from D-203. | Accepted | | D-206 | One Rust engine session corresponds to one renderer batch such as `TextGroup`, not one `Text`. A session retains multiple stable-ID paragraph states and publishes one shared render plan; paragraph mutations and removal are keyed explicitly, and each paragraph owns its text, styles, constraints, and sequential region flow. Glyph and content identities allocate from session-wide monotonic namespaces so one planner cannot alias equal paragraph-local ordinals. Rust appends each paragraph's positioned SoA directly into one pre-reserved policy-gather workspace; TypeScript never concatenates prose, merges glyph arrays, or makes one Wasm call per text. This preserves the accepted batch lifecycle and single frame crossing while keeping exclusions as one-call paragraph geometry. The append kernel has an exact two-layout allocation-free proof. Text, style, constraint, and inline-object records now carry nonzero paragraph IDs through the generated ABI; the transitional single-child state rejects mixed IDs within a transaction and rebinding across transactions. The optimized Wasm is size-neutral within compression/code-layout noise at 1,070,673 raw / 402,177 gzip / 319,722 Brotli bytes versus 1,070,685 / 402,154 / 319,914 before the keyed wire. Retained child state and shared-plan commit remain in progress. | Accepted | +| D-207 | Paragraph lifecycle identity does not determine presentation order. The frame ABI carries a separate 12-byte paragraph-control record that explicitly upserts ordered batch membership or removes retained state. The decoder rejects zero and duplicate IDs, duplicate declared orders, noncanonical removals, forged section overlap, and counts above the frame paragraph limit. Text, style, constraint, and inline-object records continue to name their owning paragraph independently. The production compiler emits control records before paragraph-owned semantics; the transitional single-child state rejects removal until the retained-child consumer lands. The reachable validation path changes optimized Wasm from 1,070,673 / 402,177 / 319,722 to 1,073,123 / 403,431 / 320,917 raw/gzip/Brotli bytes. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 3ac8f5c9..47cdb75f 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -11,7 +11,8 @@ use crate::engine::frame::{ DECORATION_UNDERLINE, DECORATION_WAVY, DEFAULT_SESSION_TEXT_CAPACITY, EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, - OVERFLOW_VISIBLE, RESULT_FLAG_CHECKPOINT, SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_START, + OVERFLOW_VISIBLE, PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, + RESULT_FLAG_CHECKPOINT, SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_START, SEMANTIC_F32_FONT_SIZE, SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, SEMANTIC_F32_FOREGROUND_GREEN, SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, @@ -241,6 +242,18 @@ struct EngineUpdateRequestHeader { inline_object_count: u32, policy_parameters_offset: u32, policy_parameters_length: u32, + max_paragraphs: u32, + paragraph_mutations_offset: u32, + paragraph_mutation_count: u32, +} + +#[repr(C)] +struct EngineParagraphMutationRecord { + opcode: u8, + flags: u8, + reserved0: u16, + paragraph_id: u32, + order: u32, } #[repr(C)] @@ -552,6 +565,11 @@ layout!( ENGINE_UPDATE_REQUEST_HEADER_ALIGNMENT, EngineUpdateRequestHeader ); +layout!( + ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + ENGINE_PARAGRAPH_MUTATION_RECORD_ALIGNMENT, + EngineParagraphMutationRecord +); layout!( ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, @@ -1134,6 +1152,46 @@ field_offset!( EngineUpdateRequestHeader, policy_parameters_length ); +field_offset!( + ENGINE_UPDATE_MAX_PARAGRAPHS, + EngineUpdateRequestHeader, + max_paragraphs +); +field_offset!( + ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET, + EngineUpdateRequestHeader, + paragraph_mutations_offset +); +field_offset!( + ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT, + EngineUpdateRequestHeader, + paragraph_mutation_count +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_OPCODE, + EngineParagraphMutationRecord, + opcode +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_FLAGS, + EngineParagraphMutationRecord, + flags +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_RESERVED0, + EngineParagraphMutationRecord, + reserved0 +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + EngineParagraphMutationRecord, + paragraph_id +); +field_offset!( + ENGINE_PARAGRAPH_MUTATION_ORDER, + EngineParagraphMutationRecord, + order +); field_offset!( ENGINE_TEXT_MUTATION_OPCODE, EngineTextMutationRecord, @@ -2115,7 +2173,19 @@ pub fn json() -> String { "inlineObjectsOffset": ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, "inlineObjectCount": ENGINE_UPDATE_INLINE_OBJECT_COUNT, "policyParametersOffset": ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, - "policyParametersLength": ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH + "policyParametersLength": ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + "maxParagraphs": ENGINE_UPDATE_MAX_PARAGRAPHS, + "paragraphMutationsOffset": ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET, + "paragraphMutationCount": ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT + }, + "engineParagraphMutation": { + "size": ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + "alignment": ENGINE_PARAGRAPH_MUTATION_RECORD_ALIGNMENT, + "opcode": ENGINE_PARAGRAPH_MUTATION_OPCODE, + "flags": ENGINE_PARAGRAPH_MUTATION_FLAGS, + "reserved0": ENGINE_PARAGRAPH_MUTATION_RESERVED0, + "paragraphId": ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + "order": ENGINE_PARAGRAPH_MUTATION_ORDER }, "engineTextMutation": { "size": ENGINE_TEXT_MUTATION_RECORD_SIZE, @@ -2566,6 +2636,10 @@ pub fn json() -> String { "regionId": SEMANTIC_U32_REGION_ID, "flowThreadId": SEMANTIC_U32_FLOW_THREAD_ID }, + "paragraphMutationOpcodes": { + "upsert": PARAGRAPH_MUTATION_UPSERT, + "remove": PARAGRAPH_MUTATION_REMOVE + }, "textMutationOpcodes": { "replaceUtf16": TEXT_MUTATION_REPLACE_UTF16 }, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index e7e7c31c..d645c296 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -78,6 +78,8 @@ pub(crate) const SEMANTIC_U32_FOREGROUND_RGBA: u8 = 0; pub(crate) const SEMANTIC_U32_CLUSTER_ID: u8 = 1; pub(crate) const SEMANTIC_U32_REGION_ID: u8 = 2; pub(crate) const SEMANTIC_U32_FLOW_THREAD_ID: u8 = 3; +pub(crate) const PARAGRAPH_MUTATION_UPSERT: u8 = 1; +pub(crate) const PARAGRAPH_MUTATION_REMOVE: u8 = 2; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct UpdateRequest<'a> { @@ -88,6 +90,7 @@ pub(crate) struct UpdateRequest<'a> { pub policy_handle: u32, pub capability_set: u32, pub limits: UpdateLimits, + pub paragraph_mutations: super::semantic_wire::ParagraphMutationBatch<'a>, pub text_mutations: super::semantic_wire::TextMutationBatch<'a>, pub style_mutations: super::semantic_wire::StyleMutationBatch<'a>, pub geometry: super::semantic_wire::GeometryBatch<'a>, @@ -95,6 +98,7 @@ pub(crate) struct UpdateRequest<'a> { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct UpdateLimits { + pub max_paragraphs: u32, pub max_clusters: u32, pub max_lines: u32, pub max_regions: u32, @@ -106,7 +110,8 @@ pub(crate) struct UpdateLimits { impl UpdateLimits { pub fn all_nonzero(self) -> bool { - self.max_clusters != 0 + self.max_paragraphs != 0 + && self.max_clusters != 0 && self.max_lines != 0 && self.max_regions != 0 && self.max_exclusions != 0 diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 8c2c58e0..26052338 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -15,8 +15,10 @@ use crate::{ ENGINE_UPDATE_EXPECTED_ENGINE_REVISION, ENGINE_UPDATE_FLAGS, ENGINE_UPDATE_INLINE_OBJECT_COUNT, ENGINE_UPDATE_INLINE_OBJECTS_OFFSET, ENGINE_UPDATE_MAX_CLUSTERS, ENGINE_UPDATE_MAX_EXCLUSIONS, ENGINE_UPDATE_MAX_INLINE_OBJECTS, - ENGINE_UPDATE_MAX_LINES, ENGINE_UPDATE_MAX_OUTPUT_BYTES, ENGINE_UPDATE_MAX_REGIONS, - ENGINE_UPDATE_MAX_SLOTS_PER_BAND, ENGINE_UPDATE_POLICY_HANDLE, + ENGINE_UPDATE_MAX_LINES, ENGINE_UPDATE_MAX_OUTPUT_BYTES, ENGINE_UPDATE_MAX_PARAGRAPHS, + ENGINE_UPDATE_MAX_REGIONS, ENGINE_UPDATE_MAX_SLOTS_PER_BAND, + ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT, ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET, + ENGINE_UPDATE_POLICY_HANDLE, ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, ENGINE_UPDATE_REGION_COUNT, ENGINE_UPDATE_REGIONS_OFFSET, ENGINE_UPDATE_REQUEST_HEADER_SIZE, ENGINE_UPDATE_SEMANTIC_VIEW_MASK, @@ -26,7 +28,7 @@ use crate::{ }, engine::{ frame::{UpdateLimits, UpdateRequest}, - semantic_wire::{parse_geometry, parse_text_mutations}, + semantic_wire::{parse_geometry, parse_paragraph_mutations, parse_text_mutations}, }, wire::read_u32, }; @@ -57,6 +59,7 @@ pub(crate) fn parse_update_request( } } let limits = UpdateLimits { + max_paragraphs: positive(bytes, ENGINE_UPDATE_MAX_PARAGRAPHS)?, max_clusters: positive(bytes, ENGINE_UPDATE_MAX_CLUSTERS)?, max_lines: positive(bytes, ENGINE_UPDATE_MAX_LINES)?, max_regions: positive(bytes, ENGINE_UPDATE_MAX_REGIONS)?, @@ -71,6 +74,15 @@ pub(crate) fn parse_update_request( { return Err(STATUS_INVALID_REQUEST); } + let paragraph_mutation_count = read_u32(bytes, ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT)?; + if paragraph_mutation_count > limits.max_paragraphs { + return Err(STATUS_INVALID_REQUEST); + } + let paragraph_mutations = parse_paragraph_mutations( + bytes, + read_u32(bytes, ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET)?, + paragraph_mutation_count, + )?; let text_mutation_count = read_u32(bytes, ENGINE_UPDATE_TEXT_MUTATION_COUNT)?; if text_mutation_count > limits.max_clusters { return Err(STATUS_INVALID_REQUEST); @@ -107,7 +119,9 @@ pub(crate) fn parse_update_request( )?; text_mutations.validate_disjoint_geometry(geometry)?; style_mutations.validate_disjoint_semantics(text_mutations, geometry)?; - if text_mutation_count == 0 + paragraph_mutations.validate_disjoint_semantics(text_mutations, style_mutations, geometry)?; + if paragraph_mutation_count == 0 + && text_mutation_count == 0 && style_mutation_count == 0 && constraint_count == 0 && region_count == 0 @@ -128,6 +142,7 @@ pub(crate) fn parse_update_request( policy_handle: read_u32(bytes, ENGINE_UPDATE_POLICY_HANDLE)?, capability_set: positive(bytes, ENGINE_UPDATE_CAPABILITY_SET)?, limits, + paragraph_mutations, text_mutations, style_mutations, geometry, @@ -185,6 +200,7 @@ mod tests { write_u32(&mut bytes, ENGINE_UPDATE_POLICY_HANDLE, 9); write_u32(&mut bytes, ENGINE_UPDATE_CAPABILITY_SET, 1); for offset in [ + ENGINE_UPDATE_MAX_PARAGRAPHS, ENGINE_UPDATE_MAX_CLUSTERS, ENGINE_UPDATE_MAX_LINES, ENGINE_UPDATE_MAX_REGIONS, diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 72a03f25..84f180de 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -28,11 +28,24 @@ use crate::{ STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, UpdateLimits, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, + PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, }, valid_language_bytes, valid_tag, wire::{array, read_f32, read_u16, read_u32}, }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ParagraphMutationBatch<'a> { + request: &'a [u8], + records: &'a [u8], +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ParagraphMutation { + Upsert { paragraph_id: u32, order: u32 }, + Remove { paragraph_id: u32 }, +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct TextMutationBatch<'a> { request: &'a [u8], @@ -361,6 +374,55 @@ impl GeometryBatch<'_> { } } +impl<'a> ParagraphMutationBatch<'a> { + pub(crate) const fn empty() -> Self { + Self { + request: &[], + records: &[], + } + } + + pub(crate) fn len(self) -> usize { + self.records.len() / abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize + } + + pub(crate) fn get(self, index: usize) -> Option { + let record = record_at( + self.records, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + index, + )?; + let paragraph_id = read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID).ok()?; + match record[abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] { + PARAGRAPH_MUTATION_UPSERT => Some(ParagraphMutation::Upsert { + paragraph_id, + order: read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER).ok()?, + }), + PARAGRAPH_MUTATION_REMOVE => Some(ParagraphMutation::Remove { paragraph_id }), + _ => None, + } + } + + pub(crate) fn validate_disjoint_semantics( + self, + text: TextMutationBatch<'_>, + styles: StyleMutationBatch<'_>, + geometry: GeometryBatch<'_>, + ) -> Result<(), u32> { + if self.records.is_empty() { + return Ok(()); + } + let range = byte_range(self.request, self.records)?; + if text.overlaps_range(range)? + || styles.overlaps_range(range)? + || geometry.overlaps_range(range)? + { + return Err(STATUS_INVALID_REQUEST); + } + Ok(()) + } +} + impl<'a> TextMutationBatch<'a> { pub(crate) const fn empty() -> Self { Self { @@ -568,6 +630,25 @@ impl<'a> StyleMutationBatch<'a> { } Ok(()) } + + fn overlaps_range(self, range: (usize, usize)) -> Result { + if !self.records.is_empty() && overlaps(range, byte_range(self.request, self.records)?) { + return Ok(true); + } + for record in self + .records + .chunks_exact(abi::ENGINE_STYLE_MUTATION_RECORD_SIZE as usize) + { + if style_payload_ranges(self.request, record)? + .into_iter() + .flatten() + .any(|payload| overlaps(range, payload)) + { + return Ok(true); + } + } + Ok(false) + } } pub(crate) fn parse_style_mutations( @@ -886,6 +967,69 @@ fn validate_decoration(record: &[u8], field_mask: u32) -> Result<(), u32> { Ok(()) } +pub(crate) fn parse_paragraph_mutations( + request: &[u8], + offset: u32, + count: u32, +) -> Result, u32> { + if count == 0 { + return if offset == 0 { + Ok(ParagraphMutationBatch::empty()) + } else { + Err(STATUS_INVALID_REQUEST) + }; + } + if offset < ENGINE_UPDATE_REQUEST_HEADER_SIZE { + return Err(STATUS_INVALID_REQUEST); + } + let records = array( + request, + offset, + count, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_ALIGNMENT, + )?; + for (index, record) in records + .chunks_exact(abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize) + .enumerate() + { + let opcode = byte(record, abi::ENGINE_PARAGRAPH_MUTATION_OPCODE)?; + let paragraph_id = read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID)?; + let order = read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER)?; + if paragraph_id == 0 + || byte(record, abi::ENGINE_PARAGRAPH_MUTATION_FLAGS)? != 0 + || read_u16(record, abi::ENGINE_PARAGRAPH_MUTATION_RESERVED0)? != 0 + || !matches!(opcode, PARAGRAPH_MUTATION_UPSERT | PARAGRAPH_MUTATION_REMOVE) + || (opcode == PARAGRAPH_MUTATION_REMOVE && order != 0) + || prior_u32_duplicate( + records, + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE, + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + index, + paragraph_id, + )? + || (opcode == PARAGRAPH_MUTATION_UPSERT + && prior_upsert_order_duplicate(records, index, order)?) + { + return Err(STATUS_INVALID_REQUEST); + } + } + Ok(ParagraphMutationBatch { request, records }) +} + +fn prior_upsert_order_duplicate(records: &[u8], index: usize, order: u32) -> Result { + for record in records[..index * abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize] + .chunks_exact(abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize) + { + if byte(record, abi::ENGINE_PARAGRAPH_MUTATION_OPCODE)? == PARAGRAPH_MUTATION_UPSERT + && read_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER)? == order + { + return Ok(true); + } + } + Ok(false) +} + pub(crate) fn parse_text_mutations( request: &[u8], offset: u32, @@ -1584,6 +1728,62 @@ mod tests { }; use alloc::vec; + #[test] + fn validates_explicit_paragraph_order_and_removal_records() { + let offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let stride = abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize; + let mut bytes = vec![0; offset + 2 * stride]; + bytes[offset + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_UPSERT; + write_u32( + &mut bytes, + offset + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + 7, + ); + write_u32( + &mut bytes, + offset + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, + 3, + ); + let second = offset + stride; + bytes[second + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_REMOVE; + write_u32( + &mut bytes, + second + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + 8, + ); + let batch = parse_paragraph_mutations(&bytes, offset as u32, 2).unwrap(); + assert_eq!( + batch.get(0), + Some(ParagraphMutation::Upsert { + paragraph_id: 7, + order: 3, + }) + ); + assert_eq!( + batch.get(1), + Some(ParagraphMutation::Remove { paragraph_id: 8 }) + ); + + write_u32( + &mut bytes, + second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, + 1, + ); + assert!(parse_paragraph_mutations(&bytes, offset as u32, 2).is_err()); + write_u32( + &mut bytes, + second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, + 0, + ); + bytes[second + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_UPSERT; + write_u32( + &mut bytes, + second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, + 3, + ); + assert!(parse_paragraph_mutations(&bytes, offset as u32, 2).is_err()); + } + #[test] fn validates_borrowed_style_snapshots_and_canonical_removals() { let bytes = valid_style_bytes(); @@ -1980,6 +2180,7 @@ mod tests { fn limits() -> UpdateLimits { UpdateLimits { + max_paragraphs: 4, max_clusters: 16, max_lines: 16, max_regions: 4, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index c144bc0e..68a66410 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1692,6 +1692,21 @@ fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineErro } fn request_paragraph_id(request: UpdateRequest<'_>) -> Result, EngineError> { + let mut declared_id = None; + for index in 0..request.paragraph_mutations.len() { + match request + .paragraph_mutations + .get(index) + .ok_or(EngineError::InvalidRequest)? + { + super::semantic_wire::ParagraphMutation::Upsert { paragraph_id, .. } => { + merge_paragraph_id(&mut declared_id, Some(paragraph_id))?; + } + super::semantic_wire::ParagraphMutation::Remove { .. } => { + return Err(EngineError::InvalidRequest); + } + } + } let mut paragraph_id = None; for index in 0..request.text_mutations.len() { merge_paragraph_id(&mut paragraph_id, request.text_mutations.paragraph_id(index))?; @@ -1707,7 +1722,10 @@ fn request_paragraph_id(request: UpdateRequest<'_>) -> Result, Engin for index in 0..geometry_count { merge_paragraph_id(&mut paragraph_id, request.geometry.paragraph_id(index))?; } - Ok(paragraph_id) + if declared_id.is_some() && paragraph_id.is_some() && declared_id != paragraph_id { + return Err(EngineError::InvalidRequest); + } + Ok(declared_id.or(paragraph_id)) } fn merge_paragraph_id( @@ -2417,6 +2435,7 @@ mod tests { policy_handle: 9, capability_set: 1, limits: super::super::frame::UpdateLimits { + max_paragraphs: 1, max_clusters: 1, max_lines: 1, max_regions: 1, @@ -2425,6 +2444,7 @@ mod tests { max_slots_per_band: 1, max_output_bytes: 128, }, + paragraph_mutations: super::super::semantic_wire::ParagraphMutationBatch::empty(), text_mutations: super::super::semantic_wire::TextMutationBatch::empty(), style_mutations: super::super::semantic_wire::StyleMutationBatch::empty(), geometry: super::super::semantic_wire::GeometryBatch::empty(), diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index f78b0ab2..fe525d25 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -94,6 +94,10 @@ export const textShaperAbi = { "ellipsis": 3, "visible": 1 }, + "paragraphMutationOpcodes": { + "remove": 2, + "upsert": 1 + }, "patchOpcodes": { "allocateOrResize": 1, "copy": 4, @@ -363,6 +367,15 @@ export const textShaperAbi = { "size": 60, "textOffset": 8 }, + "engineParagraphMutation": { + "alignment": 4, + "flags": 1, + "opcode": 0, + "order": 8, + "paragraphId": 4, + "reserved0": 2, + "size": 12 + }, "enginePatch": { "alignment": 4, "bufferGeneration": 8, @@ -565,8 +578,11 @@ export const textShaperAbi = { "maxInlineObjects": 56, "maxLines": 44, "maxOutputBytes": 64, + "maxParagraphs": 124, "maxRegions": 48, "maxSlotsPerBand": 60, + "paragraphMutationCount": 132, + "paragraphMutationsOffset": 128, "policyHandle": 24, "policyParametersLength": 120, "policyParametersOffset": 116, @@ -574,7 +590,7 @@ export const textShaperAbi = { "regionsOffset": 92, "semanticViewMask": 36, "sessionId": 8, - "size": 124, + "size": 136, "styleMutationCount": 80, "styleMutationsOffset": 76, "textMutationCount": 72, diff --git a/packages/text/src/internal/engine-frame-wire.ts b/packages/text/src/internal/engine-frame-wire.ts index cae388cc..eff3d094 100644 --- a/packages/text/src/internal/engine-frame-wire.ts +++ b/packages/text/src/internal/engine-frame-wire.ts @@ -4,6 +4,7 @@ const MAX_U32 = 0xffff_ffff; const encoder = new TextEncoder(); export interface TextEngineFrameLimits { + readonly maxParagraphs: number; readonly maxClusters: number; readonly maxLines: number; readonly maxRegions: number; @@ -13,6 +14,10 @@ export interface TextEngineFrameLimits { readonly maxOutputBytes: number; } +export type TextEngineParagraphMutation = + | { readonly opcode: 'upsert'; readonly paragraphId: number; readonly order: number } + | { readonly opcode: 'remove'; readonly paragraphId: number }; + export interface TextEngineTextMutation { readonly paragraphId: number; readonly start: number; @@ -155,6 +160,7 @@ export interface TextEngineFrameUpdate { readonly acknowledgedPublicationGeneration: number; readonly semanticViewMask?: number; readonly limits: TextEngineFrameLimits; + readonly paragraphMutations?: readonly TextEngineParagraphMutation[]; readonly textMutations?: readonly TextEngineTextMutation[]; readonly styleMutations?: readonly TextEngineStyleMutation[]; readonly constraints?: readonly TextEngineConstraint[]; @@ -168,6 +174,7 @@ export interface TextEngineFrameUpdate { export function compileTextEngineFrameUpdate(frame: TextEngineFrameUpdate): Uint8Array { const abi = textShaperAbi; const request = abi.layouts.engineUpdateRequest; + const paragraphMutations = frame.paragraphMutations ?? []; const textMutations = frame.textMutations ?? []; const styleMutations = frame.styleMutations ?? []; const constraints = frame.constraints ?? []; @@ -181,6 +188,12 @@ export function compileTextEngineFrameUpdate(frame: TextEngineFrameUpdate): Uint cursor = checkedAdd(offset, checkedProduct(count, stride, label), label); return offset; }; + const paragraphOffset = allocate( + paragraphMutations.length, + abi.layouts.engineParagraphMutation.size, + abi.layouts.engineParagraphMutation.alignment, + 'paragraph mutations', + ); const textOffset = allocate(textMutations.length, abi.layouts.engineTextMutation.size, 4, 'text mutations'); const styleOffset = allocate(styleMutations.length, abi.layouts.engineStyleMutation.size, 4, 'style mutations'); const constraintOffset = allocate(constraints.length, abi.layouts.engineConstraint.size, 4, 'constraints'); @@ -215,6 +228,7 @@ export function compileTextEngineFrameUpdate(frame: TextEngineFrameUpdate): Uint writeHeader(view, frame, bytes.length, { textOffset, + paragraphOffset, styleOffset, constraintOffset, regionOffset, @@ -222,6 +236,7 @@ export function compileTextEngineFrameUpdate(frame: TextEngineFrameUpdate): Uint inlineObjectOffset, policyParametersOffset, }); + writeParagraphMutations(view, paragraphOffset, paragraphMutations); writeTextMutations(view, textOffset, textMutations, textPayloads); writeStyleMutations(view, bytes, styleOffset, styleMutations, languageBytes, languageOffsets, featureOffsets); writeConstraints(view, constraintOffset, constraints); @@ -233,6 +248,7 @@ export function compileTextEngineFrameUpdate(frame: TextEngineFrameUpdate): Uint } interface HeaderOffsets { + readonly paragraphOffset: number; readonly textOffset: number; readonly styleOffset: number; readonly constraintOffset: number; @@ -255,6 +271,7 @@ function writeHeader(view: DataView, frame: TextEngineFrameUpdate, byteLength: n ['policyHandle', frame.policyHandle], ['capabilitySet', frame.capabilitySet], ['semanticViewMask', frame.semanticViewMask ?? 0], + ['maxParagraphs', limits.maxParagraphs], ['maxClusters', limits.maxClusters], ['maxLines', limits.maxLines], ['maxRegions', limits.maxRegions], @@ -262,6 +279,8 @@ function writeHeader(view: DataView, frame: TextEngineFrameUpdate, byteLength: n ['maxInlineObjects', limits.maxInlineObjects], ['maxSlotsPerBand', limits.maxSlotsPerBand], ['maxOutputBytes', limits.maxOutputBytes], + ['paragraphMutationsOffset', offsets.paragraphOffset], + ['paragraphMutationCount', frame.paragraphMutations?.length ?? 0], ['textMutationsOffset', offsets.textOffset], ['textMutationCount', frame.textMutations?.length ?? 0], ['styleMutationsOffset', offsets.styleOffset], @@ -281,6 +300,23 @@ function writeHeader(view: DataView, frame: TextEngineFrameUpdate, byteLength: n } } +function writeParagraphMutations( + view: DataView, + tableOffset: number, + mutations: readonly TextEngineParagraphMutation[], +): void { + const layout = textShaperAbi.layouts.engineParagraphMutation; + const opcodes = textShaperAbi.engine.paragraphMutationOpcodes; + for (const [index, mutation] of mutations.entries()) { + const offset = tableOffset + index * layout.size; + view.setUint8(offset + layout.opcode, opcodes[mutation.opcode]); + view.setUint32(offset + layout.paragraphId, u32(mutation.paragraphId, 'paragraph ID'), true); + if (mutation.opcode === 'upsert') { + view.setUint32(offset + layout.order, u32(mutation.order, 'paragraph order'), true); + } + } +} + function writeTextMutations( view: DataView, tableOffset: number, diff --git a/packages/text/tests/integration/engine-frame-wire.test.mjs b/packages/text/tests/integration/engine-frame-wire.test.mjs index ef9eee5f..b441508f 100644 --- a/packages/text/tests/integration/engine-frame-wire.test.mjs +++ b/packages/text/tests/integration/engine-frame-wire.test.mjs @@ -30,11 +30,13 @@ test('production frame compiler preserves the established benchmark request byte acknowledgedPublicationGeneration: 0, limits: { ...limits, + maxParagraphs: 1, maxRegions: 1, maxExclusions: 1, maxInlineObjects: 1, maxSlotsPerBand: 1, }, + paragraphMutations: [{ opcode: 'upsert', paragraphId: 1, order: 0 }], textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: text }], styleMutations: [ { @@ -105,6 +107,7 @@ test('production frame compiler carries full style, polygon, exclusion, and inli acknowledgedPublicationGeneration: 5, semanticViewMask: 6, limits: { + maxParagraphs: 4, maxClusters: 32, maxLines: 16, maxRegions: 2, @@ -113,6 +116,7 @@ test('production frame compiler carries full style, polygon, exclusion, and inli maxSlotsPerBand: 3, maxOutputBytes: 1_048_576, }, + paragraphMutations: [{ opcode: 'upsert', paragraphId: 3, order: 2 }], styleMutations: [ { opcode: 'upsert', diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index e5237cc7..ea25702d 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -100,6 +100,7 @@ test('Three coordinator shares shaping data across technique bindings and refere consumedPlanRevision: 0, acknowledgedPublicationGeneration: 0, limits: { + maxParagraphs: 1, maxClusters: 16, maxLines: 8, maxRegions: 1, @@ -108,6 +109,7 @@ test('Three coordinator shares shaping data across technique bindings and refere maxSlotsPerBand: 2, maxOutputBytes: 1024 * 1024, }, + paragraphMutations: [{ opcode: 'upsert', paragraphId: 1, order: 0 }], textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: 'abc' }], styleMutations: [ { diff --git a/packages/text/tests/support/engine-abi.d.mts b/packages/text/tests/support/engine-abi.d.mts index ef7327df..973a723c 100644 --- a/packages/text/tests/support/engine-abi.d.mts +++ b/packages/text/tests/support/engine-abi.d.mts @@ -43,6 +43,7 @@ export interface EngineFrameUpdateFields { readonly revision: number; }; readonly limits: { + readonly maxParagraphs?: number; readonly maxClusters: number; readonly maxLines: number; readonly maxOutputBytes: number; diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index fd586a22..21434f4e 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -79,6 +79,7 @@ export function engineUpdateBytes( view.setUint32(layout.policyHandle, policyHandle, true); view.setUint32(layout.capabilitySet, 1, true); for (const field of [ + 'maxParagraphs', 'maxClusters', 'maxLines', 'maxRegions', @@ -127,11 +128,15 @@ export function engineFrameUpdateBytes( }, ) { const request = abi.layouts.engineUpdateRequest; + const paragraphRecord = abi.layouts.engineParagraphMutation; const textRecord = abi.layouts.engineTextMutation; const styleRecord = abi.layouts.engineStyleMutation; const constraint = abi.layouts.engineConstraint; const region = abi.layouts.engineRegion; let cursor = request.size; + const hasParagraph = textMutation !== undefined || style !== undefined || geometry !== undefined; + const paragraphRecordOffset = hasParagraph ? align(cursor, paragraphRecord.alignment) : 0; + if (hasParagraph) cursor = paragraphRecordOffset + paragraphRecord.size; const textRecordOffset = textMutation === undefined ? 0 : cursor; if (textMutation !== undefined) cursor += textRecord.size; const styleRecordOffset = style === undefined ? 0 : align(cursor, styleRecord.alignment); @@ -152,6 +157,7 @@ export function engineFrameUpdateBytes( view.setUint32(request.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); view.setUint32(request.policyHandle, policyHandle, true); view.setUint32(request.capabilitySet, 1, true); + view.setUint32(request.maxParagraphs, 1, true); view.setUint32(request.maxClusters, limits.maxClusters, true); view.setUint32(request.maxLines, limits.maxLines, true); view.setUint32(request.maxRegions, 1, true); @@ -159,6 +165,8 @@ export function engineFrameUpdateBytes( view.setUint32(request.maxInlineObjects, 1, true); view.setUint32(request.maxSlotsPerBand, 1, true); view.setUint32(request.maxOutputBytes, limits.maxOutputBytes, true); + view.setUint32(request.paragraphMutationsOffset, paragraphRecordOffset, true); + view.setUint32(request.paragraphMutationCount, hasParagraph ? 1 : 0, true); view.setUint32(request.textMutationsOffset, textRecordOffset, true); view.setUint32(request.textMutationCount, textMutation === undefined ? 0 : 1, true); view.setUint32(request.styleMutationsOffset, styleRecordOffset, true); @@ -168,6 +176,12 @@ export function engineFrameUpdateBytes( view.setUint32(request.regionsOffset, regionOffset, true); view.setUint32(request.regionCount, geometry === undefined ? 0 : 1, true); + if (hasParagraph) { + view.setUint8(paragraphRecordOffset + paragraphRecord.opcode, abi.engine.paragraphMutationOpcodes.upsert); + view.setUint32(paragraphRecordOffset + paragraphRecord.paragraphId, 1, true); + view.setUint32(paragraphRecordOffset + paragraphRecord.order, 0, true); + } + if (textMutation !== undefined) { view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); view.setUint8(textRecordOffset + textRecord.encoding, abi.engine.textEncodings.utf16Le); From ece046d6b10787289502cbcee8cf408305fda9ca Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 21:51:11 -0400 Subject: [PATCH 059/128] refactor(text): separate paragraph engine state --- docs/packages/text.md | 8 +- docs/planning/decision-register.md | 2 + packages/text/rust/shaper/src/engine/state.rs | 348 ++++++++++-------- 3 files changed, 207 insertions(+), 151 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index 33e16bf7..653c4f4e 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:5588a018d8d9306184385c082648762af3d5dbc164a5cb70494e139cab364134' +source_digest: 'sha256:6d1fd8d4b1d3f4ace288388723ae75556602c8aefabe25527e2fec64b2b699aa' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -800,6 +800,12 @@ count above the frame's paragraph limit. The production frame compiler emits thi semantic records. The reachable validation path changes optimized Wasm to 1,073,123 raw / 403,431 gzip / 320,917 Brotli bytes (+2,450 / +1,254 / +1,195 over the keyed-record checkpoint). The retained-state consumer is the next checkpoint, so removal is currently rejected before mutation rather than falsely accepted. Adjacent +session ownership is now split without changing behavior: batch revision, policy binding, and render-plan compilation +remain on `EngineSession`, while the full text/style/Unicode/bidi/shaping/cluster/flow/positioning transaction lives in +one `ParagraphState`. This preserves the existing single child while making the next map conversion explicit and +testable. All 124 Rust unit tests pass. The optimized Wasm is 1,073,179 raw / 403,475 gzip / 321,149 Brotli bytes, ++56 / +44 / +232 over the paragraph-control checkpoint; no latency claim is attached to this ownership-only move. +Adjacent 8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and 6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / 319,914 raw/gzip/Brotli bytes, +105 / +40 / +252 from D-205. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index ea096ee1..f4efe76e 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -274,6 +274,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-207 | Paragraph lifecycle identity does not determine presentation order. The frame ABI carries a separate 12-byte paragraph-control record that explicitly upserts ordered batch membership or removes retained state. The decoder rejects zero and duplicate IDs, duplicate declared orders, noncanonical removals, forged section overlap, and counts above the frame paragraph limit. Text, style, constraint, and inline-object records continue to name their owning paragraph independently. The production compiler emits control records before paragraph-owned semantics; the transitional single-child state rejects removal until the retained-child consumer lands. The reachable validation path changes optimized Wasm from 1,070,673 / 402,177 / 319,722 to 1,073,123 / 403,431 / 320,917 raw/gzip/Brotli bytes. | Accepted | +| D-208 | Batch-global and paragraph-local state have distinct Rust owners before multiple children are admitted. `EngineSession` retains revision/fence state, the pinned policy identity, and the shared render-plan compiler. `ParagraphState` owns text/style mutation scratch and every retained Unicode, bidi, shaping, cluster, flow, and positioned-glyph A/B arena. The first checkpoint preserves the existing single child and exact transaction behavior; 124 Rust unit tests pass. Optimized Wasm changes from 1,073,123 / 403,431 / 320,917 to 1,073,179 / 403,475 / 321,149 raw/gzip/Brotli bytes. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 68a66410..25b60ba2 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -85,6 +85,11 @@ struct EngineSession { acknowledged_publication_generation: u32, policy_binding: Option, plan: RenderPlanCompiler, + paragraph: ParagraphState, +} + +#[derive(Default)] +struct ParagraphState { text: Vec, pending_text: Vec, text_unit_ids: Vec, @@ -346,23 +351,27 @@ impl TextEngine { return Err(EngineError::SessionConflict); } let mut session = EngineSession::default(); - session.styles.reserve_default()?; - session.pending_styles.reserve_default()?; - session.resolved_styles.reserve_default()?; - session.pending_resolved_styles.reserve_default()?; + session.paragraph.styles.reserve_default()?; + session.paragraph.pending_styles.reserve_default()?; + session.paragraph.resolved_styles.reserve_default()?; + session.paragraph.pending_resolved_styles.reserve_default()?; session + .paragraph .style_mutation_scratch .try_reserve_exact(DEFAULT_STYLE_CAPACITY) .map_err(|_| EngineError::ResultTooLarge)?; session + .paragraph .style_order_scratch .try_reserve_exact(DEFAULT_STYLE_CAPACITY) .map_err(|_| EngineError::ResultTooLarge)?; session + .paragraph .style_nesting_scratch .try_reserve_exact(DEFAULT_STYLE_CAPACITY) .map_err(|_| EngineError::ResultTooLarge)?; session + .paragraph .style_resolution_scratch .try_reserve_exact(DEFAULT_STYLE_CAPACITY) .map_err(|_| EngineError::ResultTooLarge)?; @@ -383,36 +392,37 @@ impl TextEngine { .sessions .get_mut(&handle) .ok_or(EngineError::SessionMissing)?; - reserve_text_buffer(&mut session.text, capacity)?; - reserve_text_buffer(&mut session.pending_text, capacity)?; - reserve_vec(&mut session.text_unit_ids, capacity)?; - reserve_vec(&mut session.pending_text_unit_ids, capacity)?; - session.unicode.reserve(capacity).map_err(unicode_error)?; - session + let paragraph = &mut session.paragraph; + reserve_text_buffer(&mut paragraph.text, capacity)?; + reserve_text_buffer(&mut paragraph.pending_text, capacity)?; + reserve_vec(&mut paragraph.text_unit_ids, capacity)?; + reserve_vec(&mut paragraph.pending_text_unit_ids, capacity)?; + paragraph.unicode.reserve(capacity).map_err(unicode_error)?; + paragraph .pending_unicode .reserve(capacity) .map_err(unicode_error)?; - session.bidi.reserve(capacity).map_err(bidi_error)?; - session.pending_bidi.reserve(capacity).map_err(bidi_error)?; - session.shaping_runs.reserve(capacity)?; - session.pending_shaping_runs.reserve(capacity)?; + paragraph.bidi.reserve(capacity).map_err(bidi_error)?; + paragraph.pending_bidi.reserve(capacity).map_err(bidi_error)?; + paragraph.shaping_runs.reserve(capacity)?; + paragraph.pending_shaping_runs.reserve(capacity)?; let glyph_capacity = capacity.saturating_mul(2); - session.shape.reserve(glyph_capacity)?; - session.pending_shape.reserve(glyph_capacity)?; - session.clusters.reserve(capacity)?; - session.pending_clusters.reserve(capacity)?; - session.flow_layout.reserve(capacity, 1)?; - session.pending_flow_layout.reserve(capacity, 1)?; - session.positioned.reserve(glyph_capacity)?; - session.pending_positioned.reserve(glyph_capacity)?; - session + paragraph.shape.reserve(glyph_capacity)?; + paragraph.pending_shape.reserve(glyph_capacity)?; + paragraph.clusters.reserve(capacity)?; + paragraph.pending_clusters.reserve(capacity)?; + paragraph.flow_layout.reserve(capacity, 1)?; + paragraph.pending_flow_layout.reserve(capacity, 1)?; + paragraph.positioned.reserve(glyph_capacity)?; + paragraph.pending_positioned.reserve(glyph_capacity)?; + paragraph .glyph_identity_index .prepare(glyph_capacity) .map_err(|_| EngineError::ResultTooLarge)?; - reserve_vec(&mut session.fallback_spans, capacity)?; - reserve_vec(&mut session.pending_fallback_spans, capacity)?; - reserve_vec(&mut session.fallback_span_scratch, capacity)?; - reserve_vec(&mut session.fallback_cluster_scratch, glyph_capacity)?; + reserve_vec(&mut paragraph.fallback_spans, capacity)?; + reserve_vec(&mut paragraph.pending_fallback_spans, capacity)?; + reserve_vec(&mut paragraph.fallback_span_scratch, capacity)?; + reserve_vec(&mut paragraph.fallback_cluster_scratch, glyph_capacity)?; Ok(()) } @@ -427,7 +437,7 @@ impl TextEngine { pub(crate) fn session_text(&self, handle: u32) -> Result<&[u16], EngineError> { self.sessions .get(&handle) - .map(|session| session.text.as_slice()) + .map(|session| session.paragraph.text.as_slice()) .ok_or(EngineError::SessionMissing) } @@ -435,7 +445,7 @@ impl TextEngine { pub(crate) fn session_style_count(&self, handle: u32) -> Result { self.sessions .get(&handle) - .map(|session| session.styles.len()) + .map(|session| session.paragraph.styles.len()) .ok_or(EngineError::SessionMissing) } @@ -443,7 +453,7 @@ impl TextEngine { pub(crate) fn session_style_segment_count(&self, handle: u32) -> Result { self.sessions .get(&handle) - .map(|session| session.resolved_styles.segments().len()) + .map(|session| session.paragraph.resolved_styles.segments().len()) .ok_or(EngineError::SessionMissing) } @@ -451,7 +461,7 @@ impl TextEngine { pub(crate) fn session_shaping_run_count(&self, handle: u32) -> Result { self.sessions .get(&handle) - .map(|session| session.shaping_runs.runs().len()) + .map(|session| session.paragraph.shaping_runs.runs().len()) .ok_or(EngineError::SessionMissing) } @@ -542,70 +552,71 @@ impl TextEngine { // A completed renderer fence is external monotonic state. It remains accepted even if // plan preparation or publication later aborts. session.acknowledged_publication_generation = request.acknowledged_publication_generation; - session.prepare_text(request.text_mutations)?; - if let Err(error) = session.prepare_styles(request.style_mutations, |handle| { + let paragraph = &mut session.paragraph; + paragraph.prepare_text(request.text_mutations)?; + if let Err(error) = paragraph.prepare_styles(request.style_mutations, |handle| { font_stacks .binary_search_by_key(&handle, |stack| stack.handle) .is_ok() }) { - session.abort_text(); + paragraph.abort_text(); return Err(error); } - if let Err(error) = session.prepare_unicode() { - session.abort_text(); - session.abort_styles(); + if let Err(error) = paragraph.prepare_unicode() { + paragraph.abort_text(); + paragraph.abort_styles(); return Err(error); } - if let Err(error) = session.prepare_bidi() { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); + if let Err(error) = paragraph.prepare_bidi() { + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); return Err(error); } - if let Err(error) = session.prepare_shaping_runs() { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); + if let Err(error) = paragraph.prepare_shaping_runs() { + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); return Err(error); } if let Some(shaper) = shaper.as_deref_mut() { - if let Err(error) = session.prepare_shape(shaper, font_stacks, font_bindings) { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); + if let Err(error) = paragraph.prepare_shape(shaper, font_stacks, font_bindings) { + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); + paragraph.abort_shaping_runs(); return Err(error); } - if let Err(error) = session.prepare_clusters(shaper) { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); + if let Err(error) = paragraph.prepare_clusters(shaper) { + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); + paragraph.abort_shaping_runs(); + paragraph.abort_shape(); + paragraph.abort_clusters(); return Err(error); } } - if let Err(error) = session.prepare_geometry(request.geometry) { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); + if let Err(error) = paragraph.prepare_geometry(request.geometry) { + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); + paragraph.abort_shaping_runs(); + paragraph.abort_shape(); + paragraph.abort_clusters(); return Err(error); } - let flow_changed = session.clusters_prepared - || session.geometry_prepared - || session.style_invalidation.metrics; - let positioned_changed = flow_changed || session.style_invalidation.positioning; + let flow_changed = paragraph.clusters_prepared + || paragraph.geometry_prepared + || paragraph.style_invalidation.metrics; + let positioned_changed = flow_changed || paragraph.style_invalidation.positioning; if let Some(shaper) = shaper { if flow_changed - && let Err(error) = session.prepare_flow_layout( + && let Err(error) = paragraph.prepare_flow_layout( shaper, font_stacks, font_bindings, @@ -613,28 +624,28 @@ impl TextEngine { request.limits.max_slots_per_band, ) { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); - session.abort_geometry(); - session.abort_flow_layout(); + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); + paragraph.abort_shaping_runs(); + paragraph.abort_shape(); + paragraph.abort_clusters(); + paragraph.abort_geometry(); + paragraph.abort_flow_layout(); return Err(error); } - if positioned_changed && let Err(error) = session.prepare_positioned(shaper) { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); - session.abort_geometry(); - session.abort_flow_layout(); - session.abort_positioned(); + if positioned_changed && let Err(error) = paragraph.prepare_positioned(shaper) { + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); + paragraph.abort_shaping_runs(); + paragraph.abort_shape(); + paragraph.abort_clusters(); + paragraph.abort_geometry(); + paragraph.abort_flow_layout(); + paragraph.abort_positioned(); return Err(error); } } @@ -647,10 +658,10 @@ impl TextEngine { let plan_result = if reuse_ordered_plan { session.plan.prepare_reuse() } else { - let positioned = if session.positioned_prepared { - &session.pending_positioned + let positioned = if paragraph.positioned_prepared { + ¶graph.pending_positioned } else { - &session.positioned + ¶graph.positioned }; let semantic_f32 = positioned.semantic_f32(); let semantic_u32 = positioned.semantic_u32(); @@ -671,16 +682,16 @@ impl TextEngine { .map(|binding| &binding.binding) }, ) { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); - session.abort_geometry(); - session.abort_flow_layout(); - session.abort_positioned(); + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); + paragraph.abort_shaping_runs(); + paragraph.abort_shape(); + paragraph.abort_clusters(); + paragraph.abort_geometry(); + paragraph.abort_flow_layout(); + paragraph.abort_positioned(); return Err(gather_error(error)); } let gathered = gather.view(); @@ -694,16 +705,16 @@ impl TextEngine { ) }; if let Err(error) = plan_result { - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); - session.abort_geometry(); - session.abort_flow_layout(); - session.abort_positioned(); + paragraph.abort_text(); + paragraph.abort_styles(); + paragraph.abort_unicode(); + paragraph.abort_bidi(); + paragraph.abort_shaping_runs(); + paragraph.abort_shape(); + paragraph.abort_clusters(); + paragraph.abort_geometry(); + paragraph.abort_flow_layout(); + paragraph.abort_positioned(); return Err(plan_error(error)); } Ok(PreparedUpdate { @@ -749,16 +760,16 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.plan.abort(); - session.abort_text(); - session.abort_styles(); - session.abort_unicode(); - session.abort_bidi(); - session.abort_shaping_runs(); - session.abort_shape(); - session.abort_clusters(); - session.abort_geometry(); - session.abort_flow_layout(); - session.abort_positioned(); + session.paragraph.abort_text(); + session.paragraph.abort_styles(); + session.paragraph.abort_unicode(); + session.paragraph.abort_bidi(); + session.paragraph.abort_shaping_runs(); + session.paragraph.abort_shape(); + session.paragraph.abort_clusters(); + session.paragraph.abort_geometry(); + session.paragraph.abort_flow_layout(); + session.paragraph.abort_positioned(); Ok(()) } @@ -774,16 +785,16 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.plan.commit().map_err(plan_error)?; - session.commit_text(); - session.commit_styles(); - session.commit_unicode(); - session.commit_bidi(); - session.commit_shaping_runs(); - session.commit_shape(); - session.commit_clusters(); - session.commit_geometry(); - session.commit_flow_layout(); - session.commit_positioned(); + session.paragraph.commit_text(); + session.paragraph.commit_styles(); + session.paragraph.commit_unicode(); + session.paragraph.commit_bidi(); + session.paragraph.commit_shaping_runs(); + session.paragraph.commit_shape(); + session.paragraph.commit_clusters(); + session.paragraph.commit_geometry(); + session.paragraph.commit_flow_layout(); + session.paragraph.commit_positioned(); session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, @@ -801,7 +812,7 @@ impl TextEngine { } } -impl EngineSession { +impl ParagraphState { fn prepare_text( &mut self, mutations: super::semantic_wire::TextMutationBatch<'_>, @@ -2074,12 +2085,16 @@ mod tests { assert!(engine.session_text(4).unwrap().is_empty()); engine.commit_update(prepared).unwrap(); assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); - assert_eq!(engine.sessions.get(&4).unwrap().text_unit_ids, [1, 2, 3, 4]); + assert_eq!( + engine.sessions.get(&4).unwrap().paragraph.text_unit_ids, + [1, 2, 3, 4] + ); assert_eq!( engine .sessions .get(&4) .unwrap() + .paragraph .unicode .grapheme_boundaries(), &[0, 1, 2, 3, 4] @@ -2094,8 +2109,8 @@ mod tests { engine.abort_update(prepared).unwrap(); assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); let session = engine.sessions.get(&4).unwrap(); - assert_eq!(session.text_unit_ids, [1, 2, 3, 4]); - assert_eq!(session.next_text_unit_id, 5); + assert_eq!(session.paragraph.text_unit_ids, [1, 2, 3, 4]); + assert_eq!(session.paragraph.next_text_unit_id, 5); let retry = engine.prepare_update(edit, 2).unwrap(); engine.commit_update(retry).unwrap(); @@ -2104,13 +2119,16 @@ mod tests { &[0x61, 0x58, 0x59, 0x63, 0x64, 0x21] ); assert_eq!( - engine.sessions.get(&4).unwrap().text_unit_ids, + engine.sessions.get(&4).unwrap().paragraph.text_unit_ids, [1, 5, 6, 3, 4, 7] ); let settled_capacities = { let session = engine.sessions.get(&4).unwrap(); - [session.text.capacity(), session.pending_text.capacity()] + [ + session.paragraph.text.capacity(), + session.paragraph.pending_text.capacity(), + ] }; let warm_bytes = text_mutation_bytes(&[(0, 1, &[0x7a])]); let warm_batch = @@ -2120,9 +2138,12 @@ mod tests { let prepared = engine.prepare_update(warm, 3).unwrap(); engine.commit_update(prepared).unwrap(); let session = engine.sessions.get(&4).unwrap(); - assert_eq!(session.text_unit_ids, [8, 5, 6, 3, 4, 7]); + assert_eq!(session.paragraph.text_unit_ids, [8, 5, 6, 3, 4, 7]); assert_eq!( - [session.pending_text.capacity(), session.text.capacity()], + [ + session.paragraph.pending_text.capacity(), + session.paragraph.text.capacity(), + ], settled_capacities ); } @@ -2144,9 +2165,9 @@ mod tests { Err(EngineError::InvalidRequest) ); let session = engine.sessions.get(&4).unwrap(); - assert!(session.text.is_empty()); - assert!(session.unicode.grapheme_boundaries().is_empty()); - assert!(session.bidi.levels.is_empty()); + assert!(session.paragraph.text.is_empty()); + assert!(session.paragraph.unicode.grapheme_boundaries().is_empty()); + assert!(session.paragraph.bidi.levels.is_empty()); } #[test] @@ -2164,16 +2185,43 @@ mod tests { parse_text_mutations(&text_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); let prepared = engine.prepare_update(text, 1).unwrap(); engine.commit_update(prepared).unwrap(); - assert_eq!(engine.sessions.get(&4).unwrap().bidi.paragraph_levels, &[0]); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .paragraph + .bidi + .paragraph_levels, + &[0] + ); let root_bytes = root_style_bytes_with_direction(7, DIRECTION_RTL); let mut root = update(1, 1, 1); root.style_mutations = parse_style_mutations(&root_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); let prepared = engine.prepare_update(root, 2).unwrap(); - assert_eq!(engine.sessions.get(&4).unwrap().bidi.paragraph_levels, &[0]); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .paragraph + .bidi + .paragraph_levels, + &[0] + ); engine.commit_update(prepared).unwrap(); - assert_eq!(engine.sessions.get(&4).unwrap().bidi.paragraph_levels, &[1]); + assert_eq!( + engine + .sessions + .get(&4) + .unwrap() + .paragraph + .bidi + .paragraph_levels, + &[1] + ); } #[test] From a405c15947e6163de9908aba1f60d46d8322ed7c Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 21:56:16 -0400 Subject: [PATCH 060/128] refactor(text): allocate plan identities per session --- docs/packages/text.md | 7 ++- docs/planning/decision-register.md | 2 + packages/text/rust/shaper/src/engine/state.rs | 48 ++++++++++++------- 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index 653c4f4e..47df9270 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6d1fd8d4b1d3f4ace288388723ae75556602c8aefabe25527e2fec64b2b699aa' +source_digest: 'sha256:7a8fc766c273b8e0eb5fa8c2b6367a0dedc25e3ea99684e10e696bd25b7ef103' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -805,6 +805,11 @@ remain on `EngineSession`, while the full text/style/Unicode/bidi/shaping/cluste one `ParagraphState`. This preserves the existing single child while making the next map conversion explicit and testable. All 124 Rust unit tests pass. The optimized Wasm is 1,073,179 raw / 403,475 gzip / 321,149 Brotli bytes, +56 / +44 / +232 over the paragraph-control checkpoint; no latency claim is attached to this ownership-only move. +Stable glyph IDs and semantic content revisions now allocate from transaction-local counters rooted in the owning +`EngineSession`, then commit only after shared-plan publication succeeds. `ParagraphState` retains its identity indexes +but no longer owns counter namespaces, preventing equal child-local ordinals from aliasing in one planner. The +single-child behavior remains byte-identical; the optimized Wasm is 1,073,074 raw / 403,537 gzip / 321,046 Brotli bytes +(-105 / +62 / -103 versus the ownership split), which is compression/code-layout noise rather than a size claim. Adjacent 8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and 6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index f4efe76e..93bff1c4 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -276,6 +276,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-208 | Batch-global and paragraph-local state have distinct Rust owners before multiple children are admitted. `EngineSession` retains revision/fence state, the pinned policy identity, and the shared render-plan compiler. `ParagraphState` owns text/style mutation scratch and every retained Unicode, bidi, shaping, cluster, flow, and positioned-glyph A/B arena. The first checkpoint preserves the existing single child and exact transaction behavior; 124 Rust unit tests pass. Optimized Wasm changes from 1,073,123 / 403,431 / 320,917 to 1,073,179 / 403,475 / 321,149 raw/gzip/Brotli bytes. | Accepted | +| D-209 | Stable glyph IDs and semantic content revisions are session-wide monotonic namespaces because one shared planner cannot distinguish equal paragraph-local ordinals. Paragraph identity indexes remain child-local, but prepare borrows transaction-local counters initialized from the session; abort discards them and successful shared-plan commit advances the session counters. The single-child checkpoint preserves exact behavior and all 124 Rust unit tests. Optimized Wasm changes from 1,073,179 / 403,475 / 321,149 to 1,073,074 / 403,537 / 321,046 raw/gzip/Brotli bytes, a size-neutral code-layout movement. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 25b60ba2..8feb66b1 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -85,6 +85,10 @@ struct EngineSession { acknowledged_publication_generation: u32, policy_binding: Option, plan: RenderPlanCompiler, + next_glyph_id: u32, + pending_next_glyph_id: u32, + next_content_revision: u32, + pending_next_content_revision: u32, paragraph: ParagraphState, } @@ -112,10 +116,6 @@ struct ParagraphState { clusters: ClusterArena, pending_clusters: ClusterArena, glyph_identity_index: IdentityIndex, - next_glyph_id: u32, - pending_next_glyph_id: u32, - next_content_revision: u32, - pending_next_content_revision: u32, geometry: FlowGeometryArena, pending_geometry: FlowGeometryArena, flow_layout: FlowLayoutArena, @@ -552,6 +552,8 @@ impl TextEngine { // A completed renderer fence is external monotonic state. It remains accepted even if // plan preparation or publication later aborts. session.acknowledged_publication_generation = request.acknowledged_publication_generation; + let mut next_glyph_id = session.next_glyph_id.max(1); + let mut next_content_revision = session.next_content_revision.max(1); let paragraph = &mut session.paragraph; paragraph.prepare_text(request.text_mutations)?; if let Err(error) = paragraph.prepare_styles(request.style_mutations, |handle| { @@ -589,7 +591,7 @@ impl TextEngine { paragraph.abort_shaping_runs(); return Err(error); } - if let Err(error) = paragraph.prepare_clusters(shaper) { + if let Err(error) = paragraph.prepare_clusters(shaper, &mut next_glyph_id) { paragraph.abort_text(); paragraph.abort_styles(); paragraph.abort_unicode(); @@ -635,7 +637,10 @@ impl TextEngine { paragraph.abort_flow_layout(); return Err(error); } - if positioned_changed && let Err(error) = paragraph.prepare_positioned(shaper) { + if positioned_changed + && let Err(error) = + paragraph.prepare_positioned(shaper, &mut next_content_revision) + { paragraph.abort_text(); paragraph.abort_styles(); paragraph.abort_unicode(); @@ -717,6 +722,8 @@ impl TextEngine { paragraph.abort_positioned(); return Err(plan_error(error)); } + session.pending_next_glyph_id = next_glyph_id; + session.pending_next_content_revision = next_content_revision; Ok(PreparedUpdate { session_id: request.session_id, previous: session.revision, @@ -770,6 +777,8 @@ impl TextEngine { session.paragraph.abort_geometry(); session.paragraph.abort_flow_layout(); session.paragraph.abort_positioned(); + session.pending_next_glyph_id = 0; + session.pending_next_content_revision = 0; Ok(()) } @@ -795,6 +804,10 @@ impl TextEngine { session.paragraph.commit_geometry(); session.paragraph.commit_flow_layout(); session.paragraph.commit_positioned(); + session.next_glyph_id = session.pending_next_glyph_id; + session.next_content_revision = session.pending_next_content_revision; + session.pending_next_glyph_id = 0; + session.pending_next_content_revision = 0; session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, @@ -1246,7 +1259,11 @@ impl ParagraphState { self.abort_shape(); } - fn prepare_clusters(&mut self, shaper: &ShaperRegistry) -> Result<(), EngineError> { + fn prepare_clusters( + &mut self, + shaper: &ShaperRegistry, + next_glyph_id: &mut u32, + ) -> Result<(), EngineError> { self.abort_clusters(); if !self.shape_prepared && !self.style_invalidation.metrics { return Ok(()); @@ -1278,7 +1295,6 @@ impl ParagraphState { }; if runs.is_empty() { self.pending_clusters.clear(); - self.pending_next_glyph_id = self.next_glyph_id.max(1); self.clusters_prepared = true; return Ok(()); } @@ -1297,11 +1313,10 @@ impl ParagraphState { }, |handle| shaper.font_metrics(handle), )?; - self.pending_next_glyph_id = self.next_glyph_id.max(1); if let Err(error) = self.pending_clusters.assign_stable_glyph_ids( &self.clusters, &mut self.glyph_identity_index, - &mut self.pending_next_glyph_id, + next_glyph_id, ) { self.abort_clusters(); return Err(error); @@ -1312,14 +1327,12 @@ impl ParagraphState { fn abort_clusters(&mut self) { self.pending_clusters.clear(); - self.pending_next_glyph_id = 0; self.clusters_prepared = false; } fn commit_clusters(&mut self) { if self.clusters_prepared { core::mem::swap(&mut self.clusters, &mut self.pending_clusters); - self.next_glyph_id = self.pending_next_glyph_id; } self.abort_clusters(); } @@ -1423,7 +1436,11 @@ impl ParagraphState { self.abort_flow_layout(); } - fn prepare_positioned(&mut self, shaper: &ShaperRegistry) -> Result<(), EngineError> { + fn prepare_positioned( + &mut self, + shaper: &ShaperRegistry, + next_content_revision: &mut u32, + ) -> Result<(), EngineError> { self.abort_positioned(); let text = if self.text_prepared { self.pending_text.as_slice() @@ -1460,7 +1477,6 @@ impl ParagraphState { } else { &self.flow_layout }; - self.pending_next_content_revision = self.next_content_revision.max(1); self.pending_positioned.build( &self.positioned, flow, @@ -1471,7 +1487,7 @@ impl ParagraphState { styles, bidi, &mut self.glyph_identity_index, - &mut self.pending_next_content_revision, + next_content_revision, |handle| shaper.font_metrics(handle), |handle, glyph| shaper.font_glyph_extents(handle, glyph), )?; @@ -1481,14 +1497,12 @@ impl ParagraphState { fn abort_positioned(&mut self) { self.pending_positioned.clear(); - self.pending_next_content_revision = 0; self.positioned_prepared = false; } fn commit_positioned(&mut self) { if self.positioned_prepared { core::mem::swap(&mut self.positioned, &mut self.pending_positioned); - self.next_content_revision = self.pending_next_content_revision; } self.abort_positioned(); } From 9caa9ce93c1acd6f5d530805f0622854bd77c7b3 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 22:02:24 -0400 Subject: [PATCH 061/128] feat(text): borrow paragraph record spans --- docs/packages/text.md | 8 +- docs/planning/decision-register.md | 2 + .../rust/shaper/src/engine/semantic_wire.rs | 143 ++++++++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 36 ++++- 4 files changed, 185 insertions(+), 4 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index 47df9270..abaac649 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:7a8fc766c273b8e0eb5fa8c2b6367a0dedc25e3ea99684e10e696bd25b7ef103' +source_digest: 'sha256:6616c7c15a04a52faf40f5e0d4d3c3df73bed49f879e93eb2657b5db79cffe9c' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -810,6 +810,12 @@ Stable glyph IDs and semantic content revisions now allocate from transaction-lo but no longer owns counter namespaces, preventing equal child-local ordinals from aliasing in one planner. The single-child behavior remains byte-identical; the optimized Wasm is 1,073,074 raw / 403,537 gzip / 321,046 Brotli bytes (-105 / +62 / -103 versus the ownership split), which is compression/code-layout noise rather than a size claim. +Validated text, style, constraint, and inline-object tables now expose borrowed per-paragraph span cursors. Each cursor +advances only across the current paragraph's contiguous fixed records, returns an empty borrowed slice when that +paragraph has no records, and lets the transaction reject any unclaimed tail. The current single child consumes these +views in production, so the multi-child loop will not need per-record maps, record copies, or a second decode. An exact +fixture consumes present and absent spans across every keyed semantic table. Optimized Wasm is 1,074,464 raw / 404,058 +gzip / 321,156 Brotli bytes (+1,390 / +521 / +110 over the session-identity checkpoint). Adjacent 8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and 6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 93bff1c4..be6e59eb 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -278,6 +278,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-209 | Stable glyph IDs and semantic content revisions are session-wide monotonic namespaces because one shared planner cannot distinguish equal paragraph-local ordinals. Paragraph identity indexes remain child-local, but prepare borrows transaction-local counters initialized from the session; abort discards them and successful shared-plan commit advances the session counters. The single-child checkpoint preserves exact behavior and all 124 Rust unit tests. Optimized Wasm changes from 1,073,179 / 403,475 / 321,149 to 1,073,074 / 403,537 / 321,046 raw/gzip/Brotli bytes, a size-neutral code-layout movement. | Accepted | +| D-210 | Multi-paragraph frame consumption uses forward-only borrowed spans, not per-record maps or copied semantic arrays. Validated text, style, constraint, and inline-object tables expose a cursor that consumes only contiguous records for the current paragraph, returns an empty borrowed view when absent, and leaves an exact final cursor check to reject skipped or repeated ownership. The existing single-child production transaction now uses the same path. An exact fixture covers present/absent spans in every keyed table. Optimized Wasm changes from 1,073,074 / 403,537 / 321,046 to 1,074,464 / 404,058 / 321,156 raw/gzip/Brotli bytes. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 84f180de..9aa696c3 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -218,6 +218,35 @@ impl GeometryBatch<'_> { self.inline_objects.len() / abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize } + pub(crate) fn take_paragraph( + self, + paragraph_id: u32, + constraint_cursor: &mut usize, + inline_object_cursor: &mut usize, + ) -> Result { + let constraints = take_records( + self.constraints, + abi::ENGINE_CONSTRAINT_RECORD_SIZE, + abi::ENGINE_CONSTRAINT_PARAGRAPH_ID, + paragraph_id, + constraint_cursor, + )?; + let inline_objects = take_records( + self.inline_objects, + abi::ENGINE_INLINE_OBJECT_RECORD_SIZE, + abi::ENGINE_INLINE_OBJECT_PARAGRAPH_ID, + paragraph_id, + inline_object_cursor, + )?; + Ok(Self { + request: self.request, + constraints, + regions: self.regions, + exclusions: self.exclusions, + inline_objects, + }) + } + pub(crate) fn paragraph_id(self, index: usize) -> Option { if index < self.constraint_count() { let record = record_at(self.constraints, abi::ENGINE_CONSTRAINT_RECORD_SIZE, index)?; @@ -464,6 +493,23 @@ impl<'a> TextMutationBatch<'a> { self.get(index).map(|mutation| mutation.paragraph_id) } + pub(crate) fn take_paragraph( + self, + paragraph_id: u32, + cursor: &mut usize, + ) -> Result { + Ok(Self { + request: self.request, + records: take_records( + self.records, + ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + paragraph_id, + cursor, + )?, + }) + } + pub(crate) fn validate_disjoint_geometry(self, geometry: GeometryBatch<'_>) -> Result<(), u32> { if !self.records.is_empty() && geometry.overlaps_range(byte_range(self.request, self.records)?)? @@ -592,6 +638,23 @@ impl<'a> StyleMutationBatch<'a> { } } + pub(crate) fn take_paragraph( + self, + paragraph_id: u32, + cursor: &mut usize, + ) -> Result { + Ok(Self { + request: self.request, + records: take_records( + self.records, + abi::ENGINE_STYLE_MUTATION_RECORD_SIZE, + abi::ENGINE_STYLE_MUTATION_PARAGRAPH_ID, + paragraph_id, + cursor, + )?, + }) + } + pub(crate) fn feature(value: StyleValue<'_>, index: usize) -> Option { let stride = abi::FEATURE_RECORD_SIZE as usize; let start = index.checked_mul(stride)?; @@ -1216,6 +1279,36 @@ fn record_at(records: &[u8], stride: u32, index: usize) -> Option<&[u8]> { records.get(start..start.checked_add(stride)?) } +fn take_records<'a>( + records: &'a [u8], + stride: u32, + paragraph_field: usize, + paragraph_id: u32, + cursor: &mut usize, +) -> Result<&'a [u8], u32> { + let stride = usize::try_from(stride).map_err(|_| STATUS_INVALID_REQUEST)?; + let count = records.len() / stride; + if *cursor > count { + return Err(STATUS_INVALID_REQUEST); + } + let start = *cursor; + while *cursor < count { + let record = record_at( + records, + u32::try_from(stride).map_err(|_| STATUS_INVALID_REQUEST)?, + *cursor, + ) + .ok_or(STATUS_INVALID_REQUEST)?; + if read_u32(record, paragraph_field)? != paragraph_id { + break; + } + *cursor += 1; + } + records + .get(start * stride..*cursor * stride) + .ok_or(STATUS_INVALID_REQUEST) +} + fn validate_constraints( constraints: &[u8], region_count: u32, @@ -1951,6 +2044,56 @@ mod tests { ); } + #[test] + fn consumes_contiguous_paragraph_spans_without_copying_records() { + let offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let stride = ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let mut bytes = vec![0; offset + 3 * stride]; + for (index, paragraph_id) in [7, 7, 9].into_iter().enumerate() { + let record = offset + index * stride; + bytes[record + ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + bytes[record + ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32( + &mut bytes, + record + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + paragraph_id, + ); + } + let batch = parse_text_mutations(&bytes, offset as u32, 3).unwrap(); + let mut cursor = 0; + let first = batch.take_paragraph(7, &mut cursor).unwrap(); + assert_eq!(first.len(), 2); + assert_eq!(cursor, 2); + let empty = batch.take_paragraph(8, &mut cursor).unwrap(); + assert_eq!(empty.len(), 0); + assert_eq!(cursor, 2); + let last = batch.take_paragraph(9, &mut cursor).unwrap(); + assert_eq!(last.len(), 1); + assert_eq!(cursor, 3); + + let style_bytes = valid_style_bytes(); + let styles = parse_style_mutations(&style_bytes, STYLE_OFFSET as u32, 1).unwrap(); + let mut style_cursor = 0; + assert_eq!(styles.take_paragraph(2, &mut style_cursor).unwrap().len(), 0); + assert_eq!(styles.take_paragraph(1, &mut style_cursor).unwrap().len(), 1); + assert_eq!(style_cursor, 1); + + let geometry_bytes = valid_geometry_bytes(); + let geometry = parse_valid_geometry(&geometry_bytes).unwrap(); + let (mut constraint_cursor, mut inline_cursor) = (0, 0); + let absent = geometry + .take_paragraph(2, &mut constraint_cursor, &mut inline_cursor) + .unwrap(); + assert_eq!(absent.constraint_count(), 0); + assert_eq!(absent.inline_object_count(), 0); + let present = geometry + .take_paragraph(1, &mut constraint_cursor, &mut inline_cursor) + .unwrap(); + assert_eq!(present.constraint_count(), 1); + assert_eq!(present.inline_object_count(), 1); + assert_eq!((constraint_cursor, inline_cursor), (1, 1)); + } + #[test] fn rejects_noncanonical_empty_and_overlapping_payloads() { assert!(parse_text_mutations(&[], 4, 0).is_err()); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 8feb66b1..e8063331 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -554,9 +554,39 @@ impl TextEngine { session.acknowledged_publication_generation = request.acknowledged_publication_generation; let mut next_glyph_id = session.next_glyph_id.max(1); let mut next_content_revision = session.next_content_revision.max(1); + let (text_mutations, style_mutations, geometry) = if let Some(paragraph_id) = paragraph_id { + let (mut text_cursor, mut style_cursor) = (0, 0); + let (mut constraint_cursor, mut inline_object_cursor) = (0, 0); + let text = request + .text_mutations + .take_paragraph(paragraph_id, &mut text_cursor) + .map_err(|_| EngineError::InvalidRequest)?; + let styles = request + .style_mutations + .take_paragraph(paragraph_id, &mut style_cursor) + .map_err(|_| EngineError::InvalidRequest)?; + let geometry = request + .geometry + .take_paragraph( + paragraph_id, + &mut constraint_cursor, + &mut inline_object_cursor, + ) + .map_err(|_| EngineError::InvalidRequest)?; + if text_cursor != request.text_mutations.len() + || style_cursor != request.style_mutations.len() + || constraint_cursor != request.geometry.constraint_count() + || inline_object_cursor != request.geometry.inline_object_count() + { + return Err(EngineError::InvalidRequest); + } + (text, styles, geometry) + } else { + (request.text_mutations, request.style_mutations, request.geometry) + }; let paragraph = &mut session.paragraph; - paragraph.prepare_text(request.text_mutations)?; - if let Err(error) = paragraph.prepare_styles(request.style_mutations, |handle| { + paragraph.prepare_text(text_mutations)?; + if let Err(error) = paragraph.prepare_styles(style_mutations, |handle| { font_stacks .binary_search_by_key(&handle, |stack| stack.handle) .is_ok() @@ -602,7 +632,7 @@ impl TextEngine { return Err(error); } } - if let Err(error) = paragraph.prepare_geometry(request.geometry) { + if let Err(error) = paragraph.prepare_geometry(geometry) { paragraph.abort_text(); paragraph.abort_styles(); paragraph.abort_unicode(); From 1f1c7432ba3f720403d936c81e24d45811220580 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 22:07:49 -0400 Subject: [PATCH 062/128] refactor(text): centralize paragraph capacity --- docs/packages/text.md | 7 +- docs/planning/decision-register.md | 2 + packages/text/rust/shaper/src/engine/state.rs | 100 ++++++++---------- 3 files changed, 52 insertions(+), 57 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index abaac649..21be9865 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6616c7c15a04a52faf40f5e0d4d3c3df73bed49f879e93eb2657b5db79cffe9c' +source_digest: 'sha256:c4e55483dfca0dd7e83cee94220295b9fede6b88854f915a97a2dd07d5800701' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -816,6 +816,11 @@ paragraph has no records, and lets the transaction reject any unclaimed tail. Th views in production, so the multi-child loop will not need per-record maps, record copies, or a second decode. An exact fixture consumes present and absent spans across every keyed semantic table. Optimized Wasm is 1,074,464 raw / 404,058 gzip / 321,156 Brotli bytes (+1,390 / +521 / +110 over the session-identity checkpoint). +Paragraph creation and capacity growth now share one `ParagraphState` initializer/reserver. It prewarms the paired style +arenas and reusable mutation/resolution scratch once, then reserves every active/pending text-through-positioning arena +from one capacity policy. New map children can therefore reuse the proven setup without duplicating lifecycle code or +silently omitting a scratch lane. Optimized Wasm is 1,074,774 raw / 404,030 gzip / 321,343 Brotli bytes (+310 / -28 / ++187 over the borrowed-span checkpoint). Adjacent 8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and 6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index be6e59eb..f687d167 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -280,6 +280,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-210 | Multi-paragraph frame consumption uses forward-only borrowed spans, not per-record maps or copied semantic arrays. Validated text, style, constraint, and inline-object tables expose a cursor that consumes only contiguous records for the current paragraph, returns an empty borrowed view when absent, and leaves an exact final cursor check to reject skipped or repeated ownership. The existing single-child production transaction now uses the same path. An exact fixture covers present/absent spans in every keyed table. Optimized Wasm changes from 1,073,074 / 403,537 / 321,046 to 1,074,464 / 404,058 / 321,156 raw/gzip/Brotli bytes. | Accepted | +| D-211 | Every retained paragraph is initialized and capacity-reserved through one functional `ParagraphState` path. It prewarms paired style/resolution arenas and reusable scratch, then applies one text-capacity policy to every active/pending Unicode-through-positioning arena. New batch children cannot accidentally omit a retained lane or duplicate setup logic. Optimized Wasm changes from 1,074,464 / 404,058 / 321,156 to 1,074,774 / 404,030 / 321,343 raw/gzip/Brotli bytes. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index e8063331..96168036 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -351,30 +351,7 @@ impl TextEngine { return Err(EngineError::SessionConflict); } let mut session = EngineSession::default(); - session.paragraph.styles.reserve_default()?; - session.paragraph.pending_styles.reserve_default()?; - session.paragraph.resolved_styles.reserve_default()?; - session.paragraph.pending_resolved_styles.reserve_default()?; - session - .paragraph - .style_mutation_scratch - .try_reserve_exact(DEFAULT_STYLE_CAPACITY) - .map_err(|_| EngineError::ResultTooLarge)?; - session - .paragraph - .style_order_scratch - .try_reserve_exact(DEFAULT_STYLE_CAPACITY) - .map_err(|_| EngineError::ResultTooLarge)?; - session - .paragraph - .style_nesting_scratch - .try_reserve_exact(DEFAULT_STYLE_CAPACITY) - .map_err(|_| EngineError::ResultTooLarge)?; - session - .paragraph - .style_resolution_scratch - .try_reserve_exact(DEFAULT_STYLE_CAPACITY) - .map_err(|_| EngineError::ResultTooLarge)?; + session.paragraph.initialize()?; self.sessions.insert(handle, session); Ok(()) } @@ -392,38 +369,7 @@ impl TextEngine { .sessions .get_mut(&handle) .ok_or(EngineError::SessionMissing)?; - let paragraph = &mut session.paragraph; - reserve_text_buffer(&mut paragraph.text, capacity)?; - reserve_text_buffer(&mut paragraph.pending_text, capacity)?; - reserve_vec(&mut paragraph.text_unit_ids, capacity)?; - reserve_vec(&mut paragraph.pending_text_unit_ids, capacity)?; - paragraph.unicode.reserve(capacity).map_err(unicode_error)?; - paragraph - .pending_unicode - .reserve(capacity) - .map_err(unicode_error)?; - paragraph.bidi.reserve(capacity).map_err(bidi_error)?; - paragraph.pending_bidi.reserve(capacity).map_err(bidi_error)?; - paragraph.shaping_runs.reserve(capacity)?; - paragraph.pending_shaping_runs.reserve(capacity)?; - let glyph_capacity = capacity.saturating_mul(2); - paragraph.shape.reserve(glyph_capacity)?; - paragraph.pending_shape.reserve(glyph_capacity)?; - paragraph.clusters.reserve(capacity)?; - paragraph.pending_clusters.reserve(capacity)?; - paragraph.flow_layout.reserve(capacity, 1)?; - paragraph.pending_flow_layout.reserve(capacity, 1)?; - paragraph.positioned.reserve(glyph_capacity)?; - paragraph.pending_positioned.reserve(glyph_capacity)?; - paragraph - .glyph_identity_index - .prepare(glyph_capacity) - .map_err(|_| EngineError::ResultTooLarge)?; - reserve_vec(&mut paragraph.fallback_spans, capacity)?; - reserve_vec(&mut paragraph.pending_fallback_spans, capacity)?; - reserve_vec(&mut paragraph.fallback_span_scratch, capacity)?; - reserve_vec(&mut paragraph.fallback_cluster_scratch, glyph_capacity)?; - Ok(()) + session.paragraph.reserve_text(capacity) } pub(crate) fn session_revision(&self, handle: u32) -> Result { @@ -856,6 +802,48 @@ impl TextEngine { } impl ParagraphState { + fn initialize(&mut self) -> Result<(), EngineError> { + self.styles.reserve_default()?; + self.pending_styles.reserve_default()?; + self.resolved_styles.reserve_default()?; + self.pending_resolved_styles.reserve_default()?; + reserve_vec(&mut self.style_mutation_scratch, DEFAULT_STYLE_CAPACITY)?; + reserve_vec(&mut self.style_order_scratch, DEFAULT_STYLE_CAPACITY)?; + reserve_vec(&mut self.style_nesting_scratch, DEFAULT_STYLE_CAPACITY)?; + reserve_vec(&mut self.style_resolution_scratch, DEFAULT_STYLE_CAPACITY) + } + + fn reserve_text(&mut self, capacity: usize) -> Result<(), EngineError> { + reserve_text_buffer(&mut self.text, capacity)?; + reserve_text_buffer(&mut self.pending_text, capacity)?; + reserve_vec(&mut self.text_unit_ids, capacity)?; + reserve_vec(&mut self.pending_text_unit_ids, capacity)?; + self.unicode.reserve(capacity).map_err(unicode_error)?; + self.pending_unicode + .reserve(capacity) + .map_err(unicode_error)?; + self.bidi.reserve(capacity).map_err(bidi_error)?; + self.pending_bidi.reserve(capacity).map_err(bidi_error)?; + self.shaping_runs.reserve(capacity)?; + self.pending_shaping_runs.reserve(capacity)?; + let glyph_capacity = capacity.saturating_mul(2); + self.shape.reserve(glyph_capacity)?; + self.pending_shape.reserve(glyph_capacity)?; + self.clusters.reserve(capacity)?; + self.pending_clusters.reserve(capacity)?; + self.flow_layout.reserve(capacity, 1)?; + self.pending_flow_layout.reserve(capacity, 1)?; + self.positioned.reserve(glyph_capacity)?; + self.pending_positioned.reserve(glyph_capacity)?; + self.glyph_identity_index + .prepare(glyph_capacity) + .map_err(|_| EngineError::ResultTooLarge)?; + reserve_vec(&mut self.fallback_spans, capacity)?; + reserve_vec(&mut self.pending_fallback_spans, capacity)?; + reserve_vec(&mut self.fallback_span_scratch, capacity)?; + reserve_vec(&mut self.fallback_cluster_scratch, glyph_capacity) + } + fn prepare_text( &mut self, mutations: super::semantic_wire::TextMutationBatch<'_>, From c75a3c98c9b1935b4e1f150319d4c9a4c8bc061f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 22:24:57 -0400 Subject: [PATCH 063/128] refactor(text): unify paragraph transactions --- docs/packages/text.md | 9 +- docs/planning/decision-register.md | 2 + packages/text/rust/shaper/src/engine/state.rs | 127 ++++++------------ .../render-plan-frame-abi.test.mjs | 6 +- .../integration/shaper-registration.test.mjs | 18 ++- 5 files changed, 69 insertions(+), 93 deletions(-) diff --git a/docs/packages/text.md b/docs/packages/text.md index 21be9865..261e8675 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:c4e55483dfca0dd7e83cee94220295b9fede6b88854f915a97a2dd07d5800701' +source_digest: 'sha256:4b2ff74a52d39457b541c3ab2e31c67ce6055de053dfa845bf16b8f48f2f1a54' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -821,6 +821,13 @@ arenas and reusable mutation/resolution scratch once, then reserves every active from one capacity policy. New map children can therefore reuse the proven setup without duplicating lifecycle code or silently omitting a scratch lane. Optimized Wasm is 1,074,774 raw / 404,030 gzip / 321,343 Brotli bytes (+310 / -28 / +187 over the borrowed-span checkpoint). +Paragraph finalization now has one complete ordered path: every preparation failure and explicit abort calls +`abort_all`, while successful shared-plan publication calls `commit_all`. This is the rollback boundary required before +one frame can prepare several child paragraphs. A fresh optimized-Wasm rebuild exposed stale pre-control-record fixtures; +the compiled integration lane now asserts the 136-byte request header and supplies explicit paragraph IDs on text, +style, constraint, and inline-object records. Focused compiled-Wasm integration and all 125 Rust unit tests pass. +Optimized Wasm is 1,073,248 raw / 404,463 gzip / 321,189 Brotli bytes (-1,526 / +433 / -154 from the centralized +capacity checkpoint); the mixed compression movement supports no size or latency claim. Adjacent 8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and 6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index f687d167..2ab8e1be 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -282,6 +282,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-211 | Every retained paragraph is initialized and capacity-reserved through one functional `ParagraphState` path. It prewarms paired style/resolution arenas and reusable scratch, then applies one text-capacity policy to every active/pending Unicode-through-positioning arena. New batch children cannot accidentally omit a retained lane or duplicate setup logic. Optimized Wasm changes from 1,074,464 / 404,058 / 321,156 to 1,074,774 / 404,030 / 321,343 raw/gzip/Brotli bytes. | Accepted | +| D-212 | Paragraph transaction finalization has one complete ordered definition. Every preparation failure and explicit session abort invokes `ParagraphState::abort_all`; successful shared-plan publication invokes `commit_all`. This prevents a later child failure from leaving an earlier child's pending Unicode-through-positioning arena live when the retained session becomes multi-paragraph. Rebuilding optimized Wasm also exposed pre-control-record integration fixtures: they now assert the 136-byte frame header and write explicit paragraph IDs through text, style, constraints, and inline objects. Focused compiled-Wasm integration and all 125 Rust unit tests pass. Optimized Wasm changes from 1,074,774 / 404,030 / 321,343 to 1,073,248 / 404,463 / 321,189 raw/gzip/Brotli bytes; no latency claim is attached to this control-flow consolidation. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 96168036..29d8b26b 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -537,55 +537,33 @@ impl TextEngine { .binary_search_by_key(&handle, |stack| stack.handle) .is_ok() }) { - paragraph.abort_text(); + paragraph.abort_all(); return Err(error); } if let Err(error) = paragraph.prepare_unicode() { - paragraph.abort_text(); - paragraph.abort_styles(); + paragraph.abort_all(); return Err(error); } if let Err(error) = paragraph.prepare_bidi() { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); + paragraph.abort_all(); return Err(error); } if let Err(error) = paragraph.prepare_shaping_runs() { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); + paragraph.abort_all(); return Err(error); } if let Some(shaper) = shaper.as_deref_mut() { if let Err(error) = paragraph.prepare_shape(shaper, font_stacks, font_bindings) { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); - paragraph.abort_shaping_runs(); + paragraph.abort_all(); return Err(error); } if let Err(error) = paragraph.prepare_clusters(shaper, &mut next_glyph_id) { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); - paragraph.abort_shaping_runs(); - paragraph.abort_shape(); - paragraph.abort_clusters(); + paragraph.abort_all(); return Err(error); } } if let Err(error) = paragraph.prepare_geometry(geometry) { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); - paragraph.abort_shaping_runs(); - paragraph.abort_shape(); - paragraph.abort_clusters(); + paragraph.abort_all(); return Err(error); } let flow_changed = paragraph.clusters_prepared @@ -602,31 +580,14 @@ impl TextEngine { request.limits.max_slots_per_band, ) { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); - paragraph.abort_shaping_runs(); - paragraph.abort_shape(); - paragraph.abort_clusters(); - paragraph.abort_geometry(); - paragraph.abort_flow_layout(); + paragraph.abort_all(); return Err(error); } if positioned_changed && let Err(error) = paragraph.prepare_positioned(shaper, &mut next_content_revision) { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); - paragraph.abort_shaping_runs(); - paragraph.abort_shape(); - paragraph.abort_clusters(); - paragraph.abort_geometry(); - paragraph.abort_flow_layout(); - paragraph.abort_positioned(); + paragraph.abort_all(); return Err(error); } } @@ -663,16 +624,7 @@ impl TextEngine { .map(|binding| &binding.binding) }, ) { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); - paragraph.abort_shaping_runs(); - paragraph.abort_shape(); - paragraph.abort_clusters(); - paragraph.abort_geometry(); - paragraph.abort_flow_layout(); - paragraph.abort_positioned(); + paragraph.abort_all(); return Err(gather_error(error)); } let gathered = gather.view(); @@ -686,16 +638,7 @@ impl TextEngine { ) }; if let Err(error) = plan_result { - paragraph.abort_text(); - paragraph.abort_styles(); - paragraph.abort_unicode(); - paragraph.abort_bidi(); - paragraph.abort_shaping_runs(); - paragraph.abort_shape(); - paragraph.abort_clusters(); - paragraph.abort_geometry(); - paragraph.abort_flow_layout(); - paragraph.abort_positioned(); + paragraph.abort_all(); return Err(plan_error(error)); } session.pending_next_glyph_id = next_glyph_id; @@ -743,16 +686,7 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.plan.abort(); - session.paragraph.abort_text(); - session.paragraph.abort_styles(); - session.paragraph.abort_unicode(); - session.paragraph.abort_bidi(); - session.paragraph.abort_shaping_runs(); - session.paragraph.abort_shape(); - session.paragraph.abort_clusters(); - session.paragraph.abort_geometry(); - session.paragraph.abort_flow_layout(); - session.paragraph.abort_positioned(); + session.paragraph.abort_all(); session.pending_next_glyph_id = 0; session.pending_next_content_revision = 0; Ok(()) @@ -770,16 +704,7 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.plan.commit().map_err(plan_error)?; - session.paragraph.commit_text(); - session.paragraph.commit_styles(); - session.paragraph.commit_unicode(); - session.paragraph.commit_bidi(); - session.paragraph.commit_shaping_runs(); - session.paragraph.commit_shape(); - session.paragraph.commit_clusters(); - session.paragraph.commit_geometry(); - session.paragraph.commit_flow_layout(); - session.paragraph.commit_positioned(); + session.paragraph.commit_all(); session.next_glyph_id = session.pending_next_glyph_id; session.next_content_revision = session.pending_next_content_revision; session.pending_next_glyph_id = 0; @@ -802,6 +727,32 @@ impl TextEngine { } impl ParagraphState { + fn abort_all(&mut self) { + self.abort_text(); + self.abort_styles(); + self.abort_unicode(); + self.abort_bidi(); + self.abort_shaping_runs(); + self.abort_shape(); + self.abort_clusters(); + self.abort_geometry(); + self.abort_flow_layout(); + self.abort_positioned(); + } + + fn commit_all(&mut self) { + self.commit_text(); + self.commit_styles(); + self.commit_unicode(); + self.commit_bidi(); + self.commit_shaping_runs(); + self.commit_shape(); + self.commit_clusters(); + self.commit_geometry(); + self.commit_flow_layout(); + self.commit_positioned(); + } + fn initialize(&mut self) -> Result<(), EngineError> { self.styles.reserve_default()?; self.pending_styles.reserve_default()?; diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index cfffbf2a..f73967ef 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -27,7 +27,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as const requestLayout = abi.layouts.engineUpdateRequest; const resultLayout = abi.layouts.engineResult; assert.equal(resultLayout.size, 144); - assert.equal(requestLayout.size, 124); + assert.equal(requestLayout.size, 136); assert.equal(resultLayout.alignment, 16); assert.equal(abi.layouts.engineBuffer.size, 36); assert.equal(abi.layouts.enginePatch.size, 36); @@ -42,7 +42,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as abi.layouts.engineExclusion.size, abi.layouts.engineInlineObject.size, ], - [24, 88, 52, 8, 56, 48, 56], + [24, 92, 56, 8, 56, 48, 60], ); assert.equal(abi.layouts.engineInlineObject.alignment, 4); assert.equal(abi.layouts.engineInlineObject.baselineAlignment, 52); @@ -328,6 +328,7 @@ function geometryRequestBytes(abi, expectedEngineRevision, consumedPlanRevision, view.setUint32(request[countField], 1, true); } + view.setUint32(constraintOffset + constraint.paragraphId, 1, true); view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); view.setFloat32(constraintOffset + constraint.width, 100, true); view.setFloat32(constraintOffset + constraint.height, 100, true); @@ -361,6 +362,7 @@ function geometryRequestBytes(abi, expectedEngineRevision, consumedPlanRevision, view.setFloat32(exclusionOffset + exclusion.inlineEnd, 40, true); view.setFloat32(exclusionOffset + exclusion.blockEnd, 40, true); + view.setUint32(inlineObjectOffset + inlineObject.paragraphId, 1, true); view.setUint32(inlineObjectOffset + inlineObject.id, 3, true); view.setUint32(inlineObjectOffset + inlineObject.contentRevision, 1, true); view.setUint32(inlineObjectOffset + inlineObject.textOffset, 1, true); diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 9cc19832..a292658f 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -374,10 +374,15 @@ function engineStyleUpdateBytes( }, ) { const request = abi.layouts.engineUpdateRequest; + const paragraphRecord = abi.layouts.engineParagraphMutation; const textRecord = abi.layouts.engineTextMutation; const styleRecord = abi.layouts.engineStyleMutation; - const textRecordOffset = text.length === 0 ? 0 : request.size; - const styleRecordOffset = align(request.size + (text.length === 0 ? 0 : textRecord.size), styleRecord.alignment); + const paragraphRecordOffset = align(request.size, paragraphRecord.alignment); + const textRecordOffset = text.length === 0 ? 0 : paragraphRecordOffset + paragraphRecord.size; + const styleRecordOffset = align( + paragraphRecordOffset + paragraphRecord.size + (text.length === 0 ? 0 : textRecord.size), + styleRecord.alignment, + ); const textPayloadOffset = styleRecordOffset + styleRecord.size; const textPayloadEnd = textPayloadOffset + text.length * 2; const constraint = abi.layouts.engineConstraint; @@ -395,6 +400,7 @@ function engineStyleUpdateBytes( view.setUint32(request.acknowledgedPublicationGeneration, acknowledgedPublicationGeneration, true); view.setUint32(request.policyHandle, policyHandle, true); view.setUint32(request.capabilitySet, 1, true); + view.setUint32(request.maxParagraphs, 1, true); for (const field of [ 'maxClusters', 'maxLines', @@ -406,6 +412,8 @@ function engineStyleUpdateBytes( view.setUint32(request[field], field === 'maxClusters' ? 2 : 1, true); } view.setUint32(request.maxOutputBytes, 64 * 1024, true); + view.setUint32(request.paragraphMutationsOffset, paragraphRecordOffset, true); + view.setUint32(request.paragraphMutationCount, 1, true); view.setUint32(request.textMutationsOffset, textRecordOffset, true); view.setUint32(request.textMutationCount, text.length === 0 ? 0 : 1, true); view.setUint32(request.styleMutationsOffset, styleRecordOffset, true); @@ -417,9 +425,13 @@ function engineStyleUpdateBytes( view.setUint32(request.regionCount, 1, true); } + view.setUint8(paragraphRecordOffset + paragraphRecord.opcode, abi.engine.paragraphMutationOpcodes.upsert); + view.setUint32(paragraphRecordOffset + paragraphRecord.paragraphId, 1, true); + if (text.length > 0) { view.setUint8(textRecordOffset + textRecord.opcode, abi.engine.textMutationOpcodes.replaceUtf16); view.setUint8(textRecordOffset + textRecord.encoding, abi.engine.textEncodings.utf16Le); + view.setUint32(textRecordOffset + textRecord.paragraphId, 1, true); view.setUint32(textRecordOffset + textRecord.insertOffset, textPayloadOffset, true); view.setUint32(textRecordOffset + textRecord.insertCount, text.length, true); for (const [index, unit] of text.entries()) view.setUint16(textPayloadOffset + index * 2, unit, true); @@ -429,6 +441,7 @@ function engineStyleUpdateBytes( styleRecordOffset + styleRecord.opcode, removeRoot ? abi.engine.styleMutationOpcodes.remove : abi.engine.styleMutationOpcodes.upsert, ); + view.setUint32(styleRecordOffset + styleRecord.paragraphId, 1, true); view.setUint32(styleRecordOffset + styleRecord.styleId, 1, true); if (!removeRoot) { view.setUint8(styleRecordOffset + styleRecord.flags, abi.engine.styleFlags.root); @@ -447,6 +460,7 @@ function engineStyleUpdateBytes( view.setFloat32(styleRecordOffset + styleRecord.rasterPixelRatio, 1, true); } if (geometry) { + view.setUint32(constraintOffset + constraint.paragraphId, 1, true); view.setUint32(constraintOffset + constraint.flowThreadId, 1, true); view.setFloat32(constraintOffset + constraint.width, 100, true); view.setFloat32(constraintOffset + constraint.height, 100, true); From 0ca178a2f48722bfedf8f82eb221602c7ee74090 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 22:52:28 -0400 Subject: [PATCH 064/128] feat(text): retain ordered paragraph sessions --- docs/log.md | 9 + docs/packages/text.md | 14 +- docs/planning/decision-register.md | 4 +- packages/text/rust/shaper/src/abi_contract.rs | 20 +- .../rust/shaper/src/engine/flow_geometry.rs | 107 +- packages/text/rust/shaper/src/engine/frame.rs | 1 - .../text/rust/shaper/src/engine/frame_wire.rs | 13 +- .../rust/shaper/src/engine/semantic_wire.rs | 163 +-- packages/text/rust/shaper/src/engine/state.rs | 990 ++++++++++++++---- .../integration/three-engine-runtime.test.mjs | 117 ++- 10 files changed, 1068 insertions(+), 370 deletions(-) diff --git a/docs/log.md b/docs/log.md index 00bee165..f4aaeff6 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Published multiple retained paragraphs as one Rust command buffer** — Engine sessions now own an ordered stable-ID + paragraph set rather than one paragraph. Lifecycle upsert/reorder/remove, every child semantic transaction, shared + policy gather, plan serialization, and commit/abort form one atomic publication. Missing semantic spans retain a + child, and per-child geometry retains only referenced regions/exclusions instead of inheriting invalidation from a + sibling's global table prefix. A compiled-Wasm Three-coordinator fixture publishes material groups `[7, 8]`, then + sends only reorder records and publishes `[8, 7]`; all 128 Rust unit tests pass. Optimized Wasm changes from + 1,073,248 / 404,463 / 321,189 to 1,082,551 / 407,787 / 324,499 raw/gzip/Brotli bytes. This proves the retained, + renderer-neutral command-buffer delta; public Three GPU realization and end-to-end latency remain open. + - **Preserved multi-paragraph Three batching in the Rust session design** — Existing `TextGroup` batches independent paragraphs, while the current Rust session's multiple constraints all flow the same prose. The cutover therefore uses one group/session containing stable-ID paragraph states and one shared planner/publication, rather than one Wasm call diff --git a/docs/packages/text.md b/docs/packages/text.md index 261e8675..8736a367 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:4b2ff74a52d39457b541c3ab2e31c67ce6055de053dfa845bf16b8f48f2f1a54' +source_digest: 'sha256:2fa07b0d9260a1cb1096d738142cefe19292fd5106044b7c1a134089a7e63379' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -857,6 +857,18 @@ The canonical composed Inter fixture proves GLB → registry → public `Text` The five-line, 120-glyph text above is the bounded conformance specimen. The separate live benchmark ipsum exceeds 1,000 characters and renders 1,150 glyphs through the same one-draw public `Text` path. +One engine session now retains an ordered set of stable-ID paragraph states and publishes one shared Rust render plan. +Paragraph lifecycle records create, reorder, or remove children transactionally; semantic records are consumed as +forward-only borrowed paragraph spans, and absent spans retain prior child state. The final child order feeds one +pre-sized allocation-free policy gather. Each child's retained geometry compacts only its referenced regions, +exclusions, and vertices instead of keeping unrelated global table prefixes. A compiled-Wasm coordinator fixture +creates two independent paragraphs, observes adjacent material groups `[7, 8]`, then sends only lifecycle records and +observes `[8, 7]` in the next publication. The publication is a retained renderer-neutral command-buffer delta: it +describes resources, physical buffers, dirty patches, primitives, draws, and retirement, but it is neither a native +`GPUCommandBuffer` nor a TypeScript object batch. Public Three GPU realization remains open. The optimized shaper is +1,082,551 raw / 407,787 gzip / 324,499 Brotli bytes at this checkpoint; all 128 Rust unit tests and the focused compiled- +Wasm fixture pass, with no end-to-end renderer latency claim yet. + The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 2ab8e1be..61538fef 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -270,7 +270,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-203 | Policy registration compiles input-to-buffer dependencies and reverse operation-to-buffer liveness. Position-only updates gather only source lanes reaching semantically changed buffers and skip scalar/SIMD operations reaching no active buffer; checkpoints, new glyphs, and non-positioning changes force complete evaluation. Consecutive records may reuse immutable font-binding and policy-program resolution, but glyph/resource selection remains per record. Isolated selective gathering regressed and isolated operation liveness was neutral; combined they reduced canonical Bitmap/MTSDF/Slug resize medians from the D-202 checkpoint of 4.878/5.355/6.001 ms to 4.207/4.833/5.615 ms. Resolution caching then measured 3.981 and 4.120 ms in two canonical Bitmap runs, 4.646 ms for MTSDF, and 5.622 ms for Slug. A 4.799 ms isolated Bitmap run keeps the JIT-sensitive sub-4 ms gate open. The accepted cost is 1,069,973 / 405,888 / 319,558 raw/gzip/Brotli bytes, +14,116 / +2,363 / +1,421 bytes over D-202. | Accepted | | D-204 | The first production Three policy registers Bitmap, MTSDF, and Slug together. Technique, program, and resource identify storage; material, clip, depth, and order additionally identify draws, allowing material-directed draw splits without forcing duplicate physical storage. Public string technique and raster-resource identities lower to deterministic nonzero `u32` wire IDs with UTF-8 FNV-1a, and one runtime-scoped registry rejects collisions across the shared namespace before registration. First-party font bindings compile validated raster data directly into one field-major request allocation, preserve every bitmap strike, sort resources by wire ID, and remap record references accordingly. Exact real-fixture tests compare every production field against the established renderer-parity tables, and the combined policy registers in compiled Wasm. Three render-plan consumption and public third-party policy authoring remain open. | Accepted | | D-205 | A render-binding handle identifies one loaded font/technique/resource combination independently from its shaping-font handle. Rust font stacks contain binding handles; each binding names the retained shaping font whose SFNT, plans, metrics, and extents it reuses. Fallback and cluster state retain both identities: shaping and positioning use the shaping handle, while policy gather uses the binding handle. Compiled Wasm proves two techniques can bind one shaping font and an Inter-to-Devanagari fallback emits the second binding's distinct technique in one Rust plan. The additional retained `u32` cluster lane measures 4.217/4.791/5.633 ms for Bitmap/MTSDF/Slug resize at 8 warmups/31 samples; 6–7% RSD does not distinguish this from D-203's 4.120/4.646/5.622 ms. Optimized Wasm is 1,070,580 / 402,114 / 319,662 raw/gzip/Brotli bytes, +607 / -3,774 / +104 bytes from D-203. | Accepted | -| D-206 | One Rust engine session corresponds to one renderer batch such as `TextGroup`, not one `Text`. A session retains multiple stable-ID paragraph states and publishes one shared render plan; paragraph mutations and removal are keyed explicitly, and each paragraph owns its text, styles, constraints, and sequential region flow. Glyph and content identities allocate from session-wide monotonic namespaces so one planner cannot alias equal paragraph-local ordinals. Rust appends each paragraph's positioned SoA directly into one pre-reserved policy-gather workspace; TypeScript never concatenates prose, merges glyph arrays, or makes one Wasm call per text. This preserves the accepted batch lifecycle and single frame crossing while keeping exclusions as one-call paragraph geometry. The append kernel has an exact two-layout allocation-free proof. Text, style, constraint, and inline-object records now carry nonzero paragraph IDs through the generated ABI; the transitional single-child state rejects mixed IDs within a transaction and rebinding across transactions. The optimized Wasm is size-neutral within compression/code-layout noise at 1,070,673 raw / 402,177 gzip / 319,722 Brotli bytes versus 1,070,685 / 402,154 / 319,914 before the keyed wire. Retained child state and shared-plan commit remain in progress. | Accepted | +| D-206 | One Rust engine session corresponds to one renderer batch such as `TextGroup`, not one `Text`. A session retains multiple stable-ID paragraph states and publishes one shared render plan; paragraph mutations and removal are keyed explicitly, and each paragraph owns its text, styles, constraints, and sequential region flow. Glyph and content identities allocate from session-wide monotonic namespaces so one planner cannot alias equal paragraph-local ordinals. Rust appends each paragraph's positioned SoA directly into one pre-reserved policy-gather workspace; TypeScript never concatenates prose, merges glyph arrays, or makes one Wasm call per text. This preserves the accepted batch lifecycle and single frame crossing while keeping exclusions as one-call paragraph geometry. The append kernel has an exact two-layout allocation-free proof. Text, style, constraint, and inline-object records now carry nonzero paragraph IDs through the generated ABI; the transitional single-child state rejects mixed IDs within a transaction and rebinding across transactions. The optimized Wasm is size-neutral within compression/code-layout noise at 1,070,673 raw / 402,177 gzip / 319,722 Brotli bytes versus 1,070,685 / 402,154 / 319,914 before the keyed wire. Retained child state and shared-plan commit remain in progress. | Accepted | | D-207 | Paragraph lifecycle identity does not determine presentation order. The frame ABI carries a separate 12-byte paragraph-control record that explicitly upserts ordered batch membership or removes retained state. The decoder rejects zero and duplicate IDs, duplicate declared orders, noncanonical removals, forged section overlap, and counts above the frame paragraph limit. Text, style, constraint, and inline-object records continue to name their owning paragraph independently. The production compiler emits control records before paragraph-owned semantics; the transitional single-child state rejects removal until the retained-child consumer lands. The reachable validation path changes optimized Wasm from 1,070,673 / 402,177 / 319,722 to 1,073,123 / 403,431 / 320,917 raw/gzip/Brotli bytes. | Accepted | @@ -284,6 +284,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-212 | Paragraph transaction finalization has one complete ordered definition. Every preparation failure and explicit session abort invokes `ParagraphState::abort_all`; successful shared-plan publication invokes `commit_all`. This prevents a later child failure from leaving an earlier child's pending Unicode-through-positioning arena live when the retained session becomes multi-paragraph. Rebuilding optimized Wasm also exposed pre-control-record integration fixtures: they now assert the 136-byte frame header and write explicit paragraph IDs through text, style, constraints, and inline objects. Focused compiled-Wasm integration and all 125 Rust unit tests pass. Optimized Wasm changes from 1,074,774 / 404,030 / 321,343 to 1,073,248 / 404,463 / 321,189 raw/gzip/Brotli bytes; no latency claim is attached to this control-flow consolidation. | Accepted | +| D-213 | One engine session retains multiple stable-ID paragraph states and emits one atomic, revisioned command-buffer delta for the renderer. Paragraph lifecycle records create, reorder, or remove children; absent semantic sections retain a child rather than clearing it, while present text/style/geometry spans remain forward-only borrowed views. Final paragraph order drives one allocation-free gather into the shared Rust policy/planner. Geometry retention compacts only each child's referenced regions, exclusions, and vertices, so a sibling's global table prefix cannot manufacture layout invalidation. A later-child failure aborts every child and lifecycle change; order collisions, unknown semantic owners, and paragraph limits fail before publication. A compiled-Wasm Three-coordinator proof creates two paragraphs with material groups `[7,8]`, sends a lifecycle-only reorder without resending semantic data, and observes `[8,7]` in the next Rust publication. The render plan is therefore a retained renderer-neutral command buffer, not a TypeScript batch or a native `GPUCommandBuffer`. All 128 Rust unit tests pass. Optimized Wasm changes from 1,073,248 / 404,463 / 321,189 to 1,082,551 / 407,787 / 324,499 raw/gzip/Brotli bytes. Public Three GPU realization remains open, so this checkpoint carries no end-to-end draw or latency claim. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 47cdb75f..08d9250a 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -11,16 +11,16 @@ use crate::engine::frame::{ DECORATION_UNDERLINE, DECORATION_WAVY, DEFAULT_SESSION_TEXT_CAPACITY, EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, - OVERFLOW_VISIBLE, PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, - RESULT_FLAG_CHECKPOINT, SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_START, - SEMANTIC_F32_FONT_SIZE, SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, - SEMANTIC_F32_FOREGROUND_GREEN, SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, - SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, - SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, - SEMANTIC_U32_REGION_ID, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, - STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, - STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, - STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, + OVERFLOW_VISIBLE, PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, RESULT_FLAG_CHECKPOINT, + SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_START, SEMANTIC_F32_FONT_SIZE, + SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, SEMANTIC_F32_FOREGROUND_GREEN, + SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, + SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, + SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, + SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, + STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, + STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, + STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, diff --git a/packages/text/rust/shaper/src/engine/flow_geometry.rs b/packages/text/rust/shaper/src/engine/flow_geometry.rs index 92dd8a92..8bdcc3ee 100644 --- a/packages/text/rust/shaper/src/engine/flow_geometry.rs +++ b/packages/text/rust/shaper/src/engine/flow_geometry.rs @@ -48,65 +48,58 @@ impl FlowGeometryArena { pub(crate) fn build(&mut self, geometry: GeometryBatch<'_>) -> Result<(), EngineError> { self.clear(); reserve(&mut self.constraints, geometry.constraint_count())?; + reserve(&mut self.regions, geometry.region_count())?; + reserve(&mut self.exclusions, geometry.exclusion_count())?; for index in 0..geometry.constraint_count() { - self.constraints.push( - geometry - .constraint(index) - .ok_or(EngineError::InvalidRequest)?, - ); - } - let region_count = self - .constraints - .iter() - .map(|constraint| { - usize::try_from(constraint.region_start) - .ok() - .and_then(|start| start.checked_add(usize::from(constraint.region_count))) - }) - .try_fold(0usize, |maximum, end| { - end.map(|end| maximum.max(end)) - .ok_or(EngineError::InvalidRequest) - })?; - reserve(&mut self.regions, region_count)?; - for index in 0..region_count { - let record = geometry.region(index).ok_or(EngineError::InvalidRequest)?; - let vertex_start = append_vertices( - &mut self.vertices, - geometry, - record.vertices_offset, - record.vertex_count, - )?; - self.regions.push(RetainedRegion { - record, - vertex_start, - }); - } - let exclusion_count = self - .regions - .iter() - .map(|region| { - usize::from(region.record.exclusion_start) - .checked_add(usize::from(region.record.exclusion_count)) - }) - .try_fold(0usize, |maximum, end| { - end.map(|end| maximum.max(end)) - .ok_or(EngineError::InvalidRequest) - })?; - reserve(&mut self.exclusions, exclusion_count)?; - for index in 0..exclusion_count { - let record = geometry - .exclusion(index) + let mut constraint = geometry + .constraint(index) .ok_or(EngineError::InvalidRequest)?; - let vertex_start = append_vertices( - &mut self.vertices, - geometry, - record.vertices_offset, - record.vertex_count, - )?; - self.exclusions.push(RetainedExclusion { - record, - vertex_start, - }); + let source_region_start = usize::try_from(constraint.region_start) + .map_err(|_| EngineError::InvalidRequest)?; + constraint.region_start = + u32::try_from(self.regions.len()).map_err(|_| EngineError::InvalidRequest)?; + for region_index in source_region_start + ..source_region_start + .checked_add(usize::from(constraint.region_count)) + .ok_or(EngineError::InvalidRequest)? + { + let mut region = geometry + .region(region_index) + .ok_or(EngineError::InvalidRequest)?; + let source_exclusion_start = usize::from(region.exclusion_start); + region.exclusion_start = u16::try_from(self.exclusions.len()) + .map_err(|_| EngineError::InvalidRequest)?; + let vertex_start = append_vertices( + &mut self.vertices, + geometry, + region.vertices_offset, + region.vertex_count, + )?; + for exclusion_index in source_exclusion_start + ..source_exclusion_start + .checked_add(usize::from(region.exclusion_count)) + .ok_or(EngineError::InvalidRequest)? + { + let exclusion = geometry + .exclusion(exclusion_index) + .ok_or(EngineError::InvalidRequest)?; + let vertex_start = append_vertices( + &mut self.vertices, + geometry, + exclusion.vertices_offset, + exclusion.vertex_count, + )?; + self.exclusions.push(RetainedExclusion { + record: exclusion, + vertex_start, + }); + } + self.regions.push(RetainedRegion { + record: region, + vertex_start, + }); + } + self.constraints.push(constraint); } Ok(()) } diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index d645c296..a0c1c92a 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -137,7 +137,6 @@ pub(crate) struct PreparedUpdate { pub(super) policy_handle: u32, pub(super) capability_set: u32, pub(super) policy_fingerprint: u64, - pub(super) paragraph_id: Option, } impl PreparedUpdate { diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 26052338..626bb486 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -18,13 +18,12 @@ use crate::{ ENGINE_UPDATE_MAX_LINES, ENGINE_UPDATE_MAX_OUTPUT_BYTES, ENGINE_UPDATE_MAX_PARAGRAPHS, ENGINE_UPDATE_MAX_REGIONS, ENGINE_UPDATE_MAX_SLOTS_PER_BAND, ENGINE_UPDATE_PARAGRAPH_MUTATION_COUNT, ENGINE_UPDATE_PARAGRAPH_MUTATIONS_OFFSET, - ENGINE_UPDATE_POLICY_HANDLE, - ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, - ENGINE_UPDATE_REGION_COUNT, ENGINE_UPDATE_REGIONS_OFFSET, - ENGINE_UPDATE_REQUEST_HEADER_SIZE, ENGINE_UPDATE_SEMANTIC_VIEW_MASK, - ENGINE_UPDATE_SESSION_ID, ENGINE_UPDATE_STYLE_MUTATION_COUNT, - ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, ENGINE_UPDATE_TEXT_MUTATION_COUNT, - ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, + ENGINE_UPDATE_POLICY_HANDLE, ENGINE_UPDATE_POLICY_PARAMETERS_LENGTH, + ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, ENGINE_UPDATE_REGION_COUNT, + ENGINE_UPDATE_REGIONS_OFFSET, ENGINE_UPDATE_REQUEST_HEADER_SIZE, + ENGINE_UPDATE_SEMANTIC_VIEW_MASK, ENGINE_UPDATE_SESSION_ID, + ENGINE_UPDATE_STYLE_MUTATION_COUNT, ENGINE_UPDATE_STYLE_MUTATIONS_OFFSET, + ENGINE_UPDATE_TEXT_MUTATION_COUNT, ENGINE_UPDATE_TEXT_MUTATIONS_OFFSET, }, engine::{ frame::{UpdateLimits, UpdateRequest}, diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 9aa696c3..522f63da 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -5,8 +5,8 @@ use crate::{ abi_contract::{ self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, - ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, - ENGINE_TEXT_MUTATION_PARAGRAPH_ID, ENGINE_TEXT_MUTATION_RECORD_SIZE, + ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, + ENGINE_TEXT_MUTATION_RECORD_ALIGNMENT, ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_RESERVED0, ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, @@ -19,16 +19,15 @@ use crate::{ DECORATION_NONE, DECORATION_SOLID, DECORATION_WAVY, EXCLUSION_WRAP_BOTH, EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, - OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, SHAPE_POLYGON, SHAPE_RECTANGLE, - STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, - STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, + OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, + SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, + STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, UpdateLimits, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, - PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, }, valid_language_bytes, valid_tag, wire::{array, read_f32, read_u16, read_u32}, @@ -214,10 +213,22 @@ impl GeometryBatch<'_> { self.constraints.len() / abi::ENGINE_CONSTRAINT_RECORD_SIZE as usize } + pub(crate) fn region_count(self) -> usize { + self.regions.len() / abi::ENGINE_REGION_RECORD_SIZE as usize + } + + pub(crate) fn exclusion_count(self) -> usize { + self.exclusions.len() / abi::ENGINE_EXCLUSION_RECORD_SIZE as usize + } + pub(crate) fn inline_object_count(self) -> usize { self.inline_objects.len() / abi::ENGINE_INLINE_OBJECT_RECORD_SIZE as usize } + pub(crate) fn is_empty(self) -> bool { + self.constraints.is_empty() && self.inline_objects.is_empty() + } + pub(crate) fn take_paragraph( self, paragraph_id: u32, @@ -348,33 +359,68 @@ impl GeometryBatch<'_> { for section in [self.constraints, self.inline_objects] { mix_bytes(&mut hash, section); } - for record in self - .regions - .chunks_exact(abi::ENGINE_REGION_RECORD_SIZE as usize) - { - mix_record_without_u32(&mut hash, record, abi::ENGINE_REGION_VERTICES_OFFSET); - mix_vertex_payload( - &mut hash, - self.request, - record, - abi::ENGINE_REGION_SHAPE, - abi::ENGINE_REGION_VERTICES_OFFSET, - abi::ENGINE_REGION_VERTEX_COUNT, - ); - } - for record in self - .exclusions - .chunks_exact(abi::ENGINE_EXCLUSION_RECORD_SIZE as usize) - { - mix_record_without_u32(&mut hash, record, abi::ENGINE_EXCLUSION_VERTICES_OFFSET); - mix_vertex_payload( - &mut hash, - self.request, - record, - abi::ENGINE_EXCLUSION_SHAPE, - abi::ENGINE_EXCLUSION_VERTICES_OFFSET, - abi::ENGINE_EXCLUSION_VERTEX_COUNT, - ); + for constraint_index in 0..self.constraint_count() { + let Some(constraint) = self.constraint(constraint_index) else { + continue; + }; + let Ok(region_start) = usize::try_from(constraint.region_start) else { + continue; + }; + let Some(region_end) = region_start.checked_add(usize::from(constraint.region_count)) + else { + continue; + }; + for region_index in region_start..region_end { + let Some(record) = + record_at(self.regions, abi::ENGINE_REGION_RECORD_SIZE, region_index) + else { + continue; + }; + mix_record_without_u32(&mut hash, record, abi::ENGINE_REGION_VERTICES_OFFSET); + mix_vertex_payload( + &mut hash, + self.request, + record, + abi::ENGINE_REGION_SHAPE, + abi::ENGINE_REGION_VERTICES_OFFSET, + abi::ENGINE_REGION_VERTEX_COUNT, + ); + let Ok(exclusion_start) = + read_u16(record, abi::ENGINE_REGION_EXCLUSION_START).map(usize::from) + else { + continue; + }; + let Ok(exclusion_count) = read_u16(record, abi::ENGINE_REGION_EXCLUSION_COUNT) + else { + continue; + }; + let Some(exclusion_end) = exclusion_start.checked_add(usize::from(exclusion_count)) + else { + continue; + }; + for exclusion_index in exclusion_start..exclusion_end { + let Some(exclusion) = record_at( + self.exclusions, + abi::ENGINE_EXCLUSION_RECORD_SIZE, + exclusion_index, + ) else { + continue; + }; + mix_record_without_u32( + &mut hash, + exclusion, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + ); + mix_vertex_payload( + &mut hash, + self.request, + exclusion, + abi::ENGINE_EXCLUSION_SHAPE, + abi::ENGINE_EXCLUSION_VERTICES_OFFSET, + abi::ENGINE_EXCLUSION_VERTEX_COUNT, + ); + } + } } hash } @@ -493,11 +539,7 @@ impl<'a> TextMutationBatch<'a> { self.get(index).map(|mutation| mutation.paragraph_id) } - pub(crate) fn take_paragraph( - self, - paragraph_id: u32, - cursor: &mut usize, - ) -> Result { + pub(crate) fn take_paragraph(self, paragraph_id: u32, cursor: &mut usize) -> Result { Ok(Self { request: self.request, records: take_records( @@ -638,11 +680,7 @@ impl<'a> StyleMutationBatch<'a> { } } - pub(crate) fn take_paragraph( - self, - paragraph_id: u32, - cursor: &mut usize, - ) -> Result { + pub(crate) fn take_paragraph(self, paragraph_id: u32, cursor: &mut usize) -> Result { Ok(Self { request: self.request, records: take_records( @@ -1062,7 +1100,10 @@ pub(crate) fn parse_paragraph_mutations( if paragraph_id == 0 || byte(record, abi::ENGINE_PARAGRAPH_MUTATION_FLAGS)? != 0 || read_u16(record, abi::ENGINE_PARAGRAPH_MUTATION_RESERVED0)? != 0 - || !matches!(opcode, PARAGRAPH_MUTATION_UPSERT | PARAGRAPH_MUTATION_REMOVE) + || !matches!( + opcode, + PARAGRAPH_MUTATION_UPSERT | PARAGRAPH_MUTATION_REMOVE + ) || (opcode == PARAGRAPH_MUTATION_REMOVE && order != 0) || prior_u32_duplicate( records, @@ -1832,11 +1873,7 @@ mod tests { offset + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, 7, ); - write_u32( - &mut bytes, - offset + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, - 3, - ); + write_u32(&mut bytes, offset + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 3); let second = offset + stride; bytes[second + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_REMOVE; write_u32( @@ -1857,23 +1894,11 @@ mod tests { Some(ParagraphMutation::Remove { paragraph_id: 8 }) ); - write_u32( - &mut bytes, - second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, - 1, - ); + write_u32(&mut bytes, second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 1); assert!(parse_paragraph_mutations(&bytes, offset as u32, 2).is_err()); - write_u32( - &mut bytes, - second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, - 0, - ); + write_u32(&mut bytes, second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 0); bytes[second + abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = PARAGRAPH_MUTATION_UPSERT; - write_u32( - &mut bytes, - second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, - 3, - ); + write_u32(&mut bytes, second + abi::ENGINE_PARAGRAPH_MUTATION_ORDER, 3); assert!(parse_paragraph_mutations(&bytes, offset as u32, 2).is_err()); } @@ -2074,8 +2099,14 @@ mod tests { let style_bytes = valid_style_bytes(); let styles = parse_style_mutations(&style_bytes, STYLE_OFFSET as u32, 1).unwrap(); let mut style_cursor = 0; - assert_eq!(styles.take_paragraph(2, &mut style_cursor).unwrap().len(), 0); - assert_eq!(styles.take_paragraph(1, &mut style_cursor).unwrap().len(), 1); + assert_eq!( + styles.take_paragraph(2, &mut style_cursor).unwrap().len(), + 0 + ); + assert_eq!( + styles.take_paragraph(1, &mut style_cursor).unwrap().len(), + 1 + ); assert_eq!(style_cursor, 1); let geometry_bytes = valid_geometry_bytes(); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 29d8b26b..dc3d562d 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -80,7 +80,6 @@ struct ClusterRecord { #[derive(Default)] struct EngineSession { - paragraph_id: Option, revision: SessionRevision, acknowledged_publication_generation: u32, policy_binding: Option, @@ -89,7 +88,29 @@ struct EngineSession { pending_next_glyph_id: u32, next_content_revision: u32, pending_next_content_revision: u32, - paragraph: ParagraphState, + text_capacity: usize, + spare_paragraph: Option, + paragraphs: Vec, + ordered_paragraphs: Vec, + pending_ordered_paragraphs: Vec, + lifecycle_prepared: bool, + lifecycle_changed: bool, +} + +struct RetainedParagraph { + id: u32, + order: u32, + pending_order: Option, + pending_remove: bool, + created: bool, + positioned_changed: bool, + state: ParagraphState, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct ParagraphOrder { + order: u32, + id: u32, } #[derive(Default)] @@ -351,7 +372,9 @@ impl TextEngine { return Err(EngineError::SessionConflict); } let mut session = EngineSession::default(); - session.paragraph.initialize()?; + let mut spare = ParagraphState::default(); + spare.initialize()?; + session.spare_paragraph = Some(spare); self.sessions.insert(handle, session); Ok(()) } @@ -369,7 +392,14 @@ impl TextEngine { .sessions .get_mut(&handle) .ok_or(EngineError::SessionMissing)?; - session.paragraph.reserve_text(capacity) + if let Some(paragraph) = session.spare_paragraph.as_mut() { + paragraph.reserve_text(capacity)?; + } + for paragraph in &mut session.paragraphs { + paragraph.state.reserve_text(capacity)?; + } + session.text_capacity = session.text_capacity.max(capacity); + Ok(()) } pub(crate) fn session_revision(&self, handle: u32) -> Result { @@ -383,7 +413,8 @@ impl TextEngine { pub(crate) fn session_text(&self, handle: u32) -> Result<&[u16], EngineError> { self.sessions .get(&handle) - .map(|session| session.paragraph.text.as_slice()) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.text.as_slice()) .ok_or(EngineError::SessionMissing) } @@ -391,7 +422,8 @@ impl TextEngine { pub(crate) fn session_style_count(&self, handle: u32) -> Result { self.sessions .get(&handle) - .map(|session| session.paragraph.styles.len()) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.styles.len()) .ok_or(EngineError::SessionMissing) } @@ -399,7 +431,8 @@ impl TextEngine { pub(crate) fn session_style_segment_count(&self, handle: u32) -> Result { self.sessions .get(&handle) - .map(|session| session.paragraph.resolved_styles.segments().len()) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.resolved_styles.segments().len()) .ok_or(EngineError::SessionMissing) } @@ -407,7 +440,8 @@ impl TextEngine { pub(crate) fn session_shaping_run_count(&self, handle: u32) -> Result { self.sessions .get(&handle) - .map(|session| session.paragraph.shaping_runs.runs().len()) + .and_then(EngineSession::first_paragraph_state) + .map(|paragraph| paragraph.shaping_runs.runs().len()) .ok_or(EngineError::SessionMissing) } @@ -442,7 +476,6 @@ impl TextEngine { if !request.limits.all_nonzero() { return Err(EngineError::InvalidRequest); } - let paragraph_id = request_paragraph_id(request)?; let policy = self .policies .get(&request.policy_handle) @@ -461,12 +494,6 @@ impl TextEngine { .sessions .get_mut(&request.session_id) .ok_or(EngineError::SessionMissing)?; - if session.paragraph_id.is_some() - && paragraph_id.is_some() - && session.paragraph_id != paragraph_id - { - return Err(EngineError::InvalidRequest); - } if session.policy_binding.is_some_and(|binding| { binding.handle != request.policy_handle || binding.fingerprint != policy_fingerprint }) { @@ -500,25 +527,53 @@ impl TextEngine { session.acknowledged_publication_generation = request.acknowledged_publication_generation; let mut next_glyph_id = session.next_glyph_id.max(1); let mut next_content_revision = session.next_content_revision.max(1); - let (text_mutations, style_mutations, geometry) = if let Some(paragraph_id) = paragraph_id { + let implicit_paragraph = + if request.paragraph_mutations.len() == 0 && session.paragraphs.is_empty() { + request_semantic_paragraph_id(request)? + } else { + None + }; + let preparation = (|| { + session.prepare_lifecycle( + request.paragraph_mutations, + implicit_paragraph, + request.limits.max_paragraphs, + )?; let (mut text_cursor, mut style_cursor) = (0, 0); let (mut constraint_cursor, mut inline_object_cursor) = (0, 0); - let text = request - .text_mutations - .take_paragraph(paragraph_id, &mut text_cursor) - .map_err(|_| EngineError::InvalidRequest)?; - let styles = request - .style_mutations - .take_paragraph(paragraph_id, &mut style_cursor) - .map_err(|_| EngineError::InvalidRequest)?; - let geometry = request - .geometry - .take_paragraph( - paragraph_id, - &mut constraint_cursor, - &mut inline_object_cursor, - ) - .map_err(|_| EngineError::InvalidRequest)?; + for order_index in 0..session.active_order().len() { + let paragraph_id = session.active_order()[order_index].id; + let text = request + .text_mutations + .take_paragraph(paragraph_id, &mut text_cursor) + .map_err(|_| EngineError::InvalidRequest)?; + let styles = request + .style_mutations + .take_paragraph(paragraph_id, &mut style_cursor) + .map_err(|_| EngineError::InvalidRequest)?; + let geometry = request + .geometry + .take_paragraph( + paragraph_id, + &mut constraint_cursor, + &mut inline_object_cursor, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let paragraph = session + .paragraph_mut(paragraph_id) + .ok_or(EngineError::InvalidRequest)?; + paragraph.positioned_changed = paragraph.state.prepare( + shaper.as_deref_mut(), + font_stacks, + font_bindings, + text, + styles, + geometry, + request.limits, + &mut next_glyph_id, + &mut next_content_revision, + )?; + } if text_cursor != request.text_mutations.len() || style_cursor != request.style_mutations.len() || constraint_cursor != request.geometry.constraint_count() @@ -526,123 +581,91 @@ impl TextEngine { { return Err(EngineError::InvalidRequest); } - (text, styles, geometry) - } else { - (request.text_mutations, request.style_mutations, request.geometry) - }; - let paragraph = &mut session.paragraph; - paragraph.prepare_text(text_mutations)?; - if let Err(error) = paragraph.prepare_styles(style_mutations, |handle| { - font_stacks - .binary_search_by_key(&handle, |stack| stack.handle) - .is_ok() - }) { - paragraph.abort_all(); - return Err(error); - } - if let Err(error) = paragraph.prepare_unicode() { - paragraph.abort_all(); - return Err(error); - } - if let Err(error) = paragraph.prepare_bidi() { - paragraph.abort_all(); - return Err(error); - } - if let Err(error) = paragraph.prepare_shaping_runs() { - paragraph.abort_all(); - return Err(error); - } - if let Some(shaper) = shaper.as_deref_mut() { - if let Err(error) = paragraph.prepare_shape(shaper, font_stacks, font_bindings) { - paragraph.abort_all(); - return Err(error); - } - if let Err(error) = paragraph.prepare_clusters(shaper, &mut next_glyph_id) { - paragraph.abort_all(); - return Err(error); - } - } - if let Err(error) = paragraph.prepare_geometry(geometry) { - paragraph.abort_all(); - return Err(error); - } - let flow_changed = paragraph.clusters_prepared - || paragraph.geometry_prepared - || paragraph.style_invalidation.metrics; - let positioned_changed = flow_changed || paragraph.style_invalidation.positioning; - if let Some(shaper) = shaper { - if flow_changed - && let Err(error) = paragraph.prepare_flow_layout( - shaper, - font_stacks, - font_bindings, - request.limits.max_lines, - request.limits.max_slots_per_band, - ) - { - paragraph.abort_all(); - return Err(error); - } - if positioned_changed - && let Err(error) = - paragraph.prepare_positioned(shaper, &mut next_content_revision) - { - paragraph.abort_all(); - return Err(error); - } - } - let reuse_ordered_plan = !checkpoint - && !positioned_changed - && policy - .programs() - .iter() - .all(|program| program.allocation_strategy == ALLOCATION_ORDERED_DIRECT); - let plan_result = if reuse_ordered_plan { - session.plan.prepare_reuse() - } else { - let positioned = if paragraph.positioned_prepared { - ¶graph.pending_positioned + let positioned_changed = session.lifecycle_changed + || session + .paragraphs + .iter() + .any(|paragraph| paragraph.positioned_changed); + let reuse_ordered_plan = !checkpoint + && !positioned_changed + && policy + .programs() + .iter() + .all(|program| program.allocation_strategy == ALLOCATION_ORDERED_DIRECT); + if reuse_ordered_plan { + session.plan.prepare_reuse().map_err(plan_error)?; } else { - ¶graph.positioned - }; - let semantic_f32 = positioned.semantic_f32(); - let semantic_u32 = positioned.semantic_u32(); - if let Err(error) = gather.gather( - policy, - CapabilitySetId(request.capability_set), - LayoutPlanInput { - glyphs: positioned.glyphs(), - semantic_change_masks: positioned.semantic_change_masks(), - semantic_f32: &semantic_f32, - semantic_u32: &semantic_u32, - }, - checkpoint || !positioned_changed, - |handle| { - font_bindings + let record_count = + session + .active_order() .iter() - .find(|binding| binding.handle == handle) - .map(|binding| &binding.binding) - }, - ) { - paragraph.abort_all(); - return Err(gather_error(error)); + .try_fold(0usize, |total, ordered| { + let paragraph = session + .paragraph(ordered.id) + .ok_or(EngineError::InvalidRequest)?; + let positioned = if paragraph.state.positioned_prepared { + ¶graph.state.pending_positioned + } else { + ¶graph.state.positioned + }; + total + .checked_add(positioned.glyphs().len()) + .ok_or(EngineError::ResultTooLarge) + })?; + gather.begin(policy, record_count).map_err(gather_error)?; + for order_index in 0..session.active_order().len() { + let paragraph_id = session.active_order()[order_index].id; + let paragraph = session + .paragraph(paragraph_id) + .ok_or(EngineError::InvalidRequest)?; + let positioned = if paragraph.state.positioned_prepared { + ¶graph.state.pending_positioned + } else { + ¶graph.state.positioned + }; + let semantic_f32 = positioned.semantic_f32(); + let semantic_u32 = positioned.semantic_u32(); + gather + .append( + policy, + CapabilitySetId(request.capability_set), + LayoutPlanInput { + glyphs: positioned.glyphs(), + semantic_change_masks: positioned.semantic_change_masks(), + semantic_f32: &semantic_f32, + semantic_u32: &semantic_u32, + }, + checkpoint || !paragraph.positioned_changed, + |handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| &binding.binding) + }, + ) + .map_err(gather_error)?; + } + let gathered = gather.view(); + session + .plan + .prepare( + policy, + CapabilitySetId(request.capability_set), + gathered.plan_input(), + checkpoint, + publication_generation, + request.acknowledged_publication_generation, + ) + .map_err(plan_error)?; } - let gathered = gather.view(); - session.plan.prepare( - policy, - CapabilitySetId(request.capability_set), - gathered.plan_input(), - checkpoint, - publication_generation, - request.acknowledged_publication_generation, - ) - }; - if let Err(error) = plan_result { - paragraph.abort_all(); - return Err(plan_error(error)); + session.pending_next_glyph_id = next_glyph_id; + session.pending_next_content_revision = next_content_revision; + Ok(()) + })(); + if let Err(error) = preparation { + session.abort_pending(); + return Err(error); } - session.pending_next_glyph_id = next_glyph_id; - session.pending_next_content_revision = next_content_revision; Ok(PreparedUpdate { session_id: request.session_id, previous: session.revision, @@ -652,7 +675,6 @@ impl TextEngine { policy_handle: request.policy_handle, capability_set: request.capability_set, policy_fingerprint, - paragraph_id, }) } @@ -685,10 +707,7 @@ impl TextEngine { if session.revision != prepared.previous { return Err(EngineError::RevisionConflict); } - session.plan.abort(); - session.paragraph.abort_all(); - session.pending_next_glyph_id = 0; - session.pending_next_content_revision = 0; + session.abort_pending(); Ok(()) } @@ -704,7 +723,7 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.plan.commit().map_err(plan_error)?; - session.paragraph.commit_all(); + session.commit_paragraphs(); session.next_glyph_id = session.pending_next_glyph_id; session.next_content_revision = session.pending_next_content_revision; session.pending_next_glyph_id = 0; @@ -713,9 +732,6 @@ impl TextEngine { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, }); - if session.paragraph_id.is_none() { - session.paragraph_id = prepared.paragraph_id; - } session.revision = prepared.next; Ok(CommittedUpdate { session_id: prepared.session_id, @@ -726,7 +742,289 @@ impl TextEngine { } } +impl EngineSession { + #[cfg(test)] + fn first_paragraph_state(&self) -> Option<&ParagraphState> { + self.ordered_paragraphs + .first() + .and_then(|ordered| self.paragraph(ordered.id)) + .map(|paragraph| ¶graph.state) + .or_else(|| self.paragraphs.first().map(|paragraph| ¶graph.state)) + .or(self.spare_paragraph.as_ref()) + } + + fn paragraph(&self, id: u32) -> Option<&RetainedParagraph> { + self.paragraphs + .binary_search_by_key(&id, |paragraph| paragraph.id) + .ok() + .map(|index| &self.paragraphs[index]) + } + + fn paragraph_mut(&mut self, id: u32) -> Option<&mut RetainedParagraph> { + self.paragraphs + .binary_search_by_key(&id, |paragraph| paragraph.id) + .ok() + .map(|index| &mut self.paragraphs[index]) + } + + fn prepare_lifecycle( + &mut self, + mutations: super::semantic_wire::ParagraphMutationBatch<'_>, + implicit_paragraph: Option, + max_paragraphs: u32, + ) -> Result<(), EngineError> { + if self.lifecycle_prepared { + return Err(EngineError::InvalidRequest); + } + if mutations.len() == 0 && implicit_paragraph.is_none() { + return Ok(()); + } + self.lifecycle_prepared = true; + let result = (|| { + let mut creates = + usize::from(implicit_paragraph.is_some_and(|id| self.paragraph(id).is_none())); + let mut removals = 0usize; + for index in 0..mutations.len() { + match mutations.get(index).ok_or(EngineError::InvalidRequest)? { + super::semantic_wire::ParagraphMutation::Upsert { paragraph_id, .. } => { + creates += usize::from(self.paragraph(paragraph_id).is_none()); + } + super::semantic_wire::ParagraphMutation::Remove { paragraph_id } => { + if self.paragraph(paragraph_id).is_none() { + return Err(EngineError::InvalidRequest); + } + removals += 1; + } + } + } + let final_count = self + .paragraphs + .len() + .checked_add(creates) + .and_then(|count| count.checked_sub(removals)) + .ok_or(EngineError::InvalidRequest)?; + if final_count + > usize::try_from(max_paragraphs).map_err(|_| EngineError::InvalidRequest)? + { + return Err(EngineError::InvalidRequest); + } + self.paragraphs + .try_reserve(creates) + .map_err(|_| EngineError::ResultTooLarge)?; + self.pending_ordered_paragraphs + .try_reserve(final_count) + .map_err(|_| EngineError::ResultTooLarge)?; + + for index in 0..mutations.len() { + match mutations.get(index).ok_or(EngineError::InvalidRequest)? { + super::semantic_wire::ParagraphMutation::Upsert { + paragraph_id, + order, + } => self.prepare_upsert(paragraph_id, order)?, + super::semantic_wire::ParagraphMutation::Remove { paragraph_id } => { + self.paragraph_mut(paragraph_id) + .ok_or(EngineError::InvalidRequest)? + .pending_remove = true; + } + } + } + if let Some(paragraph_id) = implicit_paragraph + && self.paragraph(paragraph_id).is_none() + { + self.prepare_upsert(paragraph_id, 0)?; + } + + self.pending_ordered_paragraphs.clear(); + for paragraph in &self.paragraphs { + if paragraph.pending_remove { + continue; + } + self.pending_ordered_paragraphs.push(ParagraphOrder { + order: paragraph.pending_order.unwrap_or(paragraph.order), + id: paragraph.id, + }); + } + self.pending_ordered_paragraphs + .sort_unstable_by_key(|paragraph| (paragraph.order, paragraph.id)); + if self + .pending_ordered_paragraphs + .windows(2) + .any(|pair| pair[0].order == pair[1].order) + { + return Err(EngineError::InvalidRequest); + } + self.lifecycle_changed = self.pending_ordered_paragraphs != self.ordered_paragraphs; + Ok(()) + })(); + if result.is_err() { + self.abort_lifecycle(); + } + result + } + + fn prepare_upsert(&mut self, id: u32, order: u32) -> Result<(), EngineError> { + match self + .paragraphs + .binary_search_by_key(&id, |paragraph| paragraph.id) + { + Ok(index) => { + self.paragraphs[index].pending_order = Some(order); + Ok(()) + } + Err(index) => { + let mut state = if let Some(spare) = self.spare_paragraph.take() { + spare + } else { + let mut state = ParagraphState::default(); + state.initialize()?; + state + }; + if let Err(error) = state.reserve_text(self.text_capacity) { + if self.spare_paragraph.is_none() { + self.spare_paragraph = Some(state); + } + return Err(error); + } + self.paragraphs.insert( + index, + RetainedParagraph { + id, + order, + pending_order: Some(order), + pending_remove: false, + created: true, + positioned_changed: false, + state, + }, + ); + Ok(()) + } + } + } + + fn active_order(&self) -> &[ParagraphOrder] { + if self.lifecycle_prepared { + &self.pending_ordered_paragraphs + } else { + &self.ordered_paragraphs + } + } + + fn abort_pending(&mut self) { + self.plan.abort(); + for paragraph in &mut self.paragraphs { + paragraph.state.abort_all(); + paragraph.positioned_changed = false; + } + self.abort_lifecycle(); + self.pending_next_glyph_id = 0; + self.pending_next_content_revision = 0; + } + + fn abort_lifecycle(&mut self) { + let mut index = 0; + while index < self.paragraphs.len() { + if self.paragraphs[index].created { + let mut paragraph = self.paragraphs.remove(index); + paragraph.state.abort_all(); + if self.spare_paragraph.is_none() { + self.spare_paragraph = Some(paragraph.state); + } + } else { + let paragraph = &mut self.paragraphs[index]; + paragraph.pending_order = None; + paragraph.pending_remove = false; + paragraph.positioned_changed = false; + index += 1; + } + } + self.pending_ordered_paragraphs.clear(); + self.lifecycle_prepared = false; + self.lifecycle_changed = false; + } + + fn commit_paragraphs(&mut self) { + for paragraph in &mut self.paragraphs { + paragraph.state.commit_all(); + paragraph.positioned_changed = false; + } + if !self.lifecycle_prepared { + return; + } + let mut index = 0; + while index < self.paragraphs.len() { + if self.paragraphs[index].pending_remove { + let paragraph = self.paragraphs.remove(index); + if self.spare_paragraph.is_none() { + self.spare_paragraph = Some(paragraph.state); + } + } else { + let paragraph = &mut self.paragraphs[index]; + if let Some(order) = paragraph.pending_order.take() { + paragraph.order = order; + } + paragraph.created = false; + index += 1; + } + } + core::mem::swap( + &mut self.ordered_paragraphs, + &mut self.pending_ordered_paragraphs, + ); + self.pending_ordered_paragraphs.clear(); + self.lifecycle_prepared = false; + self.lifecycle_changed = false; + } +} + impl ParagraphState { + #[allow(clippy::too_many_arguments)] + fn prepare( + &mut self, + mut shaper: Option<&mut ShaperRegistry>, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + text_mutations: super::semantic_wire::TextMutationBatch<'_>, + style_mutations: super::semantic_wire::StyleMutationBatch<'_>, + geometry: super::semantic_wire::GeometryBatch<'_>, + limits: super::frame::UpdateLimits, + next_glyph_id: &mut u32, + next_content_revision: &mut u32, + ) -> Result { + self.prepare_text(text_mutations)?; + self.prepare_styles(style_mutations, |handle| { + font_stacks + .binary_search_by_key(&handle, |stack| stack.handle) + .is_ok() + })?; + self.prepare_unicode()?; + self.prepare_bidi()?; + self.prepare_shaping_runs()?; + if let Some(shaper) = shaper.as_deref_mut() { + self.prepare_shape(shaper, font_stacks, font_bindings)?; + self.prepare_clusters(shaper, next_glyph_id)?; + } + self.prepare_geometry(geometry)?; + let flow_changed = + self.clusters_prepared || self.geometry_prepared || self.style_invalidation.metrics; + let positioned_changed = flow_changed || self.style_invalidation.positioning; + if let Some(shaper) = shaper { + if flow_changed { + self.prepare_flow_layout( + shaper, + font_stacks, + font_bindings, + limits.max_lines, + limits.max_slots_per_band, + )?; + } + if positioned_changed { + self.prepare_positioned(shaper, next_content_revision)?; + } + } + Ok(positioned_changed) + } + fn abort_all(&mut self) { self.abort_text(); self.abort_styles(); @@ -1311,6 +1609,9 @@ impl ParagraphState { geometry: super::semantic_wire::GeometryBatch<'_>, ) -> Result<(), EngineError> { self.abort_geometry(); + if geometry.is_empty() { + return Ok(()); + } let text_length = if self.text_prepared { self.pending_text.len() } else { @@ -1685,28 +1986,19 @@ fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineErro Ok(()) } -fn request_paragraph_id(request: UpdateRequest<'_>) -> Result, EngineError> { - let mut declared_id = None; - for index in 0..request.paragraph_mutations.len() { - match request - .paragraph_mutations - .get(index) - .ok_or(EngineError::InvalidRequest)? - { - super::semantic_wire::ParagraphMutation::Upsert { paragraph_id, .. } => { - merge_paragraph_id(&mut declared_id, Some(paragraph_id))?; - } - super::semantic_wire::ParagraphMutation::Remove { .. } => { - return Err(EngineError::InvalidRequest); - } - } - } +fn request_semantic_paragraph_id(request: UpdateRequest<'_>) -> Result, EngineError> { let mut paragraph_id = None; for index in 0..request.text_mutations.len() { - merge_paragraph_id(&mut paragraph_id, request.text_mutations.paragraph_id(index))?; + merge_paragraph_id( + &mut paragraph_id, + request.text_mutations.paragraph_id(index), + )?; } for index in 0..request.style_mutations.len() { - merge_paragraph_id(&mut paragraph_id, request.style_mutations.paragraph_id(index))?; + merge_paragraph_id( + &mut paragraph_id, + request.style_mutations.paragraph_id(index), + )?; } let geometry_count = request .geometry @@ -1716,10 +2008,7 @@ fn request_paragraph_id(request: UpdateRequest<'_>) -> Result, Engin for index in 0..geometry_count { merge_paragraph_id(&mut paragraph_id, request.geometry.paragraph_id(index))?; } - if declared_id.is_some() && paragraph_id.is_some() && declared_id != paragraph_id { - return Err(EngineError::InvalidRequest); - } - Ok(declared_id.or(paragraph_id)) + Ok(paragraph_id) } fn merge_paragraph_id( @@ -1768,8 +2057,8 @@ mod tests { self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, ENGINE_TEXT_MUTATION_INSERT_COUNT, ENGINE_TEXT_MUTATION_INSERT_OFFSET, ENGINE_TEXT_MUTATION_OPCODE, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, - ENGINE_TEXT_MUTATION_RECORD_SIZE, - ENGINE_TEXT_MUTATION_TEXT_START, ENGINE_UPDATE_REQUEST_HEADER_SIZE, + ENGINE_TEXT_MUTATION_RECORD_SIZE, ENGINE_TEXT_MUTATION_TEXT_START, + ENGINE_UPDATE_REQUEST_HEADER_SIZE, }, bidi::DIRECTION_RTL, engine::{ @@ -1777,10 +2066,10 @@ mod tests { FieldTable, FontRenderBinding, FontResource, FontStrike, MISSING_RESOURCE_INDEX, }, frame::{ - STYLE_FIELD_DIRECTION, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, - STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FLAG_ROOT, - STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, - TEXT_MUTATION_REPLACE_UTF16, + PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, STYLE_FIELD_DIRECTION, + STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_LINE_HEIGHT, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, + STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, @@ -1788,7 +2077,9 @@ mod tests { BufferSchema, CAP_ORDERED_DIRECT, CapabilitySet, Operation, PolicyDescriptor, ProgramCapabilities, ProgramDescriptor, ProgramId, ScalarType, TechniqueId, }, - semantic_wire::{parse_style_mutations, parse_text_mutations}, + semantic_wire::{ + parse_paragraph_mutations, parse_style_mutations, parse_text_mutations, + }, }, wire::write_u32, }; @@ -2069,7 +2360,13 @@ mod tests { engine.commit_update(prepared).unwrap(); assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); assert_eq!( - engine.sessions.get(&4).unwrap().paragraph.text_unit_ids, + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .text_unit_ids, [1, 2, 3, 4] ); assert_eq!( @@ -2077,7 +2374,8 @@ mod tests { .sessions .get(&4) .unwrap() - .paragraph + .first_paragraph_state() + .unwrap() .unicode .grapheme_boundaries(), &[0, 1, 2, 3, 4] @@ -2092,8 +2390,9 @@ mod tests { engine.abort_update(prepared).unwrap(); assert_eq!(engine.session_text(4).unwrap(), &[0x61, 0x62, 0x63, 0x64]); let session = engine.sessions.get(&4).unwrap(); - assert_eq!(session.paragraph.text_unit_ids, [1, 2, 3, 4]); - assert_eq!(session.paragraph.next_text_unit_id, 5); + let paragraph = session.first_paragraph_state().unwrap(); + assert_eq!(paragraph.text_unit_ids, [1, 2, 3, 4]); + assert_eq!(paragraph.next_text_unit_id, 5); let retry = engine.prepare_update(edit, 2).unwrap(); engine.commit_update(retry).unwrap(); @@ -2102,16 +2401,20 @@ mod tests { &[0x61, 0x58, 0x59, 0x63, 0x64, 0x21] ); assert_eq!( - engine.sessions.get(&4).unwrap().paragraph.text_unit_ids, + engine + .sessions + .get(&4) + .unwrap() + .first_paragraph_state() + .unwrap() + .text_unit_ids, [1, 5, 6, 3, 4, 7] ); let settled_capacities = { let session = engine.sessions.get(&4).unwrap(); - [ - session.paragraph.text.capacity(), - session.paragraph.pending_text.capacity(), - ] + let paragraph = session.first_paragraph_state().unwrap(); + [paragraph.text.capacity(), paragraph.pending_text.capacity()] }; let warm_bytes = text_mutation_bytes(&[(0, 1, &[0x7a])]); let warm_batch = @@ -2121,12 +2424,10 @@ mod tests { let prepared = engine.prepare_update(warm, 3).unwrap(); engine.commit_update(prepared).unwrap(); let session = engine.sessions.get(&4).unwrap(); - assert_eq!(session.paragraph.text_unit_ids, [8, 5, 6, 3, 4, 7]); + let paragraph = session.first_paragraph_state().unwrap(); + assert_eq!(paragraph.text_unit_ids, [8, 5, 6, 3, 4, 7]); assert_eq!( - [ - session.paragraph.pending_text.capacity(), - session.paragraph.text.capacity(), - ], + [paragraph.pending_text.capacity(), paragraph.text.capacity(),], settled_capacities ); } @@ -2148,9 +2449,10 @@ mod tests { Err(EngineError::InvalidRequest) ); let session = engine.sessions.get(&4).unwrap(); - assert!(session.paragraph.text.is_empty()); - assert!(session.paragraph.unicode.grapheme_boundaries().is_empty()); - assert!(session.paragraph.bidi.levels.is_empty()); + let paragraph = session.first_paragraph_state().unwrap(); + assert!(paragraph.text.is_empty()); + assert!(paragraph.unicode.grapheme_boundaries().is_empty()); + assert!(paragraph.bidi.levels.is_empty()); } #[test] @@ -2173,7 +2475,8 @@ mod tests { .sessions .get(&4) .unwrap() - .paragraph + .first_paragraph_state() + .unwrap() .bidi .paragraph_levels, &[0] @@ -2189,7 +2492,8 @@ mod tests { .sessions .get(&4) .unwrap() - .paragraph + .first_paragraph_state() + .unwrap() .bidi .paragraph_levels, &[0] @@ -2200,7 +2504,8 @@ mod tests { .sessions .get(&4) .unwrap() - .paragraph + .first_paragraph_state() + .unwrap() .bidi .paragraph_levels, &[1] @@ -2275,6 +2580,197 @@ mod tests { assert!(engine.session_text(4).unwrap().is_empty()); } + #[test] + fn ordered_paragraphs_commit_reorder_and_remove_as_one_session() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + engine.reserve_session_text(4, 8).unwrap(); + + let lifecycle_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 2, 1), + (PARAGRAPH_MUTATION_UPSERT, 1, 0), + ]); + let text_bytes = + paragraph_text_mutation_bytes(&[(1, 0, 0, &[0x61, 0x62]), (2, 0, 0, &[0x63, 0x64])]); + let mut initial = update(0, 0, 0); + initial.limits.max_paragraphs = 2; + initial.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + initial.text_mutations = + parse_text_mutations(&text_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [1, 2] + ); + assert_eq!(session.paragraph(1).unwrap().state.text, [0x61, 0x62]); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x63, 0x64]); + + let reorder_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 1), + (PARAGRAPH_MUTATION_UPSERT, 2, 0), + ]); + let mut reorder = update(1, 1, 1); + reorder.limits.max_paragraphs = 2; + reorder.paragraph_mutations = + parse_paragraph_mutations(&reorder_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + let prepared = engine.prepare_update(reorder, 2).unwrap(); + engine.commit_update(prepared).unwrap(); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [2, 1] + ); + assert_eq!(session.paragraph(1).unwrap().state.text, [0x61, 0x62]); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x63, 0x64]); + + let remove_bytes = paragraph_mutation_bytes(&[(PARAGRAPH_MUTATION_REMOVE, 1, 0)]); + let mut remove = update(2, 2, 2); + remove.limits.max_paragraphs = 2; + remove.paragraph_mutations = + parse_paragraph_mutations(&remove_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(remove, 3).unwrap(); + engine.commit_update(prepared).unwrap(); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session.ordered_paragraphs, + [ParagraphOrder { order: 0, id: 2 }] + ); + assert!(session.paragraph(1).is_none()); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x63, 0x64]); + assert!(session.spare_paragraph.is_some()); + } + + #[test] + fn a_later_paragraph_failure_rolls_back_every_child_and_lifecycle_change() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let lifecycle_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 0), + (PARAGRAPH_MUTATION_UPSERT, 2, 1), + ]); + let initial_text = paragraph_text_mutation_bytes(&[(1, 0, 0, &[0x61]), (2, 0, 0, &[0x62])]); + let mut initial = update(0, 0, 0); + initial.limits.max_paragraphs = 2; + initial.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + initial.text_mutations = + parse_text_mutations(&initial_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let reorder_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 1), + (PARAGRAPH_MUTATION_UPSERT, 2, 0), + ]); + let invalid_text = paragraph_text_mutation_bytes(&[(1, 0, 1, &[0x78]), (2, 9, 0, &[0x79])]); + let mut invalid = update(1, 1, 1); + invalid.limits.max_paragraphs = 2; + invalid.paragraph_mutations = + parse_paragraph_mutations(&reorder_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + invalid.text_mutations = + parse_text_mutations(&invalid_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); + assert_eq!( + engine.prepare_update(invalid, 2), + Err(EngineError::InvalidRequest) + ); + + let session = engine.sessions.get(&4).unwrap(); + assert_eq!(session.revision, SessionRevision { engine: 1, plan: 1 }); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [1, 2] + ); + assert_eq!(session.paragraph(1).unwrap().state.text, [0x61]); + assert_eq!(session.paragraph(2).unwrap().state.text, [0x62]); + assert!(!session.lifecycle_prepared); + } + + #[test] + fn paragraph_limits_unknown_semantics_and_order_collisions_are_atomic() { + let mut engine = TextEngine::default(); + engine + .register_policy(9, validated_policy(TechniqueId(1))) + .unwrap(); + engine.create_session(4).unwrap(); + let lifecycle_bytes = paragraph_mutation_bytes(&[ + (PARAGRAPH_MUTATION_UPSERT, 1, 0), + (PARAGRAPH_MUTATION_UPSERT, 2, 1), + ]); + let mut too_many = update(0, 0, 0); + too_many.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + assert_eq!( + engine.prepare_update(too_many, 1), + Err(EngineError::InvalidRequest) + ); + assert!(engine.sessions.get(&4).unwrap().paragraphs.is_empty()); + + let mut initial = update(0, 0, 0); + initial.limits.max_paragraphs = 2; + initial.paragraph_mutations = + parse_paragraph_mutations(&lifecycle_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2) + .unwrap(); + let prepared = engine.prepare_update(initial, 1).unwrap(); + engine.commit_update(prepared).unwrap(); + + let collision_bytes = paragraph_mutation_bytes(&[(PARAGRAPH_MUTATION_UPSERT, 1, 1)]); + let mut collision = update(1, 1, 1); + collision.limits.max_paragraphs = 2; + collision.paragraph_mutations = + parse_paragraph_mutations(&collision_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1) + .unwrap(); + assert_eq!( + engine.prepare_update(collision, 2), + Err(EngineError::InvalidRequest) + ); + + let unknown_text = paragraph_text_mutation_bytes(&[(3, 0, 0, &[0x61])]); + let mut unknown = update(1, 1, 1); + unknown.limits.max_paragraphs = 2; + unknown.text_mutations = + parse_text_mutations(&unknown_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + assert_eq!( + engine.prepare_update(unknown, 2), + Err(EngineError::InvalidRequest) + ); + let session = engine.sessions.get(&4).unwrap(); + assert_eq!( + session + .ordered_paragraphs + .iter() + .map(|entry| entry.id) + .collect::>(), + [1, 2] + ); + } + #[test] fn single_paragraph_session_rejects_mixed_and_rebound_paragraph_ids() { let mut engine = TextEngine::default(); @@ -2284,19 +2780,15 @@ mod tests { engine.create_session(4).unwrap(); let mut mixed_bytes = text_mutation_bytes(&[(0, 0, &[0x61]), (1, 0, &[0x62])]); - let second = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize - + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let second = + ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; write_u32( &mut mixed_bytes, second + ENGINE_TEXT_MUTATION_PARAGRAPH_ID, 2, ); - let mixed = parse_text_mutations( - &mixed_bytes, - ENGINE_UPDATE_REQUEST_HEADER_SIZE, - 2, - ) - .unwrap(); + let mixed = + parse_text_mutations(&mixed_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 2).unwrap(); let mut request = update(0, 0, 0); request.text_mutations = mixed; assert_eq!( @@ -2306,12 +2798,8 @@ mod tests { let initial_bytes = text_mutation_bytes(&[(0, 0, &[0x61])]); let mut initial = update(0, 0, 0); - initial.text_mutations = parse_text_mutations( - &initial_bytes, - ENGINE_UPDATE_REQUEST_HEADER_SIZE, - 1, - ) - .unwrap(); + initial.text_mutations = + parse_text_mutations(&initial_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); let prepared = engine.prepare_update(initial, 1).unwrap(); engine.commit_update(prepared).unwrap(); @@ -2322,12 +2810,8 @@ mod tests { 2, ); let mut rebound = update(1, 1, 1); - rebound.text_mutations = parse_text_mutations( - &rebound_bytes, - ENGINE_UPDATE_REQUEST_HEADER_SIZE, - 1, - ) - .unwrap(); + rebound.text_mutations = + parse_text_mutations(&rebound_bytes, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); assert_eq!( engine.prepare_update(rebound, 2), Err(EngineError::InvalidRequest) @@ -2558,6 +3042,66 @@ mod tests { bytes } + fn paragraph_mutation_bytes(records: &[(u8, u32, u32)]) -> Vec { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let mut bytes = + vec![ + 0; + record_offset + records.len() * abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize + ]; + for (index, &(opcode, paragraph_id, order)) in records.iter().enumerate() { + let start = record_offset + index * abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize; + let record = + &mut bytes[start..start + abi::ENGINE_PARAGRAPH_MUTATION_RECORD_SIZE as usize]; + record[abi::ENGINE_PARAGRAPH_MUTATION_OPCODE] = opcode; + write_u32( + record, + abi::ENGINE_PARAGRAPH_MUTATION_PARAGRAPH_ID, + paragraph_id, + ); + write_u32(record, abi::ENGINE_PARAGRAPH_MUTATION_ORDER, order); + } + bytes + } + + fn paragraph_text_mutation_bytes(records: &[(u32, u32, u32, &[u16])]) -> Vec { + let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; + let records_length = records.len() * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let payload_length = records + .iter() + .map(|(_, _, _, insert)| insert.len() * 2) + .sum::(); + let mut bytes = vec![0; record_offset + records_length + payload_length]; + let mut payload_offset = record_offset + records_length; + for (index, &(paragraph_id, text_start, delete_count, insert)) in records.iter().enumerate() + { + let start = record_offset + index * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; + let record = &mut bytes[start..start + ENGINE_TEXT_MUTATION_RECORD_SIZE as usize]; + record[ENGINE_TEXT_MUTATION_OPCODE] = TEXT_MUTATION_REPLACE_UTF16; + record[ENGINE_TEXT_MUTATION_ENCODING] = TEXT_ENCODING_UTF16_LE; + write_u32(record, ENGINE_TEXT_MUTATION_PARAGRAPH_ID, paragraph_id); + write_u32(record, ENGINE_TEXT_MUTATION_TEXT_START, text_start); + write_u32(record, ENGINE_TEXT_MUTATION_DELETE_COUNT, delete_count); + if !insert.is_empty() { + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_OFFSET, + u32::try_from(payload_offset).unwrap(), + ); + write_u32( + record, + ENGINE_TEXT_MUTATION_INSERT_COUNT, + u32::try_from(insert.len()).unwrap(), + ); + for &unit in insert { + bytes[payload_offset..payload_offset + 2].copy_from_slice(&unit.to_le_bytes()); + payload_offset += 2; + } + } + } + bytes + } + fn text_mutation_bytes(records: &[(u32, u32, &[u16])]) -> Vec { let record_offset = ENGINE_UPDATE_REQUEST_HEADER_SIZE as usize; let records_length = records.len() * ENGINE_TEXT_MUTATION_RECORD_SIZE as usize; diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index ea25702d..3bc89f7b 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -7,6 +7,7 @@ import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; +import { textShaperAbi } from '../../dist/generated/text-shaper-abi.js'; import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; import { TextEngineRenderPlanView } from '../../dist/internal/render-plan-view.js'; import { FontRegistry } from '../../dist/loader.js'; @@ -100,17 +101,23 @@ test('Three coordinator shares shaping data across technique bindings and refere consumedPlanRevision: 0, acknowledgedPublicationGeneration: 0, limits: { - maxParagraphs: 1, + maxParagraphs: 2, maxClusters: 16, maxLines: 8, - maxRegions: 1, + maxRegions: 2, maxExclusions: 1, maxInlineObjects: 1, maxSlotsPerBand: 2, maxOutputBytes: 1024 * 1024, }, - paragraphMutations: [{ opcode: 'upsert', paragraphId: 1, order: 0 }], - textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: 'abc' }], + paragraphMutations: [ + { opcode: 'upsert', paragraphId: 1, order: 0 }, + { opcode: 'upsert', paragraphId: 2, order: 1 }, + ], + textMutations: [ + { paragraphId: 1, start: 0, deleteCount: 0, insert: 'abc' }, + { paragraphId: 2, start: 0, deleteCount: 0, insert: 'def' }, + ], styleMutations: [ { opcode: 'upsert', @@ -128,6 +135,22 @@ test('Three coordinator shares shaping data across technique bindings and refere foregroundRgba: 0xffff_ffff, }, }, + { + opcode: 'upsert', + paragraphId: 2, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: 8, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, ], constraints: [ { @@ -151,6 +174,27 @@ test('Three coordinator shares shaping data across technique bindings and refere overflow: 'visible', blockAlign: 'start', }, + { + paragraphId: 2, + flowThreadId: 2, + geometryRevision: 1, + width: 256, + height: 128, + viewportBlockStart: 0, + viewportBlockEnd: 128, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 1, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'at-most', + heightMode: 'at-most', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, ], regions: [ { @@ -170,6 +214,23 @@ test('Three coordinator shares shaping data across technique bindings and refere clipInlineEnd: 256, clipBlockEnd: 128, }, + { + id: 2, + geometryRevision: 1, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 256, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 256, + clipBlockEnd: 128, + }, ], }), ); @@ -181,6 +242,45 @@ test('Three coordinator shares shaping data across technique bindings and refere const firstPatch = plan.record(patches, 0); assert.ok(plan.u16(firstPatch) > 0); assert.throws(() => plan.record(patches, patches.count), /outside its table/); + const drawLayout = textShaperAbi.layouts.engineDraw; + const draws = plan.table('draws'); + assert.deepEqual( + adjacentMaterialGroups(plan, draws, drawLayout.materialId), + [7, 8], + 'Rust gathers child paragraphs into one ordered command buffer', + ); + + const reorderedPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: publication.engineRevision, + consumedPlanRevision: publication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + paragraphMutations: [ + { opcode: 'upsert', paragraphId: 1, order: 1 }, + { opcode: 'upsert', paragraphId: 2, order: 0 }, + ], + }), + ); + const reorderedPlan = plan.bind(reorderedPublication); + const reorderedDraws = reorderedPlan.table('draws'); + assert.deepEqual( + adjacentMaterialGroups(reorderedPlan, reorderedDraws, drawLayout.materialId), + [8, 7], + 'lifecycle-only reorder retains both paragraphs and changes shared draw order', + ); session.dispose(); first.release(); first.release(); @@ -197,3 +297,12 @@ test('Three coordinator shares shaping data across technique bindings and refere shaper.dispose(); registered.dispose(); }); + +function adjacentMaterialGroups(plan, draws, materialOffset) { + const groups = []; + for (let index = 0; index < draws.count; index += 1) { + const material = plan.u32(plan.record(draws, index) + materialOffset); + if (groups.at(-1) !== material) groups.push(material); + } + return groups; +} From 8447a1a16a7d2230315b2e8b653d8e45d250dc19 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 23:06:25 -0400 Subject: [PATCH 065/128] fix(text): key draws by paragraph transform --- docs/log.md | 9 +++++ docs/packages/text.md | 4 +- docs/planning/decision-register.md | 2 + docs/planning/rust-layout-engine.md | 2 +- docs/planning/three-material-authority.md | 4 +- packages/text/rust/shaper/src/abi_contract.rs | 18 +++++---- .../rust/shaper/src/engine/ordered_plan.rs | 16 ++++++-- .../text/rust/shaper/src/engine/plan_input.rs | 3 +- .../text/rust/shaper/src/engine/policy.rs | 20 +++++++--- .../rust/shaper/src/engine/policy_gather.rs | 39 +++++++++++++++++-- .../rust/shaper/src/engine/render_plan.rs | 4 +- .../shaper/src/engine/render_plan_compiler.rs | 7 +++- .../shaper/src/engine/render_plan_wire.rs | 1 + .../rust/shaper/src/engine/stable_plan.rs | 16 ++++++-- packages/text/rust/shaper/src/engine/state.rs | 7 +++- packages/text/rust/shaper/src/engine/wire.rs | 3 +- .../text/src/generated/text-shaper-abi.ts | 24 ++++++------ .../text/src/internal/render-policy-wire.ts | 9 ++++- .../render-plan-frame-abi.test.mjs | 1 + .../integration/three-engine-runtime.test.mjs | 12 ++++++ packages/text/tests/support/engine-abi.mjs | 3 +- 21 files changed, 161 insertions(+), 43 deletions(-) diff --git a/docs/log.md b/docs/log.md index f4aaeff6..ae78228d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Made transform ownership explicit and removed per-cluster draws** — The first real multi-paragraph publication + exposed that paragraph-local positions had no renderer transform owner and cluster `semantic_id` prevented primitive + coalescing. The policy now requires a paragraph-derived transform draw key, forbids transform from physical storage + identity, and publishes `transformId` in the expanded 64-byte draw record. Compatible clusters coalesce; a span that + crosses semantic IDs publishes zero rather than lying about one cluster. The compiled-Wasm fixture falls from six + draws to exactly two and reverses `(materialId, transformId)` from `[(7,1),(8,2)]` to `[(8,2),(7,1)]` without semantic + resend. Optimized Wasm changes by only +222/+83/+52 raw/gzip/Brotli bytes to + 1,082,773 / 407,870 / 324,551. + - **Published multiple retained paragraphs as one Rust command buffer** — Engine sessions now own an ordered stable-ID paragraph set rather than one paragraph. Lifecycle upsert/reorder/remove, every child semantic transaction, shared policy gather, plan serialization, and commit/abort form one atomic publication. Missing semantic spans retain a diff --git a/docs/packages/text.md b/docs/packages/text.md index 8736a367..bceb3f32 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:2fa07b0d9260a1cb1096d738142cefe19292fd5106044b7c1a134089a7e63379' +source_digest: 'sha256:ee4e160e44f8228c16af088e2d1a9cce2a2984ab5c90fc37731f3045ade082d5' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -425,7 +425,7 @@ at its deliberately stale checked package-size snapshot; this stage records the that unrelated historical evidence. The render-plan wire layer now gives those result tables concrete compiler-mapped records: semantic 44 bytes, resource -40, physical buffer 36, patch 36, primitive 64, draw 60, retirement 24, and diagnostic 24. Resource kind is independent +40, physical buffer 36, patch 36, primitive 64, draw 64, retirement 24, and diagnostic 24. Resource kind is independent from create/update/retain action, and ordered-direct versus stable-indirect allocation is a dedicated buffer strategy. Patch payload bytes live inside the same immutable publication and write records carry absolute rebased spans; allocate/ resize, fill, copy, and retire records do not carry a payload address. Serialization is allocation-free, canonical diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 61538fef..0735b1a8 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -286,6 +286,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-213 | One engine session retains multiple stable-ID paragraph states and emits one atomic, revisioned command-buffer delta for the renderer. Paragraph lifecycle records create, reorder, or remove children; absent semantic sections retain a child rather than clearing it, while present text/style/geometry spans remain forward-only borrowed views. Final paragraph order drives one allocation-free gather into the shared Rust policy/planner. Geometry retention compacts only each child's referenced regions, exclusions, and vertices, so a sibling's global table prefix cannot manufacture layout invalidation. A later-child failure aborts every child and lifecycle change; order collisions, unknown semantic owners, and paragraph limits fail before publication. A compiled-Wasm Three-coordinator proof creates two paragraphs with material groups `[7,8]`, sends a lifecycle-only reorder without resending semantic data, and observes `[8,7]` in the next Rust publication. The render plan is therefore a retained renderer-neutral command buffer, not a TypeScript batch or a native `GPUCommandBuffer`. All 128 Rust unit tests pass. Optimized Wasm changes from 1,073,248 / 404,463 / 321,189 to 1,082,551 / 407,787 / 324,499 raw/gzip/Brotli bytes. Public Three GPU realization remains open, so this checkpoint carries no end-to-end draw or latency claim. | Accepted | +| D-214 | Renderer transform ownership is an explicit required draw key. Each gathered paragraph supplies its stable paragraph ID as `transform_id`; transform is forbidden from physical-storage keys, so compatible paragraphs may share buffers, but it is required in draw keys so no packet crosses renderer object transforms. The fixed draw record grows from 60 to 64 bytes and publishes `transformId`. Cluster `semantic_id` no longer splits otherwise compatible glyph spans: a single-semantic primitive retains that ID, while a span crossing semantics publishes zero until optional semantic tables describe finer inspection. The compiled-Wasm two-paragraph fixture changes from six cluster-split draws to exactly two `(materialId, transformId)` packets and preserves exact reversed ownership after lifecycle-only reorder. This is a correctness prerequisite for Three realization and removes pathological per-cluster draws rather than inferring ownership from draw order. Optimized Wasm changes from 1,082,551 / 407,787 / 324,499 to 1,082,773 / 407,870 / 324,551 raw/gzip/Brotli bytes. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 433b9f90..a324602d 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -761,7 +761,7 @@ Bitmap uses `vec2`/`vec4` records and MSDF and Slug use `vec4`/`uvec4` records, consumer proves schema, patch, revision, and retirement semantics without claiming another renderer integration. The V0 wire checkpoint uses a 144-byte, 16-byte-aligned result header followed by compiler-mapped little-endian tables: -44-byte semantic, 40-byte resource, 36-byte physical-buffer, 36-byte patch, 64-byte primitive, 60-byte draw, 24-byte +44-byte semantic, 40-byte resource, 36-byte physical-buffer, 36-byte patch, 64-byte primitive, 64-byte draw, 24-byte retirement, and 24-byte diagnostic records. Resource kind and create/update/retain action are separate. Buffer strategy is an explicit ordered-direct or stable-indirect tag. Variable patch payload bytes are part of the same immutable publication; write patches rebase their checked payload span to an absolute result offset. Other patch opcodes carry no diff --git a/docs/planning/three-material-authority.md b/docs/planning/three-material-authority.md index fb79e980..404bd6bd 100644 --- a/docs/planning/three-material-authority.md +++ b/docs/planning/three-material-authority.md @@ -102,7 +102,9 @@ glyph coverage algorithm unless the application registers a complete custom rast The Three integration interns each live `ThreeTextMaterial` by object identity and assigns a nonzero `u32 material_id`; zero means the built-in default material. The frame request carries the resolved ID on Rust-owned material segments. Rust resolves the ordinary batch → text → span cascade, maps clusters to one material ID, and preserves that ID through -glyph primitives into draw packets. +glyph primitives into draw packets. Each packet also carries the paragraph-derived `transformId`; transform is a +required draw boundary but is excluded from first-party physical-storage identity, so distinct `Text` objects may share +buffers without a draw spanning two scene-object transforms. The registered policy has independent storage and draw key masks. Every first-party policy includes material in its draw key. A capability-specific policy may omit it from the storage key, producing different material draws over ranges in diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 08d9250a..507ca203 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -28,12 +28,13 @@ use crate::engine::frame::{ }; use crate::engine::policy::{ ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, BATCH_CLIP, BATCH_DEPTH, BATCH_MATERIAL, - BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BUFFER_USAGE_COPY_DST, - BUFFER_USAGE_STORAGE, BUFFER_USAGE_VERTEX, CAP_ALIAS_VEC2, CAP_ALIAS_VEC4, CAP_INDIRECT_DRAWS, - CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, CAP_STORAGE_BUFFERS, INPUT_GLYPH, INPUT_RESOURCE, - INPUT_SEMANTIC, INPUT_STRIKE, OP_ADD_F32, OP_CONSTANT_F32, OP_CONSTANT_U32, - OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, OP_LOAD_U32, OP_MULTIPLY_F32, - OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, OP_SUBTRACT_F32, ScalarType, + BATCH_ORDER, BATCH_PROGRAM, BATCH_RESOURCE, BATCH_TECHNIQUE, BATCH_TRANSFORM, + BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BUFFER_USAGE_VERTEX, CAP_ALIAS_VEC2, + CAP_ALIAS_VEC4, CAP_INDIRECT_DRAWS, CAP_ORDERED_DIRECT, CAP_STABLE_INDIRECT, + CAP_STORAGE_BUFFERS, INPUT_GLYPH, INPUT_RESOURCE, INPUT_SEMANTIC, INPUT_STRIKE, OP_ADD_F32, + OP_CONSTANT_F32, OP_CONSTANT_U32, OP_CONVERT_U32_TO_F32, OP_LESS_THAN_F32, OP_LOAD_F32, + OP_LOAD_U32, OP_MULTIPLY_F32, OP_SELECT_F32, OP_STORE_F32, OP_STORE_U16, OP_STORE_U32, + OP_SUBTRACT_F32, ScalarType, }; use crate::engine::render_plan::{ BUFFER_ORDERED_DIRECT, BUFFER_STABLE_INDIRECT, BufferRecord, DiagnosticRecord, DrawRecord, @@ -1828,6 +1829,7 @@ field_offset!(DRAW_FLAGS, DrawRecord, flags); field_offset!(DRAW_MATERIAL_ID, DrawRecord, material_id); field_offset!(DRAW_CLIP_ID, DrawRecord, clip_id); field_offset!(DRAW_DEPTH_KEY, DrawRecord, depth_key); +field_offset!(DRAW_TRANSFORM_ID, DrawRecord, transform_id); field_offset!(DRAW_PRIMITIVE_START, DrawRecord, primitive_start); field_offset!(DRAW_PRIMITIVE_COUNT, DrawRecord, primitive_count); field_offset!(DRAW_BUFFER_START, DrawRecord, buffer_start); @@ -2454,6 +2456,7 @@ pub fn json() -> String { "materialId": DRAW_MATERIAL_ID, "clipId": DRAW_CLIP_ID, "depthKey": DRAW_DEPTH_KEY, + "transformId": DRAW_TRANSFORM_ID, "primitiveStart": DRAW_PRIMITIVE_START, "primitiveCount": DRAW_PRIMITIVE_COUNT, "bufferStart": DRAW_BUFFER_START, @@ -2577,7 +2580,8 @@ pub fn json() -> String { "material": BATCH_MATERIAL, "clip": BATCH_CLIP, "depth": BATCH_DEPTH, - "order": BATCH_ORDER + "order": BATCH_ORDER, + "transform": BATCH_TRANSFORM }, "bufferUsage": { "vertex": BUFFER_USAGE_VERTEX, diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index b5e9adaa..598ec61f 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -1011,7 +1011,14 @@ impl OrderedPlanCompiler { logical_order: u32::try_from(input_index) .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, clip_id: first.clip_id, - semantic_id: first.semantic_id, + semantic_id: if context.input.glyphs[input_index..end] + .iter() + .all(|glyph| glyph.semantic_id == first.semantic_id) + { + first.semantic_id + } else { + 0 + }, inline_start, block_start, inline_extent, @@ -1026,6 +1033,7 @@ impl OrderedPlanCompiler { material_id: if split_material { first.material_id } else { 0 }, clip_id: first.clip_id, depth_key: first.depth_key, + transform_id: first.transform_id, primitive_start: u32::try_from(primitive_start) .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, primitive_count: 1, @@ -1068,7 +1076,7 @@ impl OrderedPlanCompiler { && (!split_material || glyph.material_id == first.material_id) && glyph.clip_id == first.clip_id && glyph.depth_key == first.depth_key - && glyph.semantic_id == first.semantic_id + && glyph.transform_id == first.transform_id } fn prepare_removed_batches( @@ -1562,6 +1570,7 @@ mod tests { resource_kind: 1, resource_reference: 99, semantic_id: 1, + transform_id: 1, material_id: 1, clip_id: 0, depth_key: 0, @@ -1614,7 +1623,8 @@ mod tests { | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_MATERIAL - | BATCH_ORDER, + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, diff --git a/packages/text/rust/shaper/src/engine/plan_input.rs b/packages/text/rust/shaper/src/engine/plan_input.rs index bfd315a3..f72b5b68 100644 --- a/packages/text/rust/shaper/src/engine/plan_input.rs +++ b/packages/text/rust/shaper/src/engine/plan_input.rs @@ -13,6 +13,7 @@ pub struct PlanGlyph { pub resource_kind: u16, pub resource_reference: u32, pub semantic_id: u32, + pub transform_id: u32, pub material_id: u32, pub clip_id: u32, pub depth_key: u32, @@ -56,7 +57,7 @@ pub fn validate_input(input: PlanInput<'_>) -> Result<(), PlanInputError> { } pub fn validate_glyph(glyph: PlanGlyph) -> Result<(), PlanInputError> { - if glyph.stable_id == 0 || glyph.content_revision == 0 { + if glyph.stable_id == 0 || glyph.content_revision == 0 || glyph.transform_id == 0 { return Err(PlanInputError::InvalidIdentity); } if glyph.resource_id == 0 diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 10e69f11..bfe42758 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -35,16 +35,18 @@ pub const BATCH_MATERIAL: u32 = 1 << 3; pub const BATCH_CLIP: u32 = 1 << 4; pub const BATCH_DEPTH: u32 = 1 << 5; pub const BATCH_ORDER: u32 = 1 << 6; +pub const BATCH_TRANSFORM: u32 = 1 << 7; const BATCH_FIELDS: u32 = BATCH_TECHNIQUE | BATCH_RESOURCE | BATCH_PROGRAM | BATCH_MATERIAL | BATCH_CLIP | BATCH_DEPTH - | BATCH_ORDER; -const STORAGE_KEY_FIELDS: u32 = BATCH_FIELDS & !BATCH_ORDER; + | BATCH_ORDER + | BATCH_TRANSFORM; +const STORAGE_KEY_FIELDS: u32 = BATCH_FIELDS & !(BATCH_ORDER | BATCH_TRANSFORM); const REQUIRED_STORAGE_KEYS: u32 = BATCH_TECHNIQUE | BATCH_RESOURCE | BATCH_PROGRAM; -const REQUIRED_DRAW_KEYS: u32 = REQUIRED_STORAGE_KEYS | BATCH_ORDER; +const REQUIRED_DRAW_KEYS: u32 = REQUIRED_STORAGE_KEYS | BATCH_ORDER | BATCH_TRANSFORM; pub const BUFFER_USAGE_VERTEX: u32 = 1 << 0; pub const BUFFER_USAGE_STORAGE: u32 = 1 << 1; @@ -1863,7 +1865,11 @@ mod tests { resource_kind_mask: 1, semantic_view_mask: 0, storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, - draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | BATCH_TRANSFORM, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 2, u32_input_count: 0, @@ -2206,7 +2212,11 @@ mod tests { resource_kind_mask: 1, semantic_view_mask: 0, storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, - draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | BATCH_TRANSFORM, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 1, diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index c2242b40..71dd2a7a 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -35,6 +35,7 @@ pub struct LayoutGlyph { #[derive(Clone, Copy)] pub struct LayoutPlanInput<'a> { + pub transform_id: u32, pub glyphs: &'a [LayoutGlyph], pub semantic_change_masks: &'a [u16], pub semantic_f32: &'a [&'a [f32]], @@ -238,6 +239,7 @@ impl PolicyGatherWorkspace { resource_kind: resource.kind, resource_reference: resource.reference, semantic_id: glyph.semantic_id, + transform_id: input.transform_id, material_id: glyph.material_id, clip_id: glyph.clip_id, depth_key: glyph.depth_key, @@ -568,6 +570,7 @@ mod tests { let glyphs = [layout_glyph(1, 0)]; let foreground = [0x8040_20ff]; let input = LayoutPlanInput { + transform_id: 1, glyphs: &glyphs, semantic_change_masks: &[], semantic_f32: &[], @@ -610,6 +613,7 @@ mod tests { &policy, CAPABILITY, LayoutPlanInput { + transform_id: 1, glyphs: &glyphs, semantic_change_masks: &[], semantic_f32: &[&semantic_x], @@ -622,7 +626,7 @@ mod tests { { let gathered = workspace.view(); let input = gathered.plan_input(); - assert_eq!(core::mem::size_of::(), 60); + assert_eq!(core::mem::size_of::(), 64); assert!( input .f32_fields @@ -654,6 +658,9 @@ mod tests { .plan_view(3, CAPABILITY, policy.fingerprint()) .unwrap(); assert_eq!(plan.draws.len(), 1); + assert_eq!(plan.draws[0].transform_id, 1); + assert_eq!(plan.primitives[0].record_count, 2); + assert_eq!(plan.primitives[0].semantic_id, 0); assert_eq!(plan.patches.len(), 2); assert_eq!( &plan.payload[..8], @@ -677,12 +684,14 @@ mod tests { workspace.begin(&policy, 2).unwrap(); for input in [ LayoutPlanInput { + transform_id: 1, glyphs: &first, semantic_change_masks: &[], semantic_f32: &[&first_x], semantic_u32: &[&first_kind], }, LayoutPlanInput { + transform_id: 2, glyphs: &second, semantic_change_masks: &[], semantic_f32: &[&second_x], @@ -698,8 +707,24 @@ mod tests { assert_eq!(input.glyphs.len(), 2); assert_eq!(input.glyphs[0].stable_id, 1); assert_eq!(input.glyphs[1].stable_id, 2); + assert_eq!(input.glyphs[0].transform_id, 1); + assert_eq!(input.glyphs[1].transform_id, 2); assert_eq!(input.f32_fields[0], &[10.0, 20.0]); assert_eq!(input.u32_fields[0], &[100, 200]); + let mut compiler = RenderPlanCompiler::default(); + compiler + .prepare(&policy, CAPABILITY, input, true, 1, 0) + .unwrap(); + let plan = compiler + .plan_view(3, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!( + plan.draws + .iter() + .map(|draw| draw.transform_id) + .collect::>(), + [1, 2] + ); } #[test] @@ -715,6 +740,7 @@ mod tests { &policy, CAPABILITY, LayoutPlanInput { + transform_id: 1, glyphs: &glyphs, semantic_change_masks: &[1, 1], semantic_f32: &[&semantic_x], @@ -746,6 +772,7 @@ mod tests { &policy, CAPABILITY, LayoutPlanInput { + transform_id: 1, glyphs: &glyphs, semantic_change_masks: &[], semantic_f32: &[], @@ -761,6 +788,7 @@ mod tests { &policy, CapabilitySetId(2), LayoutPlanInput { + transform_id: 1, glyphs: &glyphs, semantic_change_masks: &[], semantic_f32: &[], @@ -776,6 +804,7 @@ mod tests { &policy, CAPABILITY, LayoutPlanInput { + transform_id: 1, glyphs: &glyphs, semantic_change_masks: &[], semantic_f32: &[], @@ -805,7 +834,7 @@ mod tests { binding_handle: 9, font_handle: 9, glyph_id, - semantic_id: 1, + semantic_id: stable_id, material_id: 6, clip_id: 0, depth_key: 0, @@ -864,7 +893,11 @@ mod tests { resource_kind_mask: 1 << 1, semantic_view_mask: 0, storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, - draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 4, u32_input_count: 4, diff --git a/packages/text/rust/shaper/src/engine/render_plan.rs b/packages/text/rust/shaper/src/engine/render_plan.rs index ffb3dde9..4f258d0d 100644 --- a/packages/text/rust/shaper/src/engine/render_plan.rs +++ b/packages/text/rust/shaper/src/engine/render_plan.rs @@ -140,6 +140,8 @@ pub struct DrawRecord { pub clip_id: u32, /// Caller-defined sortable depth bucket; logical order remains authoritative within a bucket. pub depth_key: u32, + /// Renderer-owned transform/object identity shared by this packet. + pub transform_id: u32, pub primitive_start: u32, pub primitive_count: u32, pub buffer_start: u32, @@ -197,6 +199,6 @@ const _: () = assert!(core::mem::size_of::() == 40); const _: () = assert!(core::mem::size_of::() == 36); const _: () = assert!(core::mem::size_of::() == 36); const _: () = assert!(core::mem::size_of::() == 64); -const _: () = assert!(core::mem::size_of::() == 60); +const _: () = assert!(core::mem::size_of::() == 64); const _: () = assert!(core::mem::size_of::() == 24); const _: () = assert!(core::mem::size_of::() == 24); diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index 4017dd37..8ec58690 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -880,6 +880,7 @@ mod tests { resource_kind: 1, resource_reference: technique.0 + 90, semantic_id: 1, + transform_id: 1, material_id: 1, clip_id: 0, depth_key: 0, @@ -942,7 +943,11 @@ mod tests { resource_kind_mask: 1, semantic_view_mask: 0, storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, - draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, allocation_strategy, f32_input_count: 1, u32_input_count: 0, diff --git a/packages/text/rust/shaper/src/engine/render_plan_wire.rs b/packages/text/rust/shaper/src/engine/render_plan_wire.rs index 3bccc398..9d160815 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_wire.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_wire.rs @@ -492,6 +492,7 @@ fn write_draw(bytes: &mut [u8], at: usize, value: DrawRecord) { u32_at(bytes, at, DRAW_MATERIAL_ID, value.material_id); u32_at(bytes, at, DRAW_CLIP_ID, value.clip_id); u32_at(bytes, at, DRAW_DEPTH_KEY, value.depth_key); + u32_at(bytes, at, DRAW_TRANSFORM_ID, value.transform_id); u32_at(bytes, at, DRAW_PRIMITIVE_START, value.primitive_start); u32_at(bytes, at, DRAW_PRIMITIVE_COUNT, value.primitive_count); u32_at(bytes, at, DRAW_BUFFER_START, value.buffer_start); diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 30bd7b0a..7416a024 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -1338,7 +1338,14 @@ impl StablePlanCompiler { logical_order: u32::try_from(input_index) .map_err(|_| StablePlanError::ArithmeticOverflow)?, clip_id: first.clip_id, - semantic_id: first.semantic_id, + semantic_id: if context.input.glyphs[input_index..end] + .iter() + .all(|glyph| glyph.semantic_id == first.semantic_id) + { + first.semantic_id + } else { + 0 + }, inline_start, block_start, inline_extent, @@ -1353,6 +1360,7 @@ impl StablePlanCompiler { material_id: if split_material { first.material_id } else { 0 }, clip_id: first.clip_id, depth_key: first.depth_key, + transform_id: first.transform_id, primitive_start: u32::try_from(primitive_start) .map_err(|_| StablePlanError::ArithmeticOverflow)?, primitive_count: 1, @@ -1394,7 +1402,7 @@ impl StablePlanCompiler { && (!split_material || glyph.material_id == first.material_id) && glyph.clip_id == first.clip_id && glyph.depth_key == first.depth_key - && glyph.semantic_id == first.semantic_id + && glyph.transform_id == first.transform_id } fn next_buffer_identity( @@ -2056,6 +2064,7 @@ mod tests { resource_kind: 1, resource_reference: 99, semantic_id: 1, + transform_id: 1, material_id: 1, clip_id: 0, depth_key: 0, @@ -2104,7 +2113,8 @@ mod tests { | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_MATERIAL - | BATCH_ORDER, + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, allocation_strategy: ALLOCATION_STABLE_INDIRECT, f32_input_count: 1, u32_input_count: 0, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index dc3d562d..df64edbb 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -630,6 +630,7 @@ impl TextEngine { policy, CapabilitySetId(request.capability_set), LayoutPlanInput { + transform_id: paragraph_id, glyphs: positioned.glyphs(), semantic_change_masks: positioned.semantic_change_masks(), semantic_f32: &semantic_f32, @@ -2877,7 +2878,11 @@ mod tests { resource_kind_mask: 1, semantic_view_mask: 0, storage_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE, - draw_key_mask: BATCH_TECHNIQUE | BATCH_PROGRAM | BATCH_RESOURCE | BATCH_ORDER, + draw_key_mask: BATCH_TECHNIQUE + | BATCH_PROGRAM + | BATCH_RESOURCE + | BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, diff --git a/packages/text/rust/shaper/src/engine/wire.rs b/packages/text/rust/shaper/src/engine/wire.rs index c22e5cc0..9b1e95d5 100644 --- a/packages/text/rust/shaper/src/engine/wire.rs +++ b/packages/text/rust/shaper/src/engine/wire.rs @@ -600,7 +600,8 @@ mod tests { crate::engine::policy::BATCH_TECHNIQUE | crate::engine::policy::BATCH_PROGRAM | crate::engine::policy::BATCH_RESOURCE - | crate::engine::policy::BATCH_ORDER, + | crate::engine::policy::BATCH_ORDER + | crate::engine::policy::BATCH_TRANSFORM, ); put_u16( program, diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index fe525d25..31765bcf 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -303,23 +303,24 @@ export const textShaperAbi = { }, "engineDraw": { "alignment": 4, - "bufferCount": 36, - "bufferStart": 32, + "bufferCount": 40, + "bufferStart": 36, "clipId": 16, "depthKey": 20, "flags": 10, "id": 0, - "indirectBufferId": 52, - "indirectOffset": 56, + "indirectBufferId": 56, + "indirectOffset": 60, "materialId": 12, - "orderToken": 48, - "primitiveCount": 28, - "primitiveStart": 24, + "orderToken": 52, + "primitiveCount": 32, + "primitiveStart": 28, "programId": 4, "programVariant": 8, - "resourceCount": 44, - "resourceStart": 40, - "size": 60 + "resourceCount": 48, + "resourceStart": 44, + "size": 64, + "transformId": 24 }, "engineExclusion": { "alignment": 4, @@ -810,7 +811,8 @@ export const textShaperAbi = { "order": 64, "program": 4, "resource": 2, - "technique": 1 + "technique": 1, + "transform": 128 }, "bufferUsage": { "copyDst": 4, diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index e4a45294..7f8391df 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -304,7 +304,14 @@ function createProgram( operations: context.operations, storageKeyMask: batch.technique | batch.program | batch.resource, drawKeyMask: - batch.technique | batch.program | batch.resource | batch.material | batch.clip | batch.depth | batch.order, + batch.technique | + batch.program | + batch.resource | + batch.material | + batch.clip | + batch.depth | + batch.order | + batch.transform, }; } diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index f73967ef..dd07dd3b 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -32,6 +32,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.equal(abi.layouts.engineBuffer.size, 36); assert.equal(abi.layouts.enginePatch.size, 36); assert.equal(abi.layouts.enginePrimitive.size, 64); + assert.equal(abi.layouts.engineDraw.size, 64); assert.deepEqual( [ abi.layouts.engineTextMutation.size, diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 3bc89f7b..6434b0d5 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -244,11 +244,16 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.throws(() => plan.record(patches, patches.count), /outside its table/); const drawLayout = textShaperAbi.layouts.engineDraw; const draws = plan.table('draws'); + assert.equal(draws.count, 2, 'cluster identity must not split compatible paragraph draws'); assert.deepEqual( adjacentMaterialGroups(plan, draws, drawLayout.materialId), [7, 8], 'Rust gathers child paragraphs into one ordered command buffer', ); + assert.deepEqual( + Array.from({ length: draws.count }, (_, index) => plan.u32(plan.record(draws, index) + drawLayout.transformId)), + [1, 2], + ); const reorderedPublication = session.update( compileTextEngineFrameUpdate({ @@ -276,11 +281,18 @@ test('Three coordinator shares shaping data across technique bindings and refere ); const reorderedPlan = plan.bind(reorderedPublication); const reorderedDraws = reorderedPlan.table('draws'); + assert.equal(reorderedDraws.count, 2); assert.deepEqual( adjacentMaterialGroups(reorderedPlan, reorderedDraws, drawLayout.materialId), [8, 7], 'lifecycle-only reorder retains both paragraphs and changes shared draw order', ); + assert.deepEqual( + Array.from({ length: reorderedDraws.count }, (_, index) => + reorderedPlan.u32(reorderedPlan.record(reorderedDraws, index) + drawLayout.transformId), + ), + [2, 1], + ); session.dispose(); first.release(); first.release(); diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 21434f4e..36e20956 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -448,7 +448,8 @@ export function renderPolicyBytesFromPrograms(abi, programs) { abi.policy.batchFields.technique | abi.policy.batchFields.program | abi.policy.batchFields.resource | - abi.policy.batchFields.order, + abi.policy.batchFields.order | + abi.policy.batchFields.transform, true, ); view.setUint16(offset + programLayout.variant, descriptor.variant ?? 0, true); From 43978ab82615e41f4abe52f9fd971396561b806c Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 23:10:52 -0400 Subject: [PATCH 066/128] feat(text): resolve Three plan resources --- docs/log.md | 6 +++ docs/packages/text.md | 8 +++- docs/planning/decision-register.md | 2 + packages/text/src/three/engine-runtime.ts | 46 +++++++++++++++++++ .../integration/three-engine-runtime.test.mjs | 4 ++ 5 files changed, 65 insertions(+), 1 deletion(-) diff --git a/docs/log.md b/docs/log.md index ae78228d..6c138275 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-08 +- **Resolved Rust resource references directly in Three** — The Three coordinator now registers each validated Bitmap + page, MTSDF atlas, and Slug analytic page under the same collision-checked numeric identity compiled into the Rust + font binding. A command-buffer `referenceId` resolves in one map lookup; Three does not scan fonts or repeat resource + partitioning. Incompatible technique reuse is rejected. Focused type-check, build, and compiled-Wasm coordinator tests + pass; physical buffer, patch, and draw realization remain open. + - **Made transform ownership explicit and removed per-cluster draws** — The first real multi-paragraph publication exposed that paragraph-local positions had no renderer transform owner and cluster `semantic_id` prevented primitive coalescing. The policy now requires a paragraph-derived transform draw key, forbids transform from physical storage diff --git a/docs/packages/text.md b/docs/packages/text.md index bceb3f32..8c66dfd6 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:ee4e160e44f8228c16af088e2d1a9cce2a2984ab5c90fc37731f3045ade082d5' +source_digest: 'sha256:22e86ee00399f97762ddb73b63577863719a7be53335bb4a5e5dfcd7c18a45ca' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -869,6 +869,12 @@ describes resources, physical buffers, dirty patches, primitives, draws, and ret 1,082,551 raw / 407,787 gzip / 324,499 Brotli bytes at this checkpoint; all 128 Rust unit tests and the focused compiled- Wasm fixture pass, with no end-to-end renderer latency claim yet. +The Three coordinator retains a reverse first-party resource registry keyed by the exact numeric `referenceId` emitted +in Rust resource records. Validated Bitmap pages, MTSDF atlas data, and Slug analytic pages enter it once when their font +binding is first registered. Command-buffer execution can therefore resolve the authenticated renderer resource with +one map lookup rather than searching fonts or re-partitioning glyphs. The registry rejects one numeric identity crossing +techniques. Buffer patches and draws are not yet realized by this checkpoint. + The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 0735b1a8..5643dda7 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -288,6 +288,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-214 | Renderer transform ownership is an explicit required draw key. Each gathered paragraph supplies its stable paragraph ID as `transform_id`; transform is forbidden from physical-storage keys, so compatible paragraphs may share buffers, but it is required in draw keys so no packet crosses renderer object transforms. The fixed draw record grows from 60 to 64 bytes and publishes `transformId`. Cluster `semantic_id` no longer splits otherwise compatible glyph spans: a single-semantic primitive retains that ID, while a span crossing semantics publishes zero until optional semantic tables describe finer inspection. The compiled-Wasm two-paragraph fixture changes from six cluster-split draws to exactly two `(materialId, transformId)` packets and preserves exact reversed ownership after lifecycle-only reorder. This is a correctness prerequisite for Three realization and removes pathological per-cluster draws rather than inferring ownership from draw order. Optimized Wasm changes from 1,082,551 / 407,787 / 324,499 to 1,082,773 / 407,870 / 324,551 raw/gzip/Brotli bytes. | Accepted | +| D-215 | The Three coordinator retains a runtime-scoped reverse resource registry keyed by the exact `reference_id` that Rust publishes. Registration walks each already-validated first-party font binding once: Bitmap records exact pages, MTSDF records its atlas data, and Slug records exact analytic pages under the same collision-checked wire identity used by policy/font-binding compilation. Command-buffer execution therefore resolves a resource in one map lookup and never searches loaded fonts, repeats glyph/resource partitioning, or decodes a renderer object in Rust. Equal resource identity reuses the first retained realization source; the same identity under incompatible techniques is rejected. Focused TypeScript and compiled-Wasm coordinator tests pass. Buffer/patch/draw realization remains the next slice. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts index 3d31d032..a4dde31d 100644 --- a/packages/text/src/three/engine-runtime.ts +++ b/packages/text/src/three/engine-runtime.ts @@ -1,4 +1,7 @@ import type { LoadedFont } from '../loaded-font.js'; +import { bitmap, type BitmapData, type BitmapPageData } from '../raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../raster/msdf.js'; +import { slug, type SlugData, type SlugPageData } from '../raster/slug-technique.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; import type { TextRuntime } from '../text-runtime.js'; import { firstPartyFontBindingBytes } from '../internal/font-binding-wire.js'; @@ -19,10 +22,16 @@ interface RetainedStack { references: number; } +export type ThreeTextEngineResource = + | Readonly<{ technique: typeof bitmap.id; page: BitmapPageData }> + | Readonly<{ technique: typeof msdf.id; data: MsdfData }> + | Readonly<{ technique: typeof slug.id; page: SlugPageData }>; + /** Three-owned cold registrations shared by every text batch using one renderer-neutral runtime. */ export class ThreeTextEngineCoordinator { readonly host: TextEngineHost; readonly #bindingHandles = new WeakMap, number>(); + readonly #resources = new Map(); readonly #stacks = new Map(); #nextBindingHandle = 1; #nextStackHandle = 1; @@ -70,6 +79,12 @@ export class ThreeTextEngineCoordinator { return this.host.createSession({ ...options, handle: this.#allocateSessionHandle() }); } + resolveResource(referenceId: number): ThreeTextEngineResource { + const resource = this.#resources.get(referenceId); + if (resource === undefined) throw new Error(`Three text command buffer references unknown resource ${referenceId}`); + return resource; + } + dispose(): void { if (this.#disposed) return; this.host.dispose(); @@ -82,11 +97,42 @@ export class ThreeTextEngineCoordinator { const existing = this.#bindingHandles.get(font); if (existing !== undefined) return existing; const handle = this.#allocateBindingHandle(); + this.#registerResources(font); this.host.registerFontBinding(handle, font.font.handle, firstPartyFontBindingBytes(font, this.host.wireIdentities)); this.#bindingHandles.set(font, handle); return handle; } + #registerResources(font: LoadedFont): void { + if (font.technique.id === bitmap.id) { + const data = font.data as BitmapData; + for (const strike of data.strikes) { + for (const page of strike.pages) this.#retainResource(page.resource, { technique: bitmap.id, page }); + } + return; + } + if (font.technique.id === msdf.id) { + const data = font.data as MsdfData; + this.#retainResource(data.resource, { technique: msdf.id, data }); + return; + } + if (font.technique.id === slug.id) { + const data = font.data as SlugData; + for (const page of data.pages) this.#retainResource(page.resource, { technique: slug.id, page }); + return; + } + throw new TypeError(`no first-party Three resource resolver is registered for "${font.technique.id}"`); + } + + #retainResource(key: string, resource: ThreeTextEngineResource): void { + const referenceId = this.host.wireIdentities.resolve(key); + const existing = this.#resources.get(referenceId); + if (existing !== undefined && existing.technique !== resource.technique) { + throw new TypeError(`Three text resource ${referenceId} is registered for incompatible techniques`); + } + if (existing === undefined) this.#resources.set(referenceId, resource); + } + #allocateBindingHandle(): number { return allocateHandle(this.#nextBindingHandle, (next) => (this.#nextBindingHandle = next), 'font binding'); } diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 6434b0d5..527c83f5 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -87,6 +87,10 @@ test('Three coordinator shares shaping data across technique bindings and refere }; const coordinator = new ThreeTextEngineCoordinator({ shaper }); const first = coordinator.acquireFontStack([bitmapFont, msdfFont]); + const bitmapReference = coordinator.host.wireIdentities.resolve(bitmapFont.data.strikes[0].pages[0].resource); + const msdfReference = coordinator.host.wireIdentities.resolve(msdfFont.data.resource); + assert.equal(coordinator.resolveResource(bitmapReference).technique, bitmap.id); + assert.equal(coordinator.resolveResource(msdfReference).technique, msdf.id); const shared = coordinator.acquireFontStack([bitmapFont, msdfFont]); const reversed = coordinator.acquireFontStack([msdfFont, bitmapFont]); assert.equal(shared.handle, first.handle); From b54649815fe05fdb5924297b6115135ede8a44f6 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 23:52:58 -0400 Subject: [PATCH 067/128] feat(text): index renderer transforms in plans --- docs/log.md | 9 + docs/packages/text.md | 14 +- docs/planning/decision-register.md | 4 +- docs/planning/rust-layout-engine.md | 5 +- docs/planning/three-material-authority.md | 7 +- packages/text/rust/shaper/src/abi_contract.rs | 11 +- .../shaper/src/engine/flow_composition.rs | 15 +- .../rust/shaper/src/engine/flow_geometry.rs | 1 + packages/text/rust/shaper/src/engine/frame.rs | 1 + .../rust/shaper/src/engine/ordered_plan.rs | 41 +- .../text/rust/shaper/src/engine/policy.rs | 4 +- .../rust/shaper/src/engine/positioning.rs | 10 +- .../rust/shaper/src/engine/semantic_wire.rs | 9 + .../rust/shaper/src/engine/stable_plan.rs | 9 +- .../text/src/generated/text-shaper-abi.ts | 40 +- .../text/src/internal/engine-frame-wire.ts | 7 + .../text/src/internal/render-policy-wire.ts | 49 +- packages/text/src/three/engine-plan-target.ts | 512 ++++++++++++++++++ .../render-plan-frame-abi.test.mjs | 4 +- .../integration/shaper-registration.test.mjs | 1 + .../integration/three-engine-runtime.test.mjs | 129 ++++- packages/text/tests/support/engine-abi.mjs | 1 + 22 files changed, 825 insertions(+), 58 deletions(-) create mode 100644 packages/text/src/three/engine-plan-target.ts diff --git a/docs/log.md b/docs/log.md index 6c138275..6a6d284e 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-08 +- **Made transform batching policy-selectable and executed indexed Bitmap draws** — Corrected the temporary global + transform draw boundary. Programs may now split on transform and consume nonzero draw-level IDs, or omit that key and + pack stable region transform slots into first-party policy buffer 15. The Bitmap Three executor applies Rust buffer + patches, binds direct resources, keeps matrices in a renderer-owned sidecar, and updates scene transforms without a + Wasm call. Visible overflow no longer invents a clip boundary. Rust tests prove split and indexed modes; compiled Wasm + retains two material draws and collapses the same two paragraphs to one six-instance draw after their material IDs + converge, with exact slots `[2,2,2,1,1,1]`. All 129 Rust tests and the focused Three integration pass. Optimized Wasm + is 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes. Browser pixels, MSDF/Slug, and public Three cutover remain open. + - **Resolved Rust resource references directly in Three** — The Three coordinator now registers each validated Bitmap page, MTSDF atlas, and Slug analytic page under the same collision-checked numeric identity compiled into the Rust font binding. A command-buffer `referenceId` resolves in one map lookup; Three does not scan fonts or repeat resource diff --git a/docs/packages/text.md b/docs/packages/text.md index 8c66dfd6..2f50076b 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:22e86ee00399f97762ddb73b63577863719a7be53335bb4a5e5dfcd7c18a45ca' +source_digest: 'sha256:25a3f6a830826d54b422a22a446a429b8ab4537e2d962410467e9cc60a8a8539' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -875,6 +875,18 @@ binding is first registered. Command-buffer execution can therefore resolve the one map lookup rather than searching fonts or re-partitioning glyphs. The registry rejects one numeric identity crossing techniques. Buffer patches and draws are not yet realized by this checkpoint. +The first Three command-buffer executor realizes Bitmap buffer declarations, minimal write/fill/copy patches, resource +bindings, glyph primitives, ordered draws, and buffer retirements from the Rust publication. Transform handling is a +program policy rather than a global split: a transform-keyed program receives a nonzero draw `transformId`, while the +first-party indexed programs pack one `u32 transformIndex` per instance in policy buffer 15 and publish draw-level zero. +Three owns the compact matrix sidecar and may update its dirty matrix ranges without calling Wasm or invalidating +layout. Flow regions carry the stable sidecar slot independently from local geometry; visible overflow carries no clip +identity. A compiled-Wasm fixture keeps distinct materials as two draws, then changes both retained paragraphs to one +material and observes one six-instance draw over transform slots `[2,2,2,1,1,1]`. The executor is still Bitmap-only and +not connected to the public `Text` lifecycle, so MSDF/Slug realization, browser pixels, public cutover, and end-to-end +latency remain open. The optimized shaper is 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes at this checkpoint; all +129 Rust tests and the focused compiled-Wasm/Three integration test pass. + The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 5643dda7..2ff3ea02 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -286,10 +286,12 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-213 | One engine session retains multiple stable-ID paragraph states and emits one atomic, revisioned command-buffer delta for the renderer. Paragraph lifecycle records create, reorder, or remove children; absent semantic sections retain a child rather than clearing it, while present text/style/geometry spans remain forward-only borrowed views. Final paragraph order drives one allocation-free gather into the shared Rust policy/planner. Geometry retention compacts only each child's referenced regions, exclusions, and vertices, so a sibling's global table prefix cannot manufacture layout invalidation. A later-child failure aborts every child and lifecycle change; order collisions, unknown semantic owners, and paragraph limits fail before publication. A compiled-Wasm Three-coordinator proof creates two paragraphs with material groups `[7,8]`, sends a lifecycle-only reorder without resending semantic data, and observes `[8,7]` in the next Rust publication. The render plan is therefore a retained renderer-neutral command buffer, not a TypeScript batch or a native `GPUCommandBuffer`. All 128 Rust unit tests pass. Optimized Wasm changes from 1,073,248 / 404,463 / 321,189 to 1,082,551 / 407,787 / 324,499 raw/gzip/Brotli bytes. Public Three GPU realization remains open, so this checkpoint carries no end-to-end draw or latency claim. | Accepted | -| D-214 | Renderer transform ownership is an explicit required draw key. Each gathered paragraph supplies its stable paragraph ID as `transform_id`; transform is forbidden from physical-storage keys, so compatible paragraphs may share buffers, but it is required in draw keys so no packet crosses renderer object transforms. The fixed draw record grows from 60 to 64 bytes and publishes `transformId`. Cluster `semantic_id` no longer splits otherwise compatible glyph spans: a single-semantic primitive retains that ID, while a span crossing semantics publishes zero until optional semantic tables describe finer inspection. The compiled-Wasm two-paragraph fixture changes from six cluster-split draws to exactly two `(materialId, transformId)` packets and preserves exact reversed ownership after lifecycle-only reorder. This is a correctness prerequisite for Three realization and removes pathological per-cluster draws rather than inferring ownership from draw order. Optimized Wasm changes from 1,082,551 / 407,787 / 324,499 to 1,082,773 / 407,870 / 324,551 raw/gzip/Brotli bytes. | Accepted | +| D-214 | Renderer transform ownership is an explicit required draw key. Each gathered paragraph supplies its stable paragraph ID as `transform_id`; transform is forbidden from physical-storage keys, so compatible paragraphs may share buffers, but it is required in draw keys so no packet crosses renderer object transforms. The fixed draw record grows from 60 to 64 bytes and publishes `transformId`. Cluster `semantic_id` no longer splits otherwise compatible glyph spans: a single-semantic primitive retains that ID, while a span crossing semantics publishes zero until optional semantic tables describe finer inspection. The compiled-Wasm two-paragraph fixture changes from six cluster-split draws to exactly two `(materialId, transformId)` packets and preserves exact reversed ownership after lifecycle-only reorder. This is a correctness prerequisite for Three realization and removes pathological per-cluster draws rather than inferring ownership from draw order. Optimized Wasm changes from 1,082,551 / 407,787 / 324,499 to 1,082,773 / 407,870 / 324,551 raw/gzip/Brotli bytes. | Superseded by D-216 | | D-215 | The Three coordinator retains a runtime-scoped reverse resource registry keyed by the exact `reference_id` that Rust publishes. Registration walks each already-validated first-party font binding once: Bitmap records exact pages, MTSDF records its atlas data, and Slug records exact analytic pages under the same collision-checked wire identity used by policy/font-binding compilation. Command-buffer execution therefore resolves a resource in one map lookup and never searches loaded fonts, repeats glyph/resource partitioning, or decodes a renderer object in Rust. Equal resource identity reuses the first retained realization source; the same identity under incompatible techniques is rejected. Focused TypeScript and compiled-Wasm coordinator tests pass. Buffer/patch/draw realization remains the next slice. | Accepted | +| D-216 | Transform realization is selected per policy program rather than imposed globally. A program including transform in its draw key receives transform-split packets with nonzero draw-level `transformId`; a program omitting it packs the region's stable compact `transformIndex` into policy buffer 15 and receives draw-level zero, allowing compatible glyphs to index a renderer-owned matrix sidecar in one draw. The two modes may coexist across programs and capability sets. Region transform slots are supplied with flow geometry but do not participate in layout geometry or change when an `Object3D` matrix changes. Visible overflow carries clip zero; only an actual clip/ellipsis region forms a clip boundary. Rust tests prove split `[1,2]` and indexed `[0]` outputs from the same two-transform input. The compiled-Wasm Bitmap executor copies Rust patches into retained Three storage, resolves resources directly, uploads changed transform matrices without a Wasm call, retains material splits, and collapses two same-material paragraphs into one six-instance draw with transform slots `[2,2,2,1,1,1]`. All 129 Rust tests and the focused compiled-Wasm/Three test pass. Optimized Wasm is 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes; no browser pixel or end-to-end latency claim is attached yet. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index a324602d..4e59aa18 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -479,8 +479,9 @@ rendering/batching owner; flow is never inferred from group child order. - changing a region object's world transform updates rendering only; - changing local region shape, writing mode, or exclusion geometry sends one flow-geometry mutation to Rust; -- render plans carry a region index/ID, and the Three/TSL policy reads a small `vec4`-record region-transform buffer on - both WebGPU and the WebGL PBO fallback; and +- render plans carry a region's stable compact transform slot; an indexed Three/TSL program packs that slot per + instance and reads a small `vec4`-record region-transform buffer on both WebGPU and the WebGL PBO fallback, while a + policy program that cannot use indirection may instead include transform in its draw key; and - disposing or reordering a region changes the flow-thread revision without changing text or broad shaping state. The canonical geometry inputs are rectangles and bounded simple polygons. Public helpers construct rectangles and diff --git a/docs/planning/three-material-authority.md b/docs/planning/three-material-authority.md index 404bd6bd..7de412f9 100644 --- a/docs/planning/three-material-authority.md +++ b/docs/planning/three-material-authority.md @@ -102,9 +102,10 @@ glyph coverage algorithm unless the application registers a complete custom rast The Three integration interns each live `ThreeTextMaterial` by object identity and assigns a nonzero `u32 material_id`; zero means the built-in default material. The frame request carries the resolved ID on Rust-owned material segments. Rust resolves the ordinary batch → text → span cascade, maps clusters to one material ID, and preserves that ID through -glyph primitives into draw packets. Each packet also carries the paragraph-derived `transformId`; transform is a -required draw boundary but is excluded from first-party physical-storage identity, so distinct `Text` objects may share -buffers without a draw spanning two scene-object transforms. +glyph primitives into draw packets. Transform ownership is separately policy-selectable. A program may include +transform in its draw key and receive a nonzero draw-level `transformId`, or omit that key and request the first-party +`u32 transformIndex` stream. The latter indexes a renderer-owned region matrix table and permits one compatible draw +across distinct `TextRegion`/`Text` transforms. Transform never enters first-party physical-storage identity. The registered policy has independent storage and draw key masks. Every first-party policy includes material in its draw key. A capability-specific policy may omit it from the storage key, producing different material draws over ranges in diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 507ca203..01050681 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -17,6 +17,7 @@ use crate::engine::frame::{ SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, + SEMANTIC_U32_TRANSFORM_INDEX, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, @@ -334,6 +335,7 @@ struct EngineFlowVertexRecord { struct EngineRegionRecord { id: u32, geometry_revision: u32, + transform_index: u32, vertices_offset: u32, vertex_count: u16, exclusion_start: u16, @@ -1452,6 +1454,11 @@ field_offset!( EngineRegionRecord, geometry_revision ); +field_offset!( + ENGINE_REGION_TRANSFORM_INDEX, + EngineRegionRecord, + transform_index +); field_offset!( ENGINE_REGION_VERTICES_OFFSET, EngineRegionRecord, @@ -2267,6 +2274,7 @@ pub fn json() -> String { "alignment": ENGINE_REGION_RECORD_ALIGNMENT, "id": ENGINE_REGION_ID, "geometryRevision": ENGINE_REGION_GEOMETRY_REVISION, + "transformIndex": ENGINE_REGION_TRANSFORM_INDEX, "verticesOffset": ENGINE_REGION_VERTICES_OFFSET, "vertexCount": ENGINE_REGION_VERTEX_COUNT, "exclusionStart": ENGINE_REGION_EXCLUSION_START, @@ -2638,7 +2646,8 @@ pub fn json() -> String { "foregroundRgba": SEMANTIC_U32_FOREGROUND_RGBA, "clusterId": SEMANTIC_U32_CLUSTER_ID, "regionId": SEMANTIC_U32_REGION_ID, - "flowThreadId": SEMANTIC_U32_FLOW_THREAD_ID + "flowThreadId": SEMANTIC_U32_FLOW_THREAD_ID, + "transformIndex": SEMANTIC_U32_TRANSFORM_INDEX }, "paragraphMutationOpcodes": { "upsert": PARAGRAPH_MUTATION_UPSERT, diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index ac5083cc..b8210a2b 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -6,7 +6,7 @@ use super::{ EngineError, cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, flow_geometry::{FlowGeometryArena, InlineSlotArena}, - frame::WRITING_HORIZONTAL_TB, + frame::{OVERFLOW_VISIBLE, WRITING_HORIZONTAL_TB}, line_composition::{ComposedLine, LineCursor, layout_next_line}, style_state::StyleSegment, }; @@ -15,6 +15,8 @@ use super::{ pub(crate) struct FlowLine { pub flow_thread_id: u32, pub region_id: u32, + pub transform_index: u32, + pub clip_id: u32, pub fragment_start: u32, pub fragment_count: u16, pub align: u8, @@ -140,6 +142,12 @@ impl FlowLayoutArena { region_index, constraint.flow_thread_id, region.record.id, + region.record.transform_index, + if constraint.overflow == OVERFLOW_VISIBLE { + 0 + } else { + region.record.id + }, clusters, styles, slots, @@ -169,6 +177,8 @@ impl FlowLayoutArena { region_index: usize, flow_thread_id: u32, region_id: u32, + transform_index: u32, + clip_id: u32, clusters: &ClusterArena, styles: &[StyleSegment], slot_arena: &mut InlineSlotArena, @@ -245,6 +255,8 @@ impl FlowLayoutArena { self.lines.push(FlowLine { flow_thread_id, region_id, + transform_index, + clip_id, fragment_start: u32::try_from(fragment_start) .map_err(|_| EngineError::ResultTooLarge)?, fragment_count: u16::try_from(fragment_count) @@ -598,6 +610,7 @@ mod tests { FlowRegion { id: 7, geometry_revision: 1, + transform_index: 7, vertices_offset: 0, vertex_count: 0, exclusion_start: 0, diff --git a/packages/text/rust/shaper/src/engine/flow_geometry.rs b/packages/text/rust/shaper/src/engine/flow_geometry.rs index 8bdcc3ee..1b7dfdfb 100644 --- a/packages/text/rust/shaper/src/engine/flow_geometry.rs +++ b/packages/text/rust/shaper/src/engine/flow_geometry.rs @@ -604,6 +604,7 @@ mod tests { FlowRegion { id: 1, geometry_revision: 1, + transform_index: 1, vertices_offset: 0, vertex_count, exclusion_start: 0, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index a0c1c92a..8bd9cb83 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -78,6 +78,7 @@ pub(crate) const SEMANTIC_U32_FOREGROUND_RGBA: u8 = 0; pub(crate) const SEMANTIC_U32_CLUSTER_ID: u8 = 1; pub(crate) const SEMANTIC_U32_REGION_ID: u8 = 2; pub(crate) const SEMANTIC_U32_FLOW_THREAD_ID: u8 = 3; +pub(crate) const SEMANTIC_U32_TRANSFORM_INDEX: u8 = 4; pub(crate) const PARAGRAPH_MUTATION_UPSERT: u8 = 1; pub(crate) const PARAGRAPH_MUTATION_REMOVE: u8 = 2; diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index 598ec61f..30a247b7 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -15,7 +15,7 @@ use super::{ record_alignment, take_allocation, }, policy::{ - ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BufferSchema, CapabilitySetId, + ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BufferSchema, CapabilitySetId, PolicyExecutionError, TechniqueId, ValidatedPolicy, }, render_plan::{ @@ -968,6 +968,7 @@ impl OrderedPlanCompiler { ) .ok_or(OrderedPlanError::ProgramMissing)?; let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; let mut end = input_index + 1; while end < context.input.glyphs.len() && end - input_index < usize::from(u16::MAX) @@ -978,6 +979,7 @@ impl OrderedPlanCompiler { batch_index, first_slot, split_material, + split_transform, ) { end += 1; @@ -1033,7 +1035,7 @@ impl OrderedPlanCompiler { material_id: if split_material { first.material_id } else { 0 }, clip_id: first.clip_id, depth_key: first.depth_key, - transform_id: first.transform_id, + transform_id: if split_transform { first.transform_id } else { 0 }, primitive_start: u32::try_from(primitive_start) .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, primitive_count: 1, @@ -1064,6 +1066,7 @@ impl OrderedPlanCompiler { batch_index: usize, first_slot: u32, split_material: bool, + split_transform: bool, ) -> bool { let first = glyphs[start]; let glyph = glyphs[next]; @@ -1076,7 +1079,7 @@ impl OrderedPlanCompiler { && (!split_material || glyph.material_id == first.material_id) && glyph.clip_id == first.clip_id && glyph.depth_key == first.depth_key - && glyph.transform_id == first.transform_id + && (!split_transform || glyph.transform_id == first.transform_id) } fn prepare_removed_batches( @@ -1461,6 +1464,28 @@ mod tests { assert!(plan_layout(plan).is_ok()); } + #[test] + fn policy_selects_draw_split_or_instance_transform_indirection() { + let mut glyphs = [glyph(1, 1), glyph(2, 1)]; + glyphs[1].transform_id = 2; + for (split_transform, expected) in [(true, &[1, 2][..]), (false, &[0][..])] { + let policy = policy_with_options(false, 1024, split_transform); + let mut compiler = OrderedPlanCompiler::default(); + prepare(&mut compiler, &policy, &glyphs, &[1.0, 2.0], true); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!( + plan.draws + .iter() + .map(|draw| draw.transform_id) + .collect::>(), + expected + ); + assert_eq!(plan.primitives.iter().map(|item| item.record_count).sum::(), 2); + } + } + #[test] fn policy_can_partition_physical_storage_by_material() { let policy = policy_with_material_storage(true); @@ -1590,6 +1615,14 @@ mod tests { } fn policy_with_limits(partition_materials: bool, max_buffer_bytes: u32) -> ValidatedPolicy { + policy_with_options(partition_materials, max_buffer_bytes, true) + } + + fn policy_with_options( + partition_materials: bool, + max_buffer_bytes: u32, + split_transform: bool, + ) -> ValidatedPolicy { ValidatedPolicy::new(PolicyDescriptor { capability_sets: vec![CapabilitySet { id: CAPABILITY, @@ -1624,7 +1657,7 @@ mod tests { | BATCH_RESOURCE | BATCH_MATERIAL | BATCH_ORDER - | crate::engine::policy::BATCH_TRANSFORM, + | if split_transform { BATCH_TRANSFORM } else { 0 }, allocation_strategy: ALLOCATION_ORDERED_DIRECT, f32_input_count: 1, u32_input_count: 0, diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index bfe42758..619a9537 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -46,7 +46,7 @@ const BATCH_FIELDS: u32 = BATCH_TECHNIQUE | BATCH_TRANSFORM; const STORAGE_KEY_FIELDS: u32 = BATCH_FIELDS & !(BATCH_ORDER | BATCH_TRANSFORM); const REQUIRED_STORAGE_KEYS: u32 = BATCH_TECHNIQUE | BATCH_RESOURCE | BATCH_PROGRAM; -const REQUIRED_DRAW_KEYS: u32 = REQUIRED_STORAGE_KEYS | BATCH_ORDER | BATCH_TRANSFORM; +const REQUIRED_DRAW_KEYS: u32 = REQUIRED_STORAGE_KEYS | BATCH_ORDER; pub const BUFFER_USAGE_VERTEX: u32 = 1 << 0; pub const BUFFER_USAGE_STORAGE: u32 = 1 << 1; @@ -955,7 +955,7 @@ fn f32_input_dependency(source: InputSource) -> u16 { } fn u32_input_dependency(source: InputSource) -> u16 { - if source.scope == InputScope::Semantic && source.field < 4 { + if source.scope == InputScope::Semantic && source.field < 5 { 1 << (6 + source.field) } else { 0 diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index 1af6d68f..4346485c 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -16,7 +16,7 @@ use super::{ }; pub(crate) const SEMANTIC_F32_FIELD_COUNT: usize = 6; -pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 4; +pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 5; pub(crate) const ALL_SEMANTIC_CHANGES: u16 = (1 << (SEMANTIC_F32_FIELD_COUNT + SEMANTIC_U32_FIELD_COUNT)) - 1; @@ -302,7 +302,7 @@ impl PositionedGlyphArena { glyph_id, semantic_id: clusters.stable_ids[cluster], material_id: style.material_id, - clip_id: line.region_id, + clip_id: line.clip_id, depth_key: 0, font_size: style.font_size, raster_pixel_ratio: style.raster_pixel_ratio, @@ -315,6 +315,7 @@ impl PositionedGlyphArena { clusters.stable_ids[cluster], line.region_id, line.flow_thread_id, + line.transform_index, ); } cursor += x_advance; @@ -334,6 +335,7 @@ impl PositionedGlyphArena { cluster: u32, region: u32, flow_thread: u32, + transform_index: u32, ) { self.glyphs.push(glyph); let f32_values = [ @@ -347,7 +349,7 @@ impl PositionedGlyphArena { for (field, value) in self.semantic_f32.iter_mut().zip(f32_values) { field.push(value); } - let u32_values = [foreground, cluster, region, flow_thread]; + let u32_values = [foreground, cluster, region, flow_thread, transform_index]; for (field, value) in self.semantic_u32.iter_mut().zip(u32_values) { field.push(value); } @@ -745,6 +747,8 @@ mod tests { lines: vec![FlowLine { flow_thread_id: 7, region_id: 9, + transform_index: 9, + clip_id: 9, fragment_start: 0, fragment_count: 1, align: ALIGN_CENTER, diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index 522f63da..feef0fbe 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -134,6 +134,7 @@ pub(crate) struct FlowConstraint { pub(crate) struct FlowRegion { pub id: u32, pub geometry_revision: u32, + pub transform_index: u32, pub vertices_offset: u32, pub vertex_count: u16, pub exclusion_start: u16, @@ -303,6 +304,7 @@ impl GeometryBatch<'_> { Some(FlowRegion { id: read_u32(record, abi::ENGINE_REGION_ID).ok()?, geometry_revision: read_u32(record, abi::ENGINE_REGION_GEOMETRY_REVISION).ok()?, + transform_index: read_u32(record, abi::ENGINE_REGION_TRANSFORM_INDEX).ok()?, vertices_offset: read_u32(record, abi::ENGINE_REGION_VERTICES_OFFSET).ok()?, vertex_count: read_u16(record, abi::ENGINE_REGION_VERTEX_COUNT).ok()?, exclusion_start: read_u16(record, abi::ENGINE_REGION_EXCLUSION_START).ok()?, @@ -1433,7 +1435,9 @@ fn validate_regions( .enumerate() { let id = read_u32(record, abi::ENGINE_REGION_ID)?; + let transform_index = read_u32(record, abi::ENGINE_REGION_TRANSFORM_INDEX)?; if id == 0 + || transform_index == 0 || prior_u32_duplicate( regions, abi::ENGINE_REGION_RECORD_SIZE, @@ -2415,6 +2419,11 @@ mod tests { REGION_OFFSET + abi::ENGINE_REGION_GEOMETRY_REVISION, 1, ); + write_u32( + &mut bytes, + REGION_OFFSET + abi::ENGINE_REGION_TRANSFORM_INDEX, + 1, + ); write_u16( &mut bytes, REGION_OFFSET + abi::ENGINE_REGION_EXCLUSION_COUNT, diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 7416a024..1acdb9ba 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -16,7 +16,7 @@ use super::{ record_alignment, take_allocation, }, policy::{ - ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, + ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CapabilitySetId, PolicyExecutionError, ScalarType, TechniqueId, ValidatedPolicy, }, @@ -1295,6 +1295,7 @@ impl StablePlanCompiler { ) .ok_or(StablePlanError::ProgramMissing)?; let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; let first_record = self.input_order_records[input_index]; let mut end = input_index + 1; while end < context.input.glyphs.len() @@ -1306,6 +1307,7 @@ impl StablePlanCompiler { pending_index, first_record, split_material, + split_transform, ) { end += 1; @@ -1360,7 +1362,7 @@ impl StablePlanCompiler { material_id: if split_material { first.material_id } else { 0 }, clip_id: first.clip_id, depth_key: first.depth_key, - transform_id: first.transform_id, + transform_id: if split_transform { first.transform_id } else { 0 }, primitive_start: u32::try_from(primitive_start) .map_err(|_| StablePlanError::ArithmeticOverflow)?, primitive_count: 1, @@ -1390,6 +1392,7 @@ impl StablePlanCompiler { pending_index: usize, first_record: u32, split_material: bool, + split_transform: bool, ) -> bool { let first = glyphs[start]; let glyph = glyphs[next]; @@ -1402,7 +1405,7 @@ impl StablePlanCompiler { && (!split_material || glyph.material_id == first.material_id) && glyph.clip_id == first.clip_id && glyph.depth_key == first.depth_key - && glyph.transform_id == first.transform_id + && (!split_transform || glyph.transform_id == first.transform_id) } fn next_buffer_identity( diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 31765bcf..76d080bc 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -152,7 +152,8 @@ export const textShaperAbi = { "clusterId": 1, "flowThreadId": 3, "foregroundRgba": 0, - "regionId": 2 + "regionId": 2, + "transformIndex": 4 }, "styleFields": { "all": 8191, @@ -415,26 +416,27 @@ export const textShaperAbi = { }, "engineRegion": { "alignment": 4, - "blockEnd": 36, - "blockStart": 28, - "clipBlockEnd": 52, - "clipBlockStart": 44, - "clipInlineEnd": 48, - "clipInlineStart": 40, - "exclusionCount": 16, - "exclusionStart": 14, - "flags": 18, + "blockEnd": 40, + "blockStart": 32, + "clipBlockEnd": 56, + "clipBlockStart": 48, + "clipInlineEnd": 52, + "clipInlineStart": 44, + "exclusionCount": 20, + "exclusionStart": 18, + "flags": 22, "geometryRevision": 4, "id": 0, - "inlineEnd": 32, - "inlineStart": 24, - "reserved0": 23, - "shape": 20, - "size": 56, - "textOrientation": 22, - "vertexCount": 12, - "verticesOffset": 8, - "writingMode": 21 + "inlineEnd": 36, + "inlineStart": 28, + "reserved0": 27, + "shape": 24, + "size": 60, + "textOrientation": 26, + "transformIndex": 8, + "vertexCount": 16, + "verticesOffset": 12, + "writingMode": 25 }, "engineResource": { "action": 14, diff --git a/packages/text/src/internal/engine-frame-wire.ts b/packages/text/src/internal/engine-frame-wire.ts index eff3d094..7f322620 100644 --- a/packages/text/src/internal/engine-frame-wire.ts +++ b/packages/text/src/internal/engine-frame-wire.ts @@ -102,6 +102,8 @@ export interface TextEngineFlowVertex { export interface TextEngineRegion { readonly id: number; readonly geometryRevision: number; + /** Stable compact slot in the renderer-owned region transform table. Defaults to `id`. */ + readonly transformIndex?: number; readonly shape: 'rectangle' | 'polygon'; readonly vertices?: readonly TextEngineFlowVertex[]; readonly exclusionStart: number; @@ -479,6 +481,11 @@ function writeRegions( const offset = tableOffset + index * layout.size; view.setUint32(offset + layout.id, u32(value.id, 'region ID'), true); view.setUint32(offset + layout.geometryRevision, u32(value.geometryRevision, 'region geometry revision'), true); + view.setUint32( + offset + layout.transformIndex, + u32(value.transformIndex ?? value.id, 'region transform index'), + true, + ); view.setUint32(offset + layout.verticesOffset, vertexOffsets[index]!, true); view.setUint16(offset + layout.vertexCount, u16(value.vertices?.length ?? 0, 'region vertex count'), true); view.setUint16(offset + layout.exclusionStart, u16(value.exclusionStart, 'region exclusion start'), true); diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index 7f8391df..6a66e82d 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -2,6 +2,7 @@ import { textShaperAbi } from '../generated/text-shaper-abi.js'; const MAX_U32 = 0xffff_ffff; const encoder = new TextEncoder(); +export const FIRST_PARTY_TRANSFORM_BUFFER_ID = 15; type PolicyInputScope = keyof typeof textShaperAbi.policy.inputScopes; @@ -137,8 +138,9 @@ function threeCapabilitySet(): PolicyCapabilitySet { function bitmapProgram(techniqueId: number, programId: number): PolicyProgram { const context = programContext('strike', 8, 0); - const { loadF32, binary, storeF32 } = context; + const { loadF32, loadU32, binary, storeF32, storeU32 } = context; loadF32(15); + loadU32(31, 0); binary('multiplyF32', 15, 7, 2); binary('addF32', 16, 0, 15); binary('multiplyF32', 17, 8, 2); @@ -152,14 +154,16 @@ function bitmapProgram(techniqueId: number, programId: number): PolicyProgram { [4, [13, 14]], [5, [3, 4, 5, 6]], ]); - return createProgram(techniqueId, programId, context, floatBuffers([2, 2, 2, 2, 4])); + storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + return createProgram(techniqueId, programId, context, [...floatBuffers([2, 2, 2, 2, 4]), transformIndexBuffer()]); } function msdfProgram(techniqueId: number, programId: number): PolicyProgram { const context = programContext('glyph', 10, 1); - const { operations, loadF32, loadU32, binary, constantF32, storeF32 } = context; + const { operations, loadF32, loadU32, binary, constantF32, storeF32, storeU32 } = context; loadF32(17); - loadU32(17, 0); + loadU32(17, 1); + loadU32(31, 0); binary('multiplyF32', 18, 7, 2); binary('addF32', 19, 0, 18); binary('multiplyF32', 20, 8, 2); @@ -177,14 +181,19 @@ function msdfProgram(techniqueId: number, programId: number): PolicyProgram { [6, [25, 25, 25, 25]], [7, [25, 25, 25, 24]], ]); - return createProgram(techniqueId, programId, context, floatBuffers([4, 4, 4, 4, 4, 4, 4])); + storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + return createProgram(techniqueId, programId, context, [ + ...floatBuffers([4, 4, 4, 4, 4, 4, 4]), + transformIndexBuffer(), + ]); } function slugProgram(techniqueId: number, programId: number): PolicyProgram { const context = programContext('glyph', 8, 6, true); const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; loadF32(16); - for (let field = 0; field < 6; field += 1) loadU32(21 + field, field); + loadU32(31, 0); + for (let field = 0; field < 6; field += 1) loadU32(21 + field, field + 1); binary('multiplyF32', 16, 8, 2); binary('addF32', 17, 0, 16); binary('multiplyF32', 18, 9, 2); @@ -204,7 +213,12 @@ function slugProgram(techniqueId: number, programId: number): PolicyProgram { [6, [21, 22, 23, 24]], [7, [25, 26, 29, 29]], ]); - return createProgram(techniqueId, programId, context, [...floatBuffers([4, 4, 4, 4, 4]), ...u32Buffers([4, 4], 6)]); + storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + return createProgram(techniqueId, programId, context, [ + ...floatBuffers([4, 4, 4, 4, 4]), + ...u32Buffers([4, 4], 6), + transformIndexBuffer(), + ]); } interface ProgramContext { @@ -234,6 +248,7 @@ function programContext( ): ProgramContext { const operations: PolicyOperation[] = []; const semantic = textShaperAbi.engine.semanticF32Fields; + const semanticU32 = textShaperAbi.engine.semanticU32Fields; const inputs: PolicyInput[] = [ { scope: 'semantic', field: semantic.inlineStart }, { scope: 'semantic', field: semantic.blockStart }, @@ -244,13 +259,14 @@ function programContext( { scope: 'semantic', field: semantic.foregroundAlpha }, ...(inverseFontSize ? [{ scope: 'semantic' as const, field: semantic.inverseFontSize }] : []), ...Array.from({ length: bindingF32Count }, (_, field) => ({ scope: bindingScope, field })), + { scope: 'semantic', field: semanticU32.transformIndex }, ...Array.from({ length: bindingU32Count }, (_, field) => ({ scope: bindingScope, field })), ]; return { inputs, operations, f32InputCount: 7 + (inverseFontSize ? 1 : 0) + bindingF32Count, - u32InputCount: bindingU32Count, + u32InputCount: bindingU32Count + 1, loadF32(count) { for (let field = 0; field < count; field += 1) { operations.push({ opcode: textShaperAbi.policy.opcodes.loadF32, target: field, operand0: field }); @@ -304,14 +320,7 @@ function createProgram( operations: context.operations, storageKeyMask: batch.technique | batch.program | batch.resource, drawKeyMask: - batch.technique | - batch.program | - batch.resource | - batch.material | - batch.clip | - batch.depth | - batch.order | - batch.transform, + batch.technique | batch.program | batch.resource | batch.material | batch.clip | batch.depth | batch.order, }; } @@ -340,6 +349,14 @@ function u32Buffers(widths: readonly number[], firstId: number): PolicyBuffer[] })); } +function transformIndexBuffer(): PolicyBuffer { + return { + id: FIRST_PARTY_TRANSFORM_BUFFER_ID, + scalar: textShaperAbi.policy.scalarTypes.u32, + vectorWidth: 1, + }; +} + function compilePolicy(descriptor: PolicyDescriptor): Uint8Array { const request = textShaperAbi.layouts.policyRequest; const capability = textShaperAbi.layouts.policyCapabilitySet; diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts new file mode 100644 index 00000000..9e8d6eb1 --- /dev/null +++ b/packages/text/src/three/engine-plan-target.ts @@ -0,0 +1,512 @@ +import * as TSL from 'three/tsl'; +import * as THREE from 'three/webgpu'; + +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { TextEnginePublication } from '../internal/text-engine-host.js'; +import { FIRST_PARTY_TRANSFORM_BUFFER_ID } from '../internal/render-policy-wire.js'; +import { TextEngineRenderPlanView, type RenderPlanTable } from '../internal/render-plan-view.js'; +import { bitmap, type BitmapPageData } from '../raster/bitmap-technique.js'; +import { bitmapShader } from './bitmap-shader.js'; +import type { ThreeTextEngineCoordinator, ThreeTextEngineResource } from './engine-runtime.js'; +import { invalidatePboTexture } from './retained-target.js'; + +type ScalarArray = Float32Array | Uint32Array | Uint16Array; + +interface RetainedBuffer { + readonly id: number; + readonly generation: number; + readonly policyBufferId: number; + readonly scalarType: number; + readonly vectorWidth: number; + readonly capacityRecords: number; + readonly array: ScalarArray; + readonly attribute: THREE.StorageInstancedBufferAttribute; +} + +interface RetainedResource { + readonly id: number; + readonly generation: number; + readonly techniqueId: number; + readonly referenceId: number; +} + +export interface ThreeTextEnginePlanOwner { + readonly drawRoot: THREE.Object3D; + objectForTransform(transformId: number): THREE.Object3D; + readonly renderOrderBase: number; +} + +/** Applies retained Rust command-buffer deltas to Three storage attributes and draw objects. */ +export class ThreeTextEnginePlanTarget { + readonly #coordinator: ThreeTextEngineCoordinator; + readonly #owner: ThreeTextEnginePlanOwner; + readonly #view = new TextEngineRenderPlanView(); + readonly #buffers = new Map(); + readonly #resources = new Map(); + readonly #bitmapTextures = new Map(); + readonly #materials = new Map(); + readonly #activeTransformIndices = new Set(); + readonly #rootInverse = new THREE.Matrix4(); + readonly #relativeTransform = new THREE.Matrix4(); + #transformAttribute = transformAttribute(1); + #transformGeneration = 1; + #draws: THREE.Mesh[] = []; + #disposed = false; + + constructor(coordinator: ThreeTextEngineCoordinator, owner: ThreeTextEnginePlanOwner) { + this.#coordinator = coordinator; + this.#owner = owner; + } + + get draws(): readonly THREE.Mesh[] { + return this.#draws; + } + + get gpuBytes(): number { + let bytes = 0; + for (const buffer of this.#buffers.values()) bytes += buffer.array.byteLength; + bytes += this.#transformAttribute.array.byteLength; + for (const texture of this.#bitmapTextures.values()) { + const data = texture.image.data as ArrayBufferView | undefined; + bytes += data?.byteLength ?? 0; + } + return bytes; + } + + apply(publication: TextEnginePublication): void { + if (this.#disposed) throw new Error('Three text-engine plan target has been disposed'); + const plan = this.#view.bind(publication); + const resources = plan.table('resources'); + const buffers = plan.table('buffers'); + const patches = plan.table('patches'); + const primitives = plan.table('primitives'); + const draws = plan.table('draws'); + const retirements = plan.table('retirements'); + if (resources.count !== 0) this.#readResources(plan, resources); + if (buffers.count !== 0) this.#readBuffers(plan, buffers); + this.#applyPatches(plan, patches); + if ( + resources.count !== 0 || + buffers.count !== 0 || + primitives.count !== 0 || + draws.count !== 0 || + retirements.count !== 0 + ) { + this.#replaceDraws(plan, draws, primitives, buffers, resources); + } + this.syncTransforms(); + this.#applyRetirements(plan, retirements); + } + + /** Upload changed scene transforms without crossing into Wasm or invalidating text layout. */ + syncTransforms(): number { + if (this.#activeTransformIndices.size === 0) return 0; + this.#owner.drawRoot.updateWorldMatrix(true, false); + this.#rootInverse.copy(this.#owner.drawRoot.matrixWorld).invert(); + const target = this.#transformAttribute.array as Float32Array; + let changed = 0; + for (const index of this.#activeTransformIndices) { + const object = this.#owner.objectForTransform(index); + object.updateWorldMatrix(true, false); + this.#relativeTransform.multiplyMatrices(this.#rootInverse, object.matrixWorld); + if (matrixEquals(target, index * 16, this.#relativeTransform.elements)) continue; + target.set(this.#relativeTransform.elements, index * 16); + this.#transformAttribute.addUpdateRange(index * 16, 16); + changed += 1; + } + if (changed === 0) return 0; + this.#transformAttribute.needsUpdate = true; + invalidatePboTexture(this.#transformAttribute); + return changed; + } + + dispose(): void { + if (this.#disposed) return; + this.#disposed = true; + this.#disposeDraws(); + for (const material of this.#materials.values()) material.dispose(); + for (const texture of this.#bitmapTextures.values()) texture.dispose(); + this.#materials.clear(); + this.#bitmapTextures.clear(); + this.#buffers.clear(); + this.#resources.clear(); + this.#activeTransformIndices.clear(); + } + + #readResources(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.engineResource; + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + const resource: RetainedResource = { + id: plan.u32(record + layout.id), + generation: plan.u32(record + layout.generation), + techniqueId: plan.u32(record + layout.techniqueId), + referenceId: plan.u32(record + layout.referenceId), + }; + this.#coordinator.resolveResource(resource.referenceId); + this.#resources.set(resource.id, resource); + } + } + + #readBuffers(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.engineBuffer; + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + const id = plan.u32(record + layout.id); + const generation = plan.u32(record + layout.generation); + const scalarType = plan.u8(record + layout.scalarType); + const vectorWidth = plan.u8(record + layout.vectorWidth); + const capacityRecords = plan.u32(record + layout.capacityRecords); + const byteLength = plan.u32(record + layout.byteLength); + const existing = this.#buffers.get(id); + if ( + existing?.generation === generation && + existing.scalarType === scalarType && + existing.vectorWidth === vectorWidth && + existing.array.byteLength === byteLength + ) { + continue; + } + const array = scalarArray(scalarType, byteLength); + if (array.length !== capacityRecords * vectorWidth) { + throw new Error('first-party Three policy requires tightly packed physical buffers'); + } + const attribute = new THREE.StorageInstancedBufferAttribute(array, vectorWidth); + attribute.setUsage(THREE.DynamicDrawUsage); + attribute.needsUpdate = true; + this.#buffers.set(id, { + id, + generation, + policyBufferId: plan.u16(record + layout.policyBufferId), + scalarType, + vectorWidth, + capacityRecords, + array, + attribute, + }); + } + } + + #applyPatches(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.enginePatch; + const opcodes = textShaperAbi.engine.patchOpcodes; + const touched = new Set(); + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + const opcode = plan.u16(record + layout.opcode); + const buffer = this.#buffer(plan.u32(record + layout.bufferId), plan.u32(record + layout.bufferGeneration)); + const destinationOffset = plan.u32(record + layout.destinationOffset); + const byteLength = plan.u32(record + layout.byteLength); + if (opcode === opcodes.allocateOrResize || opcode === opcodes.retire) continue; + const destination = new Uint8Array(buffer.array.buffer, buffer.array.byteOffset, buffer.array.byteLength); + if (destinationOffset + byteLength > destination.byteLength) + throw new RangeError('buffer patch exceeds allocation'); + if (opcode === opcodes.write) { + destination.set(plan.bytes(plan.u32(record + layout.payloadOffset), byteLength), destinationOffset); + } else if (opcode === opcodes.fill) { + const fill = plan.u32(record + layout.fillValue); + const view = new DataView(destination.buffer, destination.byteOffset + destinationOffset, byteLength); + if (byteLength % 4 !== 0) throw new RangeError('fill patch is not u32 aligned'); + for (let offset = 0; offset < byteLength; offset += 4) view.setUint32(offset, fill, true); + } else if (opcode === opcodes.copy) { + const source = this.#buffers.get(plan.u32(record + layout.sourceBufferId)); + if (source === undefined) throw new Error('copy patch references an unknown source buffer'); + const sourceOffset = plan.u32(record + layout.sourceOffset); + const sourceBytes = new Uint8Array(source.array.buffer, source.array.byteOffset, source.array.byteLength); + if (source.array.buffer === buffer.array.buffer && source.array.byteOffset === buffer.array.byteOffset) { + destination.copyWithin(destinationOffset, sourceOffset, sourceOffset + byteLength); + } else { + destination.set(sourceBytes.subarray(sourceOffset, sourceOffset + byteLength), destinationOffset); + } + } else { + throw new Error(`unsupported text-engine patch opcode ${opcode}`); + } + markUpdated(buffer, destinationOffset, byteLength, !touched.has(buffer.id)); + touched.add(buffer.id); + } + } + + #replaceDraws( + plan: TextEngineRenderPlanView, + draws: RenderPlanTable, + primitives: RenderPlanTable, + buffers: RenderPlanTable, + resources: RenderPlanTable, + ): void { + const drawLayout = textShaperAbi.layouts.engineDraw; + const primitiveLayout = textShaperAbi.layouts.enginePrimitive; + const bufferLayout = textShaperAbi.layouts.engineBuffer; + const resourceLayout = textShaperAbi.layouts.engineResource; + const next: THREE.Mesh[] = []; + const transformIndices = this.#collectTransformIndices(plan, draws, primitives, buffers); + this.#ensureTransformCapacity(transformIndices); + try { + for (let index = 0; index < draws.count; index += 1) { + const draw = plan.record(draws, index); + if (plan.u32(draw + drawLayout.primitiveCount) !== 1) { + throw new Error('first-party Three plan target requires one primitive span per draw'); + } + const primitiveIndex = plan.u32(draw + drawLayout.primitiveStart); + const primitive = plan.record(primitives, primitiveIndex); + if (plan.u16(primitive + primitiveLayout.kind) !== textShaperAbi.engine.primitiveKinds.glyph) { + throw new Error('first-party Three plan target does not yet realize non-glyph primitives'); + } + const drawBufferStart = plan.u32(draw + drawLayout.bufferStart); + const drawBufferCount = plan.u32(draw + drawLayout.bufferCount); + const byPolicyId = new Map(); + for (let bufferIndex = drawBufferStart; bufferIndex < drawBufferStart + drawBufferCount; bufferIndex += 1) { + const record = plan.record(buffers, bufferIndex); + const buffer = this.#buffer(plan.u32(record + bufferLayout.id), plan.u32(record + bufferLayout.generation)); + byPolicyId.set(buffer.policyBufferId, buffer); + } + const resourceRecord = plan.record(resources, plan.u32(draw + drawLayout.resourceStart)); + const resource = this.#resources.get(plan.u32(resourceRecord + resourceLayout.id)); + if (resource === undefined) throw new Error('draw references an unknown retained resource'); + const materialId = plan.u32(draw + drawLayout.materialId); + const material = this.#bitmapMaterial(resource, byPolicyId, materialId); + const geometry = unitQuad(); + geometry.instanceCount = plan.u16(primitive + primitiveLayout.recordCount); + for (const buffer of byPolicyId.values()) { + geometry.setAttribute(`_pmndrsText_${buffer.policyBufferId}`, buffer.attribute); + } + geometry.setAttribute('_pmndrsTextTransforms', this.#transformAttribute); + const mesh = new THREE.Mesh(geometry, material); + mesh.userData.pmndrsTextRunStart = plan.u32(primitive + primitiveLayout.recordIndex); + mesh.frustumCulled = false; + mesh.renderOrder = this.#owner.renderOrderBase + index; + this.#owner.drawRoot.add(mesh); + next.push(mesh); + } + } catch (error) { + for (const mesh of next) { + mesh.removeFromParent(); + mesh.geometry.dispose(); + } + throw error; + } + this.#disposeDraws(); + this.#draws = next; + this.#activeTransformIndices.clear(); + for (const transformIndex of transformIndices) this.#activeTransformIndices.add(transformIndex); + } + + #collectTransformIndices( + plan: TextEngineRenderPlanView, + draws: RenderPlanTable, + primitives: RenderPlanTable, + buffers: RenderPlanTable, + ): Set { + const drawLayout = textShaperAbi.layouts.engineDraw; + const primitiveLayout = textShaperAbi.layouts.enginePrimitive; + const bufferLayout = textShaperAbi.layouts.engineBuffer; + const result = new Set(); + for (let drawIndex = 0; drawIndex < draws.count; drawIndex += 1) { + const draw = plan.record(draws, drawIndex); + const primitive = plan.record(primitives, plan.u32(draw + drawLayout.primitiveStart)); + const bufferStart = plan.u32(draw + drawLayout.bufferStart); + const bufferEnd = bufferStart + plan.u32(draw + drawLayout.bufferCount); + let transformBuffer: RetainedBuffer | undefined; + for (let bufferIndex = bufferStart; bufferIndex < bufferEnd; bufferIndex += 1) { + const record = plan.record(buffers, bufferIndex); + const candidate = this.#buffer(plan.u32(record + bufferLayout.id), plan.u32(record + bufferLayout.generation)); + if (candidate.policyBufferId === FIRST_PARTY_TRANSFORM_BUFFER_ID) transformBuffer = candidate; + } + if (transformBuffer === undefined || !(transformBuffer.array instanceof Uint32Array)) { + throw new Error('indexed Three draw is missing its u32 transform-index buffer'); + } + const start = plan.u32(primitive + primitiveLayout.recordIndex); + const end = start + plan.u16(primitive + primitiveLayout.recordCount); + for (let recordIndex = start; recordIndex < end; recordIndex += 1) { + const transformIndex = transformBuffer.array[recordIndex]; + if (transformIndex === undefined || transformIndex === 0) { + throw new Error('indexed Three draw references an invalid transform slot'); + } + result.add(transformIndex); + } + } + return result; + } + + #ensureTransformCapacity(indices: ReadonlySet): void { + let maximum = 0; + for (const index of indices) maximum = Math.max(maximum, index); + const requiredRecords = (maximum + 1) * 4; + if (this.#transformAttribute.count >= requiredRecords) return; + let capacity = this.#transformAttribute.count; + while (capacity < requiredRecords) capacity *= 2; + this.#transformAttribute = transformAttribute(capacity / 4); + this.#transformGeneration += 1; + for (const material of this.#materials.values()) material.dispose(); + this.#materials.clear(); + } + + #bitmapMaterial( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + ): THREE.MeshBasicNodeMaterial { + const resolved = this.#coordinator.resolveResource(resource.referenceId); + if (resolved.technique !== bitmap.id) { + throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); + } + const page = bitmapPage(resolved); + const required = [1, 2, 3, 4, 5].map((id) => { + const buffer = buffers.get(id); + if (buffer === undefined) throw new Error(`Bitmap draw is missing policy buffer ${id}`); + return buffer; + }); + const transformIndices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); + if (transformIndices === undefined) throw new Error('Bitmap draw is missing its transform-index buffer'); + const key = `${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.id}:${buffer.generation}`) + .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; + let material = this.#materials.get(key); + if (material !== undefined) return material; + const texture = this.#bitmapTexture(resource.referenceId, page); + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = TSL.instanceIndex.add(runStart); + const shader = bitmapShader( + { + origin: TSL.storage(required[0]!.attribute, 'vec2', required[0]!.attribute.count) + .setPBO(true) + .element(instance), + size: TSL.storage(required[1]!.attribute, 'vec2', required[1]!.attribute.count).setPBO(true).element(instance), + uvOrigin: TSL.storage(required[2]!.attribute, 'vec2', required[2]!.attribute.count) + .setPBO(true) + .element(instance), + uvSize: TSL.storage(required[3]!.attribute, 'vec2', required[3]!.attribute.count) + .setPBO(true) + .element(instance), + color: TSL.storage(required[4]!.attribute, 'vec4', required[4]!.attribute.count).setPBO(true).element(instance), + }, + { page: texture }, + ); + material = new THREE.MeshBasicNodeMaterial({ + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + }); + material.positionNode = indexedTransformPosition( + shader.position, + transformIndices.attribute, + this.#transformAttribute, + instance, + ); + material.vertexNode = shader.clipPosition; + material.colorNode = shader.color; + material.opacityNode = shader.opacity; + this.#materials.set(key, material); + return material; + } + + #bitmapTexture(referenceId: number, page: BitmapPageData): THREE.DataTexture { + let texture = this.#bitmapTextures.get(referenceId); + if (texture !== undefined) return texture; + texture = new THREE.DataTexture(page.bytes, page.width, page.height, THREE.RedFormat, THREE.UnsignedByteType); + texture.colorSpace = THREE.NoColorSpace; + texture.magFilter = THREE.LinearFilter; + texture.minFilter = THREE.LinearFilter; + texture.generateMipmaps = false; + texture.flipY = false; + texture.needsUpdate = true; + this.#bitmapTextures.set(referenceId, texture); + return texture; + } + + #applyRetirements(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { + const layout = textShaperAbi.layouts.engineRetirement; + for (let index = 0; index < table.count; index += 1) { + const record = plan.record(table, index); + if (plan.u16(record + layout.kind) !== textShaperAbi.engine.retirementKinds.buffer) continue; + const id = plan.u32(record + layout.id); + const generation = plan.u32(record + layout.generation); + if (this.#buffers.get(id)?.generation === generation) this.#buffers.delete(id); + } + } + + #buffer(id: number, generation: number): RetainedBuffer { + const buffer = this.#buffers.get(id); + if (buffer === undefined || buffer.generation !== generation) { + throw new Error(`text-engine buffer ${id}:${generation} is not retained`); + } + return buffer; + } + + #disposeDraws(): void { + for (const draw of this.#draws) { + draw.removeFromParent(); + draw.geometry.dispose(); + } + this.#draws = []; + } +} + +function bitmapPage(resource: ThreeTextEngineResource): BitmapPageData { + if (resource.technique !== bitmap.id || !('page' in resource)) { + throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); + } + return resource.page as BitmapPageData; +} + +function scalarArray(scalarType: number, byteLength: number): ScalarArray { + const scalar = textShaperAbi.policy.scalarTypes; + if (scalarType === scalar.f32) return new Float32Array(byteLength / 4); + if (scalarType === scalar.u32) return new Uint32Array(byteLength / 4); + if (scalarType === scalar.u16) return new Uint16Array(byteLength / 2); + throw new Error(`unsupported text-engine scalar type ${scalarType}`); +} + +function transformAttribute(transformCapacity: number): THREE.StorageInstancedBufferAttribute { + const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(transformCapacity * 16), 4); + attribute.setUsage(THREE.DynamicDrawUsage); + return attribute; +} + +function matrixEquals(target: Float32Array, offset: number, matrix: readonly number[]): boolean { + for (let index = 0; index < 16; index += 1) { + if (target[offset + index] !== Math.fround(matrix[index]!)) return false; + } + return true; +} + +function indexedTransformPosition( + position: THREE.Node<'vec3'>, + indexAttribute: THREE.StorageInstancedBufferAttribute, + transforms: THREE.StorageInstancedBufferAttribute, + instance: THREE.Node<'uint'>, +): THREE.Node<'vec3'> { + const transformIndex = TSL.storage(indexAttribute, 'uint', indexAttribute.count).setPBO(true).element(instance); + const firstColumn = transformIndex.mul(4); + const table = TSL.storage(transforms, 'vec4', transforms.count).setPBO(true); + const local = TSL.vec4(position, 1); + return table + .element(firstColumn) + .mul(local.x) + .add(table.element(firstColumn.add(1)).mul(local.y)) + .add(table.element(firstColumn.add(2)).mul(local.z)) + .add(table.element(firstColumn.add(3)).mul(local.w)).xyz; +} + +function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: number, firstPatch: boolean): void { + const scalarBytes = buffer.array.BYTES_PER_ELEMENT; + if (byteOffset % scalarBytes !== 0 || byteLength % scalarBytes !== 0) { + throw new RangeError('buffer patch is not scalar aligned'); + } + if (firstPatch) buffer.attribute.clearUpdateRanges(); + buffer.attribute.addUpdateRange(byteOffset / scalarBytes, byteLength / scalarBytes); + buffer.attribute.needsUpdate = true; + invalidatePboTexture(buffer.attribute); +} + +function unitQuad(): THREE.InstancedBufferGeometry { + const geometry = new THREE.InstancedBufferGeometry(); + geometry.setAttribute( + 'position', + new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0], 3), + ); + geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); + return geometry; +} diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index dd07dd3b..6628af8f 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -43,7 +43,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as abi.layouts.engineExclusion.size, abi.layouts.engineInlineObject.size, ], - [24, 92, 56, 8, 56, 48, 60], + [24, 92, 56, 8, 60, 48, 60], ); assert.equal(abi.layouts.engineInlineObject.alignment, 4); assert.equal(abi.layouts.engineInlineObject.baselineAlignment, 52); @@ -99,6 +99,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as flowThreadId: 3, foregroundRgba: 0, regionId: 2, + transformIndex: 4, }); assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size, 0), abi.status.ok); assert.equal(fn.sessionCount(), 1); @@ -345,6 +346,7 @@ function geometryRequestBytes(abi, expectedEngineRevision, consumedPlanRevision, view.setUint32(regionOffset + region.id, 1, true); view.setUint32(regionOffset + region.geometryRevision, 1, true); + view.setUint32(regionOffset + region.transformIndex, 1, true); view.setUint16(regionOffset + region.exclusionCount, 1, true); view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index a292658f..9f70b2ef 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -476,6 +476,7 @@ function engineStyleUpdateBytes( view.setUint32(regionOffset + region.id, 1, true); view.setUint32(regionOffset + region.geometryRevision, 1, true); + view.setUint32(regionOffset + region.transformIndex, 1, true); view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 527c83f5..1e48f8b7 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import { gunzipSync } from 'node:zlib'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import * as THREE from 'three/webgpu'; import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; @@ -16,6 +17,7 @@ import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; import { defineRasterResourceId } from '../../dist/raster-technique.js'; import { createRuntimeShaper } from '../../dist/shaper.js'; import { ThreeTextEngineCoordinator } from '../../dist/three/engine-runtime.js'; +import { ThreeTextEnginePlanTarget } from '../../dist/three/engine-plan-target.js'; const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', import.meta.url); const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); @@ -256,8 +258,66 @@ test('Three coordinator shares shaping data across technique bindings and refere ); assert.deepEqual( Array.from({ length: draws.count }, (_, index) => plan.u32(plan.record(draws, index) + drawLayout.transformId)), + [0, 0], + 'draw-level transform is zero when the policy selects indexed transform storage', + ); + const drawRoot = new THREE.Object3D(); + const paragraphObjects = new Map([ + [1, new THREE.Object3D()], + [2, new THREE.Object3D()], + ]); + paragraphObjects.get(1).position.x = 3; + paragraphObjects.get(2).position.x = 7; + const target = new ThreeTextEnginePlanTarget(coordinator, { + drawRoot, + renderOrderBase: 10, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + }); + target.apply(publication); + assert.equal(target.draws.length, 2); + assert.deepEqual( + target.draws.map((draw) => draw.parent), + [drawRoot, drawRoot], + 'indexed draws share one renderer node instead of splitting by scene transform', + ); + assert.deepEqual( + target.draws.map((draw) => draw.geometry.instanceCount), + [3, 3], + ); + assert.deepEqual( + target.draws.map((draw) => draw.renderOrder), + [10, 11], + ); + const transformIndexAttribute = target.draws[0].geometry.getAttribute('_pmndrsText_15'); + const transformTableAttribute = target.draws[0].geometry.getAttribute('_pmndrsTextTransforms'); + assert.ok(transformIndexAttribute.array instanceof Uint32Array); + assert.ok(transformTableAttribute.array instanceof Float32Array); + assert.deepEqual( + target.draws.map((draw) => transformIndexAttribute.array[draw.userData.pmndrsTextRunStart]), [1, 2], + 'Rust packs the renderer sidecar slot once per glyph instance', + ); + assert.equal(transformTableAttribute.array[1 * 16 + 12], 3); + assert.equal(transformTableAttribute.array[2 * 16 + 12], 7); + assert.deepEqual( + Array.from(transformTableAttribute.array.subarray(16, 32)), + Array.from(paragraphObjects.get(1).matrixWorld.elements, Math.fround), + ); + assert.deepEqual( + Array.from(transformTableAttribute.array.subarray(32, 48)), + Array.from(paragraphObjects.get(2).matrixWorld.elements, Math.fround), ); + const unchangedTransformVersion = transformTableAttribute.version; + assert.equal(target.syncTransforms(), 0); + assert.equal(transformTableAttribute.version, unchangedTransformVersion, 'unchanged matrices schedule no upload'); + paragraphObjects.get(1).position.x = 4; + assert.equal(target.syncTransforms(), 1); + assert.equal(transformTableAttribute.version, unchangedTransformVersion + 1); + assert.equal(transformTableAttribute.array[1 * 16 + 12], 4); const reorderedPublication = session.update( compileTextEngineFrameUpdate({ @@ -295,8 +355,75 @@ test('Three coordinator shares shaping data across technique bindings and refere Array.from({ length: reorderedDraws.count }, (_, index) => reorderedPlan.u32(reorderedPlan.record(reorderedDraws, index) + drawLayout.transformId), ), - [2, 1], + [0, 0], + ); + const previousDraws = [...target.draws]; + target.apply(reorderedPublication); + assert.ok( + previousDraws.every((draw) => draw.parent === null), + 'superseded command-buffer draws detach', + ); + assert.deepEqual( + target.draws.map((draw) => draw.parent), + [drawRoot, drawRoot], + ); + assert.deepEqual( + target.draws.map((draw) => draw.renderOrder), + [10, 11], + ); + const coalescedPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: reorderedPublication.engineRevision, + consumedPlanRevision: reorderedPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 2, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: 7, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + ], + }), + ); + const coalescedPlan = plan.bind(coalescedPublication); + const coalescedDraws = coalescedPlan.table('draws'); + assert.equal(coalescedDraws.count, 1, 'same-material paragraphs coalesce across indexed transforms'); + assert.equal(coalescedPlan.u32(coalescedPlan.record(coalescedDraws, 0) + drawLayout.transformId), 0); + target.apply(coalescedPublication); + assert.equal(target.draws.length, 1); + assert.equal(target.draws[0].geometry.instanceCount, 6); + const coalescedTransformIndices = target.draws[0].geometry.getAttribute('_pmndrsText_15').array; + const coalescedStart = target.draws[0].userData.pmndrsTextRunStart; + assert.deepEqual( + Array.from(coalescedTransformIndices.subarray(coalescedStart, coalescedStart + 6)), + [2, 2, 2, 1, 1, 1], ); + assert.ok(target.gpuBytes > 0); + target.dispose(); session.dispose(); first.release(); first.release(); diff --git a/packages/text/tests/support/engine-abi.mjs b/packages/text/tests/support/engine-abi.mjs index 36e20956..9cc1fc0d 100644 --- a/packages/text/tests/support/engine-abi.mjs +++ b/packages/text/tests/support/engine-abi.mjs @@ -232,6 +232,7 @@ export function engineFrameUpdateBytes( view.setUint32(regionOffset + region.id, 1, true); view.setUint32(regionOffset + region.geometryRevision, geometry.revision, true); + view.setUint32(regionOffset + region.transformIndex, 1, true); view.setUint8(regionOffset + region.shape, abi.engine.flowShapeKinds.rectangle); view.setUint8(regionOffset + region.writingMode, abi.engine.writingModes.horizontalTb); view.setUint8(regionOffset + region.textOrientation, abi.engine.textOrientations.mixed); From dfb875e24bdf21d7b7e813fc55351d17015b985f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sat, 8 Aug 2026 23:59:07 -0400 Subject: [PATCH 068/128] feat(text): execute MSDF render plans --- docs/log.md | 7 ++ docs/packages/text.md | 13 +- docs/planning/decision-register.md | 2 + packages/text/src/three/engine-plan-target.ts | 117 +++++++++++++++++- .../integration/three-engine-runtime.test.mjs | 54 +++++++- 5 files changed, 186 insertions(+), 7 deletions(-) diff --git a/docs/log.md b/docs/log.md index 6a6d284e..0b052b2b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-08 +- **Executed MSDF from the Rust command buffer** — Extended the shared Three plan executor rather than adding another + target path. Rust policy buffers 1–7 bind directly as MSDF `vec4` storage, buffer 15 indexes the shared transform + sidecar, the renderer resolves and builds the validated layered atlas once, and the canonical `msdfShader` remains the + coverage authority. A compiled-Wasm fixture changes two retained paragraphs from Bitmap-first to MSDF-first fallback + without text or geometry resend and receives one six-instance program-2 draw. Wasm bytes are unchanged. Live pixels, + Slug, bounded retirement, material factories, and public cutover remain open. + - **Made transform batching policy-selectable and executed indexed Bitmap draws** — Corrected the temporary global transform draw boundary. Programs may now split on transform and consume nonzero draw-level IDs, or omit that key and pack stable region transform slots into first-party policy buffer 15. The Bitmap Three executor applies Rust buffer diff --git a/docs/packages/text.md b/docs/packages/text.md index 2f50076b..5ea7658d 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:25a3f6a830826d54b422a22a446a429b8ab4537e2d962410467e9cc60a8a8539' +source_digest: 'sha256:fa4cf68dac7a7f288956a565bc048e9159d74b6e67ba0794578d691d2155ce0d' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -882,10 +882,13 @@ first-party indexed programs pack one `u32 transformIndex` per instance in polic Three owns the compact matrix sidecar and may update its dirty matrix ranges without calling Wasm or invalidating layout. Flow regions carry the stable sidecar slot independently from local geometry; visible overflow carries no clip identity. A compiled-Wasm fixture keeps distinct materials as two draws, then changes both retained paragraphs to one -material and observes one six-instance draw over transform slots `[2,2,2,1,1,1]`. The executor is still Bitmap-only and -not connected to the public `Text` lifecycle, so MSDF/Slug realization, browser pixels, public cutover, and end-to-end -latency remain open. The optimized shaper is 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes at this checkpoint; all -129 Rust tests and the focused compiled-Wasm/Three integration test pass. +material and observes one six-instance draw over transform slots `[2,2,2,1,1,1]`. MSDF uses the same executor: Rust +packs seven exact `vec4` streams plus transform indices, Three resolves the validated atlas directly and feeds those +streams to the canonical `msdfShader`, and a retained Bitmap-first → MSDF-first stack change produces one six-instance +program-2 draw without resending text or geometry. The executor is not connected to the public `Text` lifecycle, so +Slug realization, browser pixels, retirement-bounded caches, material factories, public cutover, and end-to-end latency +remain open. The optimized shaper remains 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes; all 129 Rust tests and the +focused compiled-Wasm/Three integration test pass. The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 2ff3ea02..0d1dd05e 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -292,6 +292,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-216 | Transform realization is selected per policy program rather than imposed globally. A program including transform in its draw key receives transform-split packets with nonzero draw-level `transformId`; a program omitting it packs the region's stable compact `transformIndex` into policy buffer 15 and receives draw-level zero, allowing compatible glyphs to index a renderer-owned matrix sidecar in one draw. The two modes may coexist across programs and capability sets. Region transform slots are supplied with flow geometry but do not participate in layout geometry or change when an `Object3D` matrix changes. Visible overflow carries clip zero; only an actual clip/ellipsis region forms a clip boundary. Rust tests prove split `[1,2]` and indexed `[0]` outputs from the same two-transform input. The compiled-Wasm Bitmap executor copies Rust patches into retained Three storage, resolves resources directly, uploads changed transform matrices without a Wasm call, retains material splits, and collapses two same-material paragraphs into one six-instance draw with transform slots `[2,2,2,1,1,1]`. All 129 Rust tests and the focused compiled-Wasm/Three test pass. Optimized Wasm is 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes; no browser pixel or end-to-end latency claim is attached yet. | Accepted | +| D-217 | MSDF command-buffer realization reuses the same retained buffer/patch/draw executor and transform sidecar as Bitmap. Policy buffers 1–7 are exact `vec4` streams for geometry, UVs, bounds, fill, outline, shadow, and effects; buffer 15 remains the transform index. The renderer resolves the Rust resource reference directly, builds one layered RGBA atlas from validated pages, and invokes the canonical `msdfShader` rather than repacking TypeScript technique storage. A compiled-Wasm fixture changes two retained paragraphs from Bitmap-first to MSDF-first fallback without resending text or geometry and observes one six-instance program-2 draw with all eight required buffers. Wasm bytes do not change because this slice is renderer-only. Live WebGPU/WebGL pixels, material factories, retirement-bounded caches, and public `Text` cutover remain open. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 9e8d6eb1..dee3095d 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -6,8 +6,10 @@ import type { TextEnginePublication } from '../internal/text-engine-host.js'; import { FIRST_PARTY_TRANSFORM_BUFFER_ID } from '../internal/render-policy-wire.js'; import { TextEngineRenderPlanView, type RenderPlanTable } from '../internal/render-plan-view.js'; import { bitmap, type BitmapPageData } from '../raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../raster/msdf.js'; import { bitmapShader } from './bitmap-shader.js'; import type { ThreeTextEngineCoordinator, ThreeTextEngineResource } from './engine-runtime.js'; +import { msdfShader } from './msdf-shader.js'; import { invalidatePboTexture } from './retained-target.js'; type ScalarArray = Float32Array | Uint32Array | Uint16Array; @@ -44,6 +46,7 @@ export class ThreeTextEnginePlanTarget { readonly #buffers = new Map(); readonly #resources = new Map(); readonly #bitmapTextures = new Map(); + readonly #msdfAtlases = new Map(); readonly #materials = new Map(); readonly #activeTransformIndices = new Set(); readonly #rootInverse = new THREE.Matrix4(); @@ -70,6 +73,10 @@ export class ThreeTextEnginePlanTarget { const data = texture.image.data as ArrayBufferView | undefined; bytes += data?.byteLength ?? 0; } + for (const atlas of this.#msdfAtlases.values()) { + const data = atlas.image.data as ArrayBufferView | undefined; + bytes += data?.byteLength ?? 0; + } return bytes; } @@ -126,8 +133,10 @@ export class ThreeTextEnginePlanTarget { this.#disposeDraws(); for (const material of this.#materials.values()) material.dispose(); for (const texture of this.#bitmapTextures.values()) texture.dispose(); + for (const atlas of this.#msdfAtlases.values()) atlas.dispose(); this.#materials.clear(); this.#bitmapTextures.clear(); + this.#msdfAtlases.clear(); this.#buffers.clear(); this.#resources.clear(); this.#activeTransformIndices.clear(); @@ -263,7 +272,7 @@ export class ThreeTextEnginePlanTarget { const resource = this.#resources.get(plan.u32(resourceRecord + resourceLayout.id)); if (resource === undefined) throw new Error('draw references an unknown retained resource'); const materialId = plan.u32(draw + drawLayout.materialId); - const material = this.#bitmapMaterial(resource, byPolicyId, materialId); + const material = this.#material(resource, byPolicyId, materialId); const geometry = unitQuad(); geometry.instanceCount = plan.u16(primitive + primitiveLayout.recordCount); for (const buffer of byPolicyId.values()) { @@ -402,6 +411,81 @@ export class ThreeTextEnginePlanTarget { return material; } + #material( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + ): THREE.MeshBasicNodeMaterial { + const resolved = this.#coordinator.resolveResource(resource.referenceId); + if (resolved.technique === bitmap.id) return this.#bitmapMaterial(resource, buffers, materialId); + if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId); + throw new Error('this Three plan target checkpoint does not yet realize Slug draws'); + } + + #msdfMaterial( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + ): THREE.MeshBasicNodeMaterial { + const data = msdfData(this.#coordinator.resolveResource(resource.referenceId)); + const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { + const buffer = buffers.get(id); + if (buffer === undefined) throw new Error(`MSDF draw is missing policy buffer ${id}`); + return buffer; + }); + const transformIndices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); + if (transformIndices === undefined) throw new Error('MSDF draw is missing its transform-index buffer'); + const key = `msdf:${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.id}:${buffer.generation}`) + .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; + let material = this.#materials.get(key); + if (material !== undefined) return material; + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = TSL.instanceIndex.add(runStart); + const fields = required.map((buffer) => + TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance), + ); + const shader = msdfShader( + { + origin: fields[0]!.xy, + size: fields[0]!.zw, + uvOrigin: fields[1]!.xy, + uvSize: fields[1]!.zw, + uvBounds: fields[2]!, + fillColor: fields[3]!, + outlineColor: fields[4]!, + shadowColor: fields[5]!, + shadowOffset: fields[6]!.xy, + outlineWidth: fields[6]!.z, + pageIndex: fields[6]!.w, + }, + { + atlas: this.#msdfAtlas(resource.referenceId, data), + atlasWidth: data.binding.width, + atlasHeight: data.binding.height, + pixelRange: data.pixelRange, + }, + ); + material = new THREE.MeshBasicNodeMaterial({ + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + }); + material.positionNode = indexedTransformPosition( + shader.position, + transformIndices.attribute, + this.#transformAttribute, + instance, + ); + material.colorNode = shader.color; + material.opacityNode = shader.opacity; + this.#materials.set(key, material); + return material; + } + #bitmapTexture(referenceId: number, page: BitmapPageData): THREE.DataTexture { let texture = this.#bitmapTextures.get(referenceId); if (texture !== undefined) return texture; @@ -416,6 +500,30 @@ export class ThreeTextEnginePlanTarget { return texture; } + #msdfAtlas(referenceId: number, data: MsdfData): THREE.DataArrayTexture { + let atlas = this.#msdfAtlases.get(referenceId); + if (atlas !== undefined) return atlas; + const bytes = new Uint8Array(data.binding.width * data.binding.height * data.binding.layers * 4); + for (let layer = 0; layer < data.pages.length; layer += 1) { + const page = data.pages[layer]!; + for (let row = 0; row < page.height; row += 1) { + const source = row * page.width * 4; + const target = (layer * data.binding.height + row) * data.binding.width * 4; + bytes.set(page.bytes.subarray(source, source + page.width * 4), target); + } + } + atlas = new THREE.DataArrayTexture(bytes, data.binding.width, data.binding.height, data.binding.layers); + atlas.format = THREE.RGBAFormat; + atlas.type = THREE.UnsignedByteType; + atlas.colorSpace = THREE.NoColorSpace; + atlas.magFilter = THREE.LinearFilter; + atlas.minFilter = THREE.LinearFilter; + atlas.generateMipmaps = false; + atlas.needsUpdate = true; + this.#msdfAtlases.set(referenceId, atlas); + return atlas; + } + #applyRetirements(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { const layout = textShaperAbi.layouts.engineRetirement; for (let index = 0; index < table.count; index += 1) { @@ -451,6 +559,13 @@ function bitmapPage(resource: ThreeTextEngineResource): BitmapPageData { return resource.page as BitmapPageData; } +function msdfData(resource: ThreeTextEngineResource): MsdfData { + if (resource.technique !== msdf.id || !('data' in resource)) { + throw new Error('Three MSDF draw references an incompatible resource'); + } + return resource.data; +} + function scalarArray(scalarType: number, byteLength: number): ScalarArray { const scalar = textShaperAbi.policy.scalarTypes; if (scalarType === scalar.f32) return new Float32Array(byteLength / 4); diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 1e48f8b7..fa8d175a 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -78,7 +78,11 @@ test('Three coordinator shares shaping data across technique bindings and refere raster: undefined, data: { resource: defineRasterResourceId('coordinator.mtsdf'), - binding: {}, + binding: { + width: Math.max(...msdfRaster.pages.map((page) => page.width)), + height: Math.max(...msdfRaster.pages.map((page) => page.height)), + layers: msdfRaster.pages.length, + }, emSize: extension.emSize, pixelRange: extension.pixelRange, planeUnitsPerEm: extension.planeUnitsPerEm, @@ -422,6 +426,54 @@ test('Three coordinator shares shaping data across technique bindings and refere Array.from(coalescedTransformIndices.subarray(coalescedStart, coalescedStart + 6)), [2, 2, 2, 1, 1, 1], ); + const bitmapGpuBytes = target.gpuBytes; + const msdfPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: coalescedPublication.engineRevision, + consumedPlanRevision: coalescedPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [2, 1].map((paragraphId) => ({ + opcode: 'upsert', + paragraphId, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: reversed.handle, + materialId: 7, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + })), + }), + ); + const msdfPlan = plan.bind(msdfPublication); + const msdfDraws = msdfPlan.table('draws'); + assert.equal(msdfDraws.count, 1); + assert.equal(msdfPlan.u32(msdfPlan.record(msdfDraws, 0) + drawLayout.programId), 2); + target.apply(msdfPublication); + assert.equal(target.draws.length, 1); + assert.equal(target.draws[0].geometry.instanceCount, 6); + for (const policyBufferId of [1, 2, 3, 4, 5, 6, 7, 15]) { + assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); + } + assert.ok(target.gpuBytes > bitmapGpuBytes, 'MSDF atlas residency is included in command-buffer accounting'); assert.ok(target.gpuBytes > 0); target.dispose(); session.dispose(); From 0d3360550a032c9b88c76bd6672efec19320d10f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 00:16:26 -0400 Subject: [PATCH 069/128] feat(text): execute Slug render plans --- docs/log.md | 8 + docs/packages/text.md | 12 +- docs/planning/decision-register.md | 2 + .../text/src/internal/slug-shaders/index.ts | 2 +- .../src/internal/slug-shaders/slug-dilate.ts | 57 ++++- packages/text/src/three/engine-plan-target.ts | 202 +++++++++++++++++- packages/text/src/three/slug-shader.ts | 50 +++-- .../integration/three-engine-runtime.test.mjs | 95 +++++++- 8 files changed, 393 insertions(+), 35 deletions(-) diff --git a/docs/log.md b/docs/log.md index 0b052b2b..08c27916 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-08 +- **Executed Slug from the Rust command buffer** — Bound Rust's five float `vec4`, two integer `uvec4`, and indexed- + transform streams directly to the canonical Slug graph. The renderer retains validated curve/header/reference + textures and keeps the WebGL-compatible packed-reference representation. A new full-MVP dilation input applies the + exact per-instance transform to both placement and analytic half-pixel expansion while retaining the legacy row + interface. The compiled-Wasm fixture republishes the same six retained glyphs as one program-3 draw after Bitmap and + MSDF, without resending text or geometry. Wasm bytes are unchanged. Live shader compilation/pixels, bounded + retirement, material factories, and public cutover remain open. + - **Executed MSDF from the Rust command buffer** — Extended the shared Three plan executor rather than adding another target path. Rust policy buffers 1–7 bind directly as MSDF `vec4` storage, buffer 15 indexes the shared transform sidecar, the renderer resolves and builds the validated layered atlas once, and the canonical `msdfShader` remains the diff --git a/docs/packages/text.md b/docs/packages/text.md index 5ea7658d..e61433bd 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:fa4cf68dac7a7f288956a565bc048e9159d74b6e67ba0794578d691d2155ce0d' +source_digest: 'sha256:b1cb33af564181b940c763b7f3d640b4f9264c0cef5da564afed61f486c906ae' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -886,9 +886,13 @@ material and observes one six-instance draw over transform slots `[2,2,2,1,1,1]` packs seven exact `vec4` streams plus transform indices, Three resolves the validated atlas directly and feeds those streams to the canonical `msdfShader`, and a retained Bitmap-first → MSDF-first stack change produces one six-instance program-2 draw without resending text or geometry. The executor is not connected to the public `Text` lifecycle, so -Slug realization, browser pixels, retirement-bounded caches, material factories, public cutover, and end-to-end latency -remain open. The optimized shaper remains 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes; all 129 Rust tests and the -focused compiled-Wasm/Three integration test pass. +Slug uses the same executor with five float and two integer `vec4` streams plus transform indices. Its validated curve, +header, and packed-reference textures are retained once. The indexed matrix drives both final placement and the +canonical analytic dilation graph through an exact per-instance MVP, while the existing row-based shader interface +remains valid for transform-split targets. The compiled-Wasm fixture republishes the same six retained instances as one +program-3 draw without resending text or geometry. Browser shader compilation/pixels, retirement-bounded caches, +material factories, public cutover, and end-to-end latency remain open. The optimized shaper remains 1,083,255 raw / +411,409 gzip / 324,539 Brotli bytes; all 129 Rust tests and the focused compiled-Wasm/Three integration test pass. The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 0d1dd05e..3e9f5c0c 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -294,6 +294,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-217 | MSDF command-buffer realization reuses the same retained buffer/patch/draw executor and transform sidecar as Bitmap. Policy buffers 1–7 are exact `vec4` streams for geometry, UVs, bounds, fill, outline, shadow, and effects; buffer 15 remains the transform index. The renderer resolves the Rust resource reference directly, builds one layered RGBA atlas from validated pages, and invokes the canonical `msdfShader` rather than repacking TypeScript technique storage. A compiled-Wasm fixture changes two retained paragraphs from Bitmap-first to MSDF-first fallback without resending text or geometry and observes one six-instance program-2 draw with all eight required buffers. Wasm bytes do not change because this slice is renderer-only. Live WebGPU/WebGL pixels, material factories, retirement-bounded caches, and public `Text` cutover remain open. | Accepted | +| D-218 | Slug command-buffer realization consumes five Rust-packed `vec4` streams, two `uvec4` streams, and the shared transform-index stream without TypeScript glyph repacking. The executor resolves each validated analytic page once and preserves the WebGL-compatible packed-reference texture. Indexed batching applies one per-instance matrix to both final vertex placement and Slug's half-pixel dilation: the canonical shader accepts an exact full MVP and derives clip-position and clip-normal gradients without matrix-row extraction. The legacy row interface remains available for existing targets. A compiled-Wasm fixture changes the same two retained paragraphs from Bitmap to MSDF to Slug without resending text or geometry and observes one six-instance program-3 draw with policy buffers 1–7 and 15. Wasm bytes do not change because this slice is renderer-only. Live shader compilation/pixels, material factories, retirement-bounded caches, and public `Text` cutover remain open. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/internal/slug-shaders/index.ts b/packages/text/src/internal/slug-shaders/index.ts index 1bd005d6..a8b601a6 100644 --- a/packages/text/src/internal/slug-shaders/index.ts +++ b/packages/text/src/internal/slug-shaders/index.ts @@ -1,7 +1,7 @@ /** Internal Slug TSL primitives. Adapted from three-flatland Slug at 2935a89f (MIT). */ export { calcCoverage } from './calc-coverage.js'; export { calcRootCode } from './calc-root-code.js'; -export { slugDilate } from './slug-dilate.js'; +export { slugDilate, slugDilateMatrix } from './slug-dilate.js'; export { MAX_SAFE_SLUG_BAND_CURVES, slugRender, diff --git a/packages/text/src/internal/slug-shaders/slug-dilate.ts b/packages/text/src/internal/slug-shaders/slug-dilate.ts index 66e6b7f4..a9721e5a 100644 --- a/packages/text/src/internal/slug-shaders/slug-dilate.ts +++ b/packages/text/src/internal/slug-shaders/slug-dilate.ts @@ -1,6 +1,6 @@ /** Adapted from three-flatland Slug at 2935a89f (MIT). */ import type { Node } from 'three/webgpu'; -import { add, div, dot, mul, normalize, sqrt, sub, vec2 } from 'three/tsl'; +import { add, div, dot, mul, normalize, sqrt, sub, vec2, vec4 } from 'three/tsl'; export interface SlugDilationNodes { readonly position: Node<'vec2'>; @@ -21,14 +21,57 @@ export function slugDilate( const normal = normalize(outwardNormal).toVar('slugDilateNormal'); const homogeneousW = add(dot(mvpRow3.xy, position), mvpRow3.w).toVar('slugDilateW'); const wGradient = dot(mvpRow3.xy, normal).toVar('slugDilateWGradient'); - const projectedX = mul( + return dilateFromProjection( + position, + normal, + textureCoordinate, + inverseScale, + homogeneousW, + wGradient, sub(mul(homogeneousW, dot(mvpRow0.xy, normal)), mul(wGradient, add(dot(mvpRow0.xy, position), mvpRow0.w))), - viewport.x, - ).toVar('slugDilateProjectedX'); - const projectedY = mul( sub(mul(homogeneousW, dot(mvpRow1.xy, normal)), mul(wGradient, add(dot(mvpRow1.xy, position), mvpRow1.w))), - viewport.y, - ).toVar('slugDilateProjectedY'); + viewport, + ); +} + +/** Expand a glyph quad using the exact per-instance model-view-projection matrix selected by a batched draw. */ +export function slugDilateMatrix( + position: Node<'vec2'>, + outwardNormal: Node<'vec2'>, + textureCoordinate: Node<'vec2'>, + inverseScale: Node<'float'>, + modelViewProjection: Node<'mat4'>, + viewport: Node<'vec2'>, +): SlugDilationNodes { + const normal = normalize(outwardNormal).toVar('slugDilateNormal'); + const clipPosition = modelViewProjection.mul(vec4(position, 0, 1)).toVar('slugDilateClipPosition'); + const clipNormal = modelViewProjection.mul(vec4(normal, 0, 0)).toVar('slugDilateClipNormal'); + return dilateFromProjection( + position, + normal, + textureCoordinate, + inverseScale, + clipPosition.w, + clipNormal.w, + sub(mul(clipPosition.w, clipNormal.x), mul(clipNormal.w, clipPosition.x)), + sub(mul(clipPosition.w, clipNormal.y), mul(clipNormal.w, clipPosition.y)), + viewport, + ); +} + +function dilateFromProjection( + position: Node<'vec2'>, + normal: Node<'vec2'>, + textureCoordinate: Node<'vec2'>, + inverseScale: Node<'float'>, + homogeneousW: Node<'float'>, + wGradient: Node<'float'>, + projectedXValue: Node<'float'>, + projectedYValue: Node<'float'>, + viewport: Node<'vec2'>, +): SlugDilationNodes { + const projectedX = mul(projectedXValue, viewport.x).toVar('slugDilateProjectedX'); + const projectedY = mul(projectedYValue, viewport.y).toVar('slugDilateProjectedY'); const squaredW = mul(homogeneousW, homogeneousW).toVar('slugDilateSquaredW'); const wTimesGradient = mul(homogeneousW, wGradient).toVar('slugDilateWTimesGradient'); const projectedLengthSquared = add(mul(projectedX, projectedX), mul(projectedY, projectedY)).toVar( diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index dee3095d..68372630 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -7,10 +7,12 @@ import { FIRST_PARTY_TRANSFORM_BUFFER_ID } from '../internal/render-policy-wire. import { TextEngineRenderPlanView, type RenderPlanTable } from '../internal/render-plan-view.js'; import { bitmap, type BitmapPageData } from '../raster/bitmap-technique.js'; import { msdf, type MsdfData } from '../raster/msdf.js'; +import { slug, type SlugPageData } from '../raster/slug-technique.js'; import { bitmapShader } from './bitmap-shader.js'; import type { ThreeTextEngineCoordinator, ThreeTextEngineResource } from './engine-runtime.js'; import { msdfShader } from './msdf-shader.js'; import { invalidatePboTexture } from './retained-target.js'; +import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; type ScalarArray = Float32Array | Uint32Array | Uint16Array; @@ -32,6 +34,11 @@ interface RetainedResource { readonly referenceId: number; } +interface RetainedSlugPage extends ThreeSlugPageResources { + readonly byteLength: number; + dispose(): void; +} + export interface ThreeTextEnginePlanOwner { readonly drawRoot: THREE.Object3D; objectForTransform(transformId: number): THREE.Object3D; @@ -47,6 +54,7 @@ export class ThreeTextEnginePlanTarget { readonly #resources = new Map(); readonly #bitmapTextures = new Map(); readonly #msdfAtlases = new Map(); + readonly #slugPages = new Map(); readonly #materials = new Map(); readonly #activeTransformIndices = new Set(); readonly #rootInverse = new THREE.Matrix4(); @@ -77,6 +85,7 @@ export class ThreeTextEnginePlanTarget { const data = atlas.image.data as ArrayBufferView | undefined; bytes += data?.byteLength ?? 0; } + for (const page of this.#slugPages.values()) bytes += page.byteLength; return bytes; } @@ -134,9 +143,11 @@ export class ThreeTextEnginePlanTarget { for (const material of this.#materials.values()) material.dispose(); for (const texture of this.#bitmapTextures.values()) texture.dispose(); for (const atlas of this.#msdfAtlases.values()) atlas.dispose(); + for (const page of this.#slugPages.values()) page.dispose(); this.#materials.clear(); this.#bitmapTextures.clear(); this.#msdfAtlases.clear(); + this.#slugPages.clear(); this.#buffers.clear(); this.#resources.clear(); this.#activeTransformIndices.clear(); @@ -419,7 +430,8 @@ export class ThreeTextEnginePlanTarget { const resolved = this.#coordinator.resolveResource(resource.referenceId); if (resolved.technique === bitmap.id) return this.#bitmapMaterial(resource, buffers, materialId); if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId); - throw new Error('this Three plan target checkpoint does not yet realize Slug draws'); + if (resolved.technique === slug.id) return this.#slugMaterial(resource, buffers, materialId); + throw new Error('this Three plan target does not recognize the draw technique'); } #msdfMaterial( @@ -524,6 +536,117 @@ export class ThreeTextEnginePlanTarget { return atlas; } + #slugMaterial( + resource: RetainedResource, + buffers: ReadonlyMap, + materialId: number, + ): THREE.MeshBasicNodeMaterial { + const page = slugPage(this.#coordinator.resolveResource(resource.referenceId)); + const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { + const buffer = buffers.get(id); + if (buffer === undefined) throw new Error(`Slug draw is missing policy buffer ${id}`); + return buffer; + }); + const transformIndices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); + if (transformIndices === undefined) throw new Error('Slug draw is missing its transform-index buffer'); + const key = `slug:${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.id}:${buffer.generation}`) + .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; + let material = this.#materials.get(key); + if (material !== undefined) return material; + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = TSL.instanceIndex.add(runStart); + const floatFields = required + .slice(0, 5) + .map((buffer) => TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance)); + const addresses = TSL.storage(required[5]!.attribute, 'uvec4', required[5]!.attribute.count) + .setPBO(true) + .element(instance); + const counts = TSL.storage(required[6]!.attribute, 'uvec4', required[6]!.attribute.count) + .setPBO(true) + .element(instance); + const transform = indexedTransformNodes(transformIndices.attribute, this.#transformAttribute, instance); + const modelViewProjection = TSL.cameraProjectionMatrix.mul(TSL.modelViewMatrix).mul(transform.matrix); + const viewport = TSL.uniform(new THREE.Vector2(1, 1)).onRenderUpdate(({ renderer }, self) => + renderer?.getDrawingBufferSize(self.value), + ); + const shader = slugShader( + { + origin: floatFields[0]!.xy, + size: floatFields[0]!.zw, + emOrigin: floatFields[1]!.xy, + emSize: floatFields[1]!.zw, + bandTransform: floatFields[2]!, + color: floatFields[3]!, + inverseScale: floatFields[4]!.x, + curveBaseTexel: addresses.x, + horizontalHeaderBase: addresses.y, + verticalHeaderBase: addresses.z, + referenceBase: addresses.w, + horizontalBandCount: counts.x, + verticalBandCount: counts.y, + }, + { + page: this.#slugPage(resource.referenceId, page), + viewport, + modelViewProjection, + }, + ); + material = new THREE.MeshBasicNodeMaterial({ + blending: THREE.NormalBlending, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + }); + material.positionNode = transform.position(shader.position); + material.colorNode = shader.color; + material.opacityNode = shader.opacity; + this.#materials.set(key, material); + return material; + } + + #slugPage(referenceId: number, data: SlugPageData): RetainedSlugPage { + let page = this.#slugPages.get(referenceId); + if (page !== undefined) return page; + const curves = ownedUint16(data.curveBytes); + const headers = ownedUint32(data.headerBytes); + const references = packReferencePairs(ownedUint16(data.referenceBytes), data.referenceWidth); + const curveTexture = dataTexture(curves, data.curveWidth, data.curveHeight, THREE.RGBAFormat, THREE.HalfFloatType); + const headerTexture = dataTexture( + headers, + data.headerWidth, + data.headerHeight, + THREE.RedIntegerFormat, + THREE.UnsignedIntType, + ); + const referenceTexture = dataTexture( + references.data, + references.width, + references.height, + THREE.RedIntegerFormat, + THREE.UnsignedIntType, + ); + page = { + curveTexture, + curveWidth: data.curveWidth, + headerTexture, + headerWidth: data.headerWidth, + referenceTexture, + referenceWidth: references.width, + byteLength: curves.byteLength + headers.byteLength + references.data.byteLength, + dispose() { + curveTexture.dispose(); + headerTexture.dispose(); + referenceTexture.dispose(); + }, + }; + this.#slugPages.set(referenceId, page); + return page; + } + #applyRetirements(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { const layout = textShaperAbi.layouts.engineRetirement; for (let index = 0; index < table.count; index += 1) { @@ -566,6 +689,13 @@ function msdfData(resource: ThreeTextEngineResource): MsdfData { return resource.data; } +function slugPage(resource: ThreeTextEngineResource): SlugPageData { + if (resource.technique !== slug.id || !('page' in resource)) { + throw new Error('Three Slug draw references an incompatible resource'); + } + return resource.page as SlugPageData; +} + function scalarArray(scalarType: number, byteLength: number): ScalarArray { const scalar = textShaperAbi.policy.scalarTypes; if (scalarType === scalar.f32) return new Float32Array(byteLength / 4); @@ -593,16 +723,72 @@ function indexedTransformPosition( transforms: THREE.StorageInstancedBufferAttribute, instance: THREE.Node<'uint'>, ): THREE.Node<'vec3'> { + return indexedTransformNodes(indexAttribute, transforms, instance).position(position); +} + +function indexedTransformNodes( + indexAttribute: THREE.StorageInstancedBufferAttribute, + transforms: THREE.StorageInstancedBufferAttribute, + instance: THREE.Node<'uint'>, +): { + readonly matrix: THREE.Node<'mat4'>; + readonly position: (position: THREE.Node<'vec3'>) => THREE.Node<'vec3'>; +} { const transformIndex = TSL.storage(indexAttribute, 'uint', indexAttribute.count).setPBO(true).element(instance); const firstColumn = transformIndex.mul(4); const table = TSL.storage(transforms, 'vec4', transforms.count).setPBO(true); - const local = TSL.vec4(position, 1); - return table - .element(firstColumn) - .mul(local.x) - .add(table.element(firstColumn.add(1)).mul(local.y)) - .add(table.element(firstColumn.add(2)).mul(local.z)) - .add(table.element(firstColumn.add(3)).mul(local.w)).xyz; + const column0 = table.element(firstColumn); + const column1 = table.element(firstColumn.add(1)); + const column2 = table.element(firstColumn.add(2)); + const column3 = table.element(firstColumn.add(3)); + return { + matrix: TSL.mat4(column0, column1, column2, column3), + position(position) { + const local = TSL.vec4(position, 1); + return column0.mul(local.x).add(column1.mul(local.y)).add(column2.mul(local.z)).add(column3.mul(local.w)).xyz; + }, + }; +} + +function dataTexture( + data: Uint16Array | Uint32Array, + width: number, + height: number, + format: THREE.PixelFormat, + type: THREE.TextureDataType, +): THREE.DataTexture { + const texture = new THREE.DataTexture(data, width, height, format, type); + texture.colorSpace = THREE.NoColorSpace; + texture.flipY = false; + texture.generateMipmaps = false; + texture.minFilter = THREE.NearestFilter; + texture.magFilter = THREE.NearestFilter; + texture.needsUpdate = true; + return texture; +} + +function ownedUint16(bytes: Uint8Array): Uint16Array { + const copy = bytes.slice(); + return new Uint16Array(copy.buffer, copy.byteOffset, copy.byteLength / 2); +} + +function ownedUint32(bytes: Uint8Array): Uint32Array { + const copy = bytes.slice(); + return new Uint32Array(copy.buffer, copy.byteOffset, copy.byteLength / 4); +} + +function packReferencePairs( + references: Uint16Array, + preferredWidth: number, +): { readonly data: Uint32Array; readonly width: number; readonly height: number } { + const texelCount = Math.ceil(references.length / 2); + const width = Math.min(preferredWidth, texelCount); + const height = Math.ceil(texelCount / width); + const data = new Uint32Array(width * height); + for (let index = 0; index < references.length; index += 1) { + data[index >> 1] = (data[index >> 1] ?? 0) | (references[index]! << ((index & 1) * 16)); + } + return { data, width, height }; } function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: number, firstPatch: boolean): void { diff --git a/packages/text/src/three/slug-shader.ts b/packages/text/src/three/slug-shader.ts index 45c02fb9..a91fefab 100644 --- a/packages/text/src/three/slug-shader.ts +++ b/packages/text/src/three/slug-shader.ts @@ -1,7 +1,7 @@ import * as TSL from 'three/tsl'; import type { DataTexture, Node } from 'three/webgpu'; -import { slugDilate, slugRender, type SlugRenderOptions } from '../internal/slug-shaders/index.js'; +import { slugDilate, slugDilateMatrix, slugRender, type SlugRenderOptions } from '../internal/slug-shaders/index.js'; /** * One glyph instance's canonical Slug fields, already resolved to nodes. The address and count fields locate the @@ -52,16 +52,30 @@ export interface ThreeSlugFillRule { * The GPU resources one Slug glyph batch binds. The clip-space rows and viewport drive the analytic half-pixel * dilation, so they must describe the same draw the returned position node feeds. */ -export interface ThreeSlugShaderResources { +interface ThreeSlugShaderResourceBase { readonly page: ThreeSlugPageResources; /** Drawing-buffer size in device pixels. */ readonly viewport: Node<'vec2'>; + readonly fillRule?: ThreeSlugFillRule; +} + +interface ThreeSlugShaderRowResources extends ThreeSlugShaderResourceBase { readonly modelViewProjectionRow0: Node<'vec4'>; readonly modelViewProjectionRow1: Node<'vec4'>; readonly modelViewProjectionRow3: Node<'vec4'>; - readonly fillRule?: ThreeSlugFillRule; + readonly modelViewProjection?: never; } +interface ThreeSlugShaderMatrixResources extends ThreeSlugShaderResourceBase { + /** Exact MVP selected per glyph when a renderer batches multiple model transforms into one draw. */ + readonly modelViewProjection: Node<'mat4'>; + readonly modelViewProjectionRow0?: never; + readonly modelViewProjectionRow1?: never; + readonly modelViewProjectionRow3?: never; +} + +export type ThreeSlugShaderResources = ThreeSlugShaderRowResources | ThreeSlugShaderMatrixResources; + /** * Everything the canonical Slug graph produces, so a program can consume a stage or compose over its final output. * @@ -107,16 +121,26 @@ export function slugShader( instance.emOrigin.x.add(TSL.positionLocal.x.mul(instance.emSize.x)), instance.emOrigin.y.sub(TSL.positionLocal.y.mul(instance.emSize.y)), ); - const dilated = slugDilate( - localPosition, - outwardNormal, - emCoordinate, - instance.inverseScale, - resources.modelViewProjectionRow0, - resources.modelViewProjectionRow1, - resources.modelViewProjectionRow3, - resources.viewport, - ); + const dilated = + resources.modelViewProjection === undefined + ? slugDilate( + localPosition, + outwardNormal, + emCoordinate, + instance.inverseScale, + resources.modelViewProjectionRow0, + resources.modelViewProjectionRow1, + resources.modelViewProjectionRow3, + resources.viewport, + ) + : slugDilateMatrix( + localPosition, + outwardNormal, + emCoordinate, + instance.inverseScale, + resources.modelViewProjection, + resources.viewport, + ); renderCoordinate.assign(dilated.textureCoordinate); return TSL.vec3(dilated.position.x, dilated.position.y, 0); })(); diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index fa8d175a..30d91825 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -4,10 +4,12 @@ import test from 'node:test'; import { gunzipSync } from 'node:zlib'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; +import { read as readKtx2 } from 'ktx-parse'; import * as THREE from 'three/webgpu'; import { validateBitmapArtifact } from '../../dist/bakers/bitmap-validator.js'; import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; +import { validateSlugArtifact } from '../../dist/bakers/slug-validator.js'; import { textShaperAbi } from '../../dist/generated/text-shaper-abi.js'; import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; import { TextEngineRenderPlanView } from '../../dist/internal/render-plan-view.js'; @@ -15,6 +17,7 @@ import { FontRegistry } from '../../dist/loader.js'; import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; import { defineRasterResourceId } from '../../dist/raster-technique.js'; +import { slug, slugDescriptor } from '../../dist/raster/slug-technique.js'; import { createRuntimeShaper } from '../../dist/shaper.js'; import { ThreeTextEngineCoordinator } from '../../dist/three/engine-runtime.js'; import { ThreeTextEnginePlanTarget } from '../../dist/three/engine-plan-target.js'; @@ -23,17 +26,21 @@ const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', i const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); test('Three coordinator shares shaping data across technique bindings and reference-counts stack handles', async () => { - const [bitmapBytes, compressedMsdf, wasm] = await Promise.all([ + const [bitmapBytes, compressedMsdf, compressedSlug, wasm] = await Promise.all([ readFile(new URL('inter-bitmap-16.font.glb', fixtureRoot)), readFile(new URL('inter-mtsdf.font.glb.gz', fixtureRoot)), + readFile(new URL('inter-slug.font.glb.gz', fixtureRoot)), readFile(wasmUrl), ]); const msdfBytes = gunzipSync(compressedMsdf); - const [bitmapCore, msdfCore] = await Promise.all([ + const slugBytes = gunzipSync(compressedSlug); + const [bitmapCore, msdfCore, slugCore] = await Promise.all([ validateFontArtifact(bitmapBytes), validateFontArtifact(msdfBytes), + validateFontArtifact(slugBytes), ]); assert.equal(bitmapCore.shapingHash, msdfCore.shapingHash); + assert.equal(bitmapCore.shapingHash, slugCore.shapingHash); const registry = new FontRegistry(); const registered = await registry.registerAsset(bitmapBytes); const shaper = await createRuntimeShaper({ registry, wasm }); @@ -52,6 +59,13 @@ test('Three coordinator shares shaping data across technique bindings and refere glyphCount: msdfCore.glyphCount, glyphIdWidth: 16, }); + const slugRaster = await validateSlugArtifact(slugBytes, { + descriptor: slugDescriptor(), + rasterKey: slugCore.document.extensions.PMNDRS_font.rasters[0].rasterKey, + shapingHash: slugCore.shapingHash, + glyphCount: slugCore.glyphCount, + glyphIdWidth: 16, + }); const bitmapFont = { runtime: undefined, font: registered, @@ -91,6 +105,33 @@ test('Three coordinator shares shaping data across technique bindings and refere }, disposed: false, }; + const slugExtension = slugRaster.document.extensions.PMNDRS_font_slug; + const slugFont = { + runtime: undefined, + font: registered, + technique: slug, + raster: undefined, + data: { + planeUnitsPerEm: slugExtension.planeUnitsPerEm, + records: slugRaster.records, + pages: slugRaster.pages.map((page, pageIndex) => ({ + resource: defineRasterResourceId(`coordinator.slug.${pageIndex}`), + curveWidth: page.curveWidth, + curveHeight: page.curveHeight, + curveBytes: readKtx2(page.curve.bytes).levels[0].levelData.slice(), + headerCount: page.headerCount, + headerWidth: page.headerWidth, + headerHeight: page.headerHeight, + headerBytes: page.headers.bytes.slice(), + referenceCount: page.referenceCount, + referenceWidth: page.referenceWidth, + referenceHeight: page.referenceHeight, + referenceBytes: page.references.bytes.slice(), + })), + bindings: [], + }, + disposed: false, + }; const coordinator = new ThreeTextEngineCoordinator({ shaper }); const first = coordinator.acquireFontStack([bitmapFont, msdfFont]); const bitmapReference = coordinator.host.wireIdentities.resolve(bitmapFont.data.strikes[0].pages[0].resource); @@ -99,6 +140,7 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.equal(coordinator.resolveResource(msdfReference).technique, msdf.id); const shared = coordinator.acquireFontStack([bitmapFont, msdfFont]); const reversed = coordinator.acquireFontStack([msdfFont, bitmapFont]); + const slugFirst = coordinator.acquireFontStack([slugFont, msdfFont, bitmapFont]); assert.equal(shared.handle, first.handle); assert.notEqual(reversed.handle, first.handle, 'fallback order is part of stack identity'); const session = coordinator.createSession({ requestCapacity: 4_096, resultCapacity: 1024 * 1024, textCapacity: 16 }); @@ -474,6 +516,54 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); } assert.ok(target.gpuBytes > bitmapGpuBytes, 'MSDF atlas residency is included in command-buffer accounting'); + const msdfGpuBytes = target.gpuBytes; + const slugPublication = session.update( + compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: msdfPublication.engineRevision, + consumedPlanRevision: msdfPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [2, 1].map((paragraphId) => ({ + opcode: 'upsert', + paragraphId, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: slugFirst.handle, + materialId: 7, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + })), + }), + ); + const slugPlan = plan.bind(slugPublication); + const slugDraws = slugPlan.table('draws'); + assert.equal(slugDraws.count, 1); + assert.equal(slugPlan.u32(slugPlan.record(slugDraws, 0) + drawLayout.programId), 3); + target.apply(slugPublication); + assert.equal(target.draws.length, 1); + assert.equal(target.draws[0].geometry.instanceCount, 6); + for (const policyBufferId of [1, 2, 3, 4, 5, 6, 7, 15]) { + assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); + } + assert.ok(target.gpuBytes > msdfGpuBytes, 'Slug page residency is included in command-buffer accounting'); assert.ok(target.gpuBytes > 0); target.dispose(); session.dispose(); @@ -487,6 +577,7 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.notEqual(replacement.handle, first.handle, 'a retired stack handle is not immediately reused'); replacement.release(); reversed.release(); + slugFirst.release(); coordinator.dispose(); assert.throws(() => coordinator.acquireFontStack([bitmapFont]), /disposed/); shaper.dispose(); From d5142e30744e3ad40f8096c2c1986adb0cd46bcb Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 00:27:33 -0400 Subject: [PATCH 070/128] feat(text): resolve Three material factories --- docs/log.md | 9 ++ docs/packages/text.md | 11 +- docs/planning/decision-register.md | 2 + docs/planning/three-material-authority.md | 65 +++++++---- packages/text/src/three.ts | 2 + packages/text/src/three/engine-plan-target.ts | 109 ++++++++++++------ packages/text/src/three/engine-runtime.ts | 48 ++++++++ packages/text/src/three/material.ts | 36 ++++++ .../integration/three-engine-runtime.test.mjs | 40 +++++-- .../text/tests/types/three-shader-api.test.ts | 12 ++ 10 files changed, 268 insertions(+), 66 deletions(-) create mode 100644 packages/text/src/three/material.ts diff --git a/docs/log.md b/docs/log.md index 08c27916..916fca46 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,14 @@ # pmndrs/text documentation update log +## 2026-08-09 + +- **Carried Three material factories through Rust material IDs** — Added the public factory definition and a runtime- + scoped identity registry while keeping Rust callback-free. The executor resolves each nonzero `materialId` only when + a compatible realization is absent and supplies the canonical technique shader, final indexed-transform position, + and a DRY default-material constructor. A compiled-Wasm fixture proves two material draws over shared storage, zero + new factory calls on reorder/coalescing, and one new selected-factory call when the retained glyphs switch to MSDF and + Slug. The public `Text` property route and fence-bounded retirement remain cutover work. + ## 2026-08-08 - **Executed Slug from the Rust command buffer** — Bound Rust's five float `vec4`, two integer `uvec4`, and indexed- diff --git a/docs/packages/text.md b/docs/packages/text.md index e61433bd..ccb13b60 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:b1cb33af564181b940c763b7f3d640b4f9264c0cef5da564afed61f486c906ae' +source_digest: 'sha256:c78cc5ab00af32f4455e0fa37790706b1f080e75f43a0e2309ee84b7ba98e0f1' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -894,6 +894,15 @@ program-3 draw without resending text or geometry. Browser shader compilation/pi material factories, public cutover, and end-to-end latency remain open. The optimized shaper remains 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes; all 129 Rust tests and the focused compiled-Wasm/Three integration test pass. +Three material definitions now have one public construction function and one runtime-scoped numeric identity registry. +The executor resolves Rust's `materialId` to a factory only when a compatible technique/program/resource material is +missing. The context carries the exact canonical shader output, the final renderer-local position after indexed +transforms, and a function that constructs the canonical default material. Factories must return fresh `NodeMaterial` +instances and Three owns their disposal. The compiled-Wasm fixture proves distinct material draws share physical glyph +storage, reorder and coalescing reuse cached materials, and the same selected factory is instantiated once for each of +Bitmap, MSDF, and Slug. The `TextGroup`/`Text`/span property route and fence-bounded retirement remain part of public +cutover, so this checkpoint does not claim that authored materials are available from `Text` yet. + The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 3e9f5c0c..a3a498db 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -296,6 +296,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-218 | Slug command-buffer realization consumes five Rust-packed `vec4` streams, two `uvec4` streams, and the shared transform-index stream without TypeScript glyph repacking. The executor resolves each validated analytic page once and preserves the WebGL-compatible packed-reference texture. Indexed batching applies one per-instance matrix to both final vertex placement and Slug's half-pixel dilation: the canonical shader accepts an exact full MVP and derives clip-position and clip-normal gradients without matrix-row extraction. The legacy row interface remains available for existing targets. A compiled-Wasm fixture changes the same two retained paragraphs from Bitmap to MSDF to Slug without resending text or geometry and observes one six-instance program-3 draw with policy buffers 1–7 and 15. Wasm bytes do not change because this slice is renderer-only. Live shader compilation/pixels, material factories, retirement-bounded caches, and public `Text` cutover remain open. | Accepted | +| D-219 | Three material authority uses one public `defineTextMaterial(create)` definition interned by object identity into a nonzero `u32 materialId`; zero remains the canonical default. Rust treats the ID only as policy-directed draw compatibility and never invokes the factory. The Three factory receives a technique-discriminated canonical shader output, the exact renderer-local position after transform indirection, and a DRY `createDefaultMaterial()`. It runs once per compatible material/program/resource realization, must return a fresh `NodeMaterial`, and the adapter owns disposal. A compiled-Wasm fixture proves two IDs split draws over shared storage, reorder/coalescing causes no extra calls, and changing retained glyphs from Bitmap to MSDF to Slug invokes the selected factory once for each new technique realization. Public `TextGroup`/`Text`/span material properties and renderer-fence cache retirement remain cutover work. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/docs/planning/three-material-authority.md b/docs/planning/three-material-authority.md index 7de412f9..ebe65c62 100644 --- a/docs/planning/three-material-authority.md +++ b/docs/planning/three-material-authority.md @@ -3,7 +3,7 @@ type: Research Concept title: Three material authority for text draws description: Defines user-owned material factories carried from text and span properties through numeric Rust render-plan material identities. documentation_type: reference -status: draft +status: stable tags: [planning, threejs, tsl, materials, render-plan] sources: - id: three-api @@ -40,27 +40,36 @@ This boundary keeps layout and shaping renderer-neutral. Rust never stores a Jav or interprets a Three material. It uses `material_id` only as policy-directed draw compatibility data and writes it into the fixed render-plan draw record as `materialId`. -## Provisional public shape +## Public construction shape ```ts -interface ThreeTextMaterialContext { - /** The package-owned canonical Bitmap, MTSDF, or Slug shader for this exact technique. */ - readonly shader: Shader; - /** Exact resource and instance accessors for the draw being realized. */ - readonly resource: ThreeRasterResourceContextOf; - readonly instance: ThreeRasterInstanceContextOf; - /** Builds the same default material used when no application material is supplied. */ - createDefaultMaterial(): ThreeRasterMaterialOf; +type ThreeTextMaterialContext = + | { + readonly technique: 'pmndrs.bitmap'; + readonly shader: ThreeBitmapShaderOutput; + readonly position: Node<'vec3'>; + createDefaultMaterial(): THREE.NodeMaterial; + } + | { + readonly technique: 'pmndrs.msdf'; + readonly shader: ThreeMsdfShaderOutput; + readonly position: Node<'vec3'>; + createDefaultMaterial(): THREE.NodeMaterial; + } + | { + readonly technique: 'pmndrs.slug'; + readonly shader: ThreeSlugShaderOutput; + readonly position: Node<'vec3'>; + createDefaultMaterial(): THREE.NodeMaterial; + }; + +interface ThreeTextMaterial { + create(context: ThreeTextMaterialContext): THREE.NodeMaterial; } -interface ThreeTextMaterial { - create(context: ThreeTextMaterialContext): THREE.NodeMaterial; -} - -declare function defineTextMaterial( - shader: Shader, - create: (context: ThreeTextMaterialContext) => THREE.NodeMaterial, -): ThreeTextMaterial; +declare function defineTextMaterial( + create: (context: ThreeTextMaterialContext) => THREE.NodeMaterial, +): ThreeTextMaterial; interface TextGroupOptions { readonly material?: ThreeTextMaterial; @@ -75,16 +84,18 @@ interface TextSpan { } ``` -The exact conditional types remain technique-specific in the public declaration. The abbreviated aliases above state -ownership, not a new universal shader context. +`position` is the exact renderer-local position after policy-selected transform indirection. This is distinct from the +canonical shader's paragraph-local position and prevents a custom material from accidentally bypassing indexed +transforms. The discriminant narrows the exact Bitmap, MSDF, or Slug shader output without a universal union record in +Rust or Wasm. ```ts -const etched = defineTextMaterial(slugShader, ({ shader, resource, instance }) => { - const raster = shader({ resource, instance }); +const etched = defineTextMaterial(({ technique, shader, position }) => { + if (technique !== 'pmndrs.slug') return createFallbackMaterial({ shader, position }); const material = new THREE.MeshStandardNodeMaterial({ transparent: true }); - material.positionNode = raster.position; - material.colorNode = mix(raster.color, sheen, raster.coverage); - material.opacityNode = raster.opacity; + material.positionNode = position; + material.colorNode = mix(shader.color, sheen, shader.coverage); + material.opacityNode = shader.opacity; return material; }); @@ -97,6 +108,10 @@ placement, coverage, color, and opacity nodes. Creating another `NodeMaterial` i shadows, depth writes/tests, and other standard Three behavior. Neither path may replace or duplicate the technique's glyph coverage algorithm unless the application registers a complete custom raster program. +The construction function, runtime-scoped identity registry, and command-buffer executor are implemented. The public +`TextGroup`/`Text`/span `material` properties land with the atomic Rust-session cutover; until then callers cannot yet +author a material through those objects even though the executor route is tested. + ## Identity and render-plan route The Three integration interns each live `ThreeTextMaterial` by object identity and assigns a nonzero `u32 material_id`; diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 3db3c1b2..d3b693ec 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -19,6 +19,8 @@ export type { ThreeBitmapShaderResources, } from './three/bitmap-shader.js'; export { FontLoader } from './three/font-loader.js'; +export { defineTextMaterial } from './three/material.js'; +export type { ThreeTextMaterial, ThreeTextMaterialContext } from './three/material.js'; export { msdfShader } from './three/msdf-shader.js'; export type { ThreeMsdfInstanceNodes, ThreeMsdfShaderOutput, ThreeMsdfShaderResources } from './three/msdf-shader.js'; export { registerThreeRasterProgram } from './three/program-registry.js'; diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 68372630..bcb139b9 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -13,6 +13,7 @@ import type { ThreeTextEngineCoordinator, ThreeTextEngineResource } from './engi import { msdfShader } from './msdf-shader.js'; import { invalidatePboTexture } from './retained-target.js'; import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; +import type { ThreeTextMaterialContext } from './material.js'; type ScalarArray = Float32Array | Uint32Array | Uint16Array; @@ -55,7 +56,8 @@ export class ThreeTextEnginePlanTarget { readonly #bitmapTextures = new Map(); readonly #msdfAtlases = new Map(); readonly #slugPages = new Map(); - readonly #materials = new Map(); + readonly #materials = new Map(); + readonly #ownedMaterials = new WeakSet(); readonly #activeTransformIndices = new Set(); readonly #rootInverse = new THREE.Matrix4(); readonly #relativeTransform = new THREE.Matrix4(); @@ -364,7 +366,7 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, - ): THREE.MeshBasicNodeMaterial { + ): THREE.NodeMaterial { const resolved = this.#coordinator.resolveResource(resource.referenceId); if (resolved.technique !== bitmap.id) { throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); @@ -403,21 +405,18 @@ export class ThreeTextEnginePlanTarget { }, { page: texture }, ); - material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = indexedTransformPosition( + const position = indexedTransformPosition( shader.position, transformIndices.attribute, this.#transformAttribute, instance, ); - material.vertexNode = shader.clipPosition; - material.colorNode = shader.color; - material.opacityNode = shader.opacity; + material = this.#createMaterial(materialId, { + technique: bitmap.id, + shader, + position, + createDefaultMaterial: () => bitmapMaterial(shader, position), + }); this.#materials.set(key, material); return material; } @@ -426,7 +425,7 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, - ): THREE.MeshBasicNodeMaterial { + ): THREE.NodeMaterial { const resolved = this.#coordinator.resolveResource(resource.referenceId); if (resolved.technique === bitmap.id) return this.#bitmapMaterial(resource, buffers, materialId); if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId); @@ -438,7 +437,7 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, - ): THREE.MeshBasicNodeMaterial { + ): THREE.NodeMaterial { const data = msdfData(this.#coordinator.resolveResource(resource.referenceId)); const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { const buffer = buffers.get(id); @@ -480,20 +479,18 @@ export class ThreeTextEnginePlanTarget { pixelRange: data.pixelRange, }, ); - material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = indexedTransformPosition( + const position = indexedTransformPosition( shader.position, transformIndices.attribute, this.#transformAttribute, instance, ); - material.colorNode = shader.color; - material.opacityNode = shader.opacity; + material = this.#createMaterial(materialId, { + technique: msdf.id, + shader, + position, + createDefaultMaterial: () => coverageMaterial(shader, position), + }); this.#materials.set(key, material); return material; } @@ -540,7 +537,7 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, - ): THREE.MeshBasicNodeMaterial { + ): THREE.NodeMaterial { const page = slugPage(this.#coordinator.resolveResource(resource.referenceId)); const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { const buffer = buffers.get(id); @@ -594,16 +591,13 @@ export class ThreeTextEnginePlanTarget { modelViewProjection, }, ); - material = new THREE.MeshBasicNodeMaterial({ - blending: THREE.NormalBlending, - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, + const position = transform.position(shader.position); + material = this.#createMaterial(materialId, { + technique: slug.id, + shader, + position, + createDefaultMaterial: () => coverageMaterial(shader, position), }); - material.positionNode = transform.position(shader.position); - material.colorNode = shader.color; - material.opacityNode = shader.opacity; this.#materials.set(key, material); return material; } @@ -647,6 +641,18 @@ export class ThreeTextEnginePlanTarget { return page; } + #createMaterial(materialId: number, context: ThreeTextMaterialContext): THREE.NodeMaterial { + const definition = this.#coordinator.resolveMaterial(materialId); + const material = definition?.create(context) ?? context.createDefaultMaterial(); + if (material?.isNodeMaterial !== true) + throw new TypeError('text material factory must return a Three NodeMaterial'); + if (this.#ownedMaterials.has(material)) { + throw new TypeError('text material factory must return a fresh unowned NodeMaterial'); + } + this.#ownedMaterials.add(material); + return material; + } + #applyRetirements(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { const layout = textShaperAbi.layouts.engineRetirement; for (let index = 0; index < table.count; index += 1) { @@ -675,6 +681,43 @@ export class ThreeTextEnginePlanTarget { } } +function bitmapMaterial( + shader: Readonly<{ + clipPosition: THREE.Node<'vec4'>; + color: THREE.Node<'vec3'>; + opacity: THREE.Node<'float'>; + }>, + position: THREE.Node<'vec3'>, +): THREE.MeshBasicNodeMaterial { + const material = baseTextMaterial(); + material.positionNode = position; + material.vertexNode = shader.clipPosition; + material.colorNode = shader.color; + material.opacityNode = shader.opacity; + return material; +} + +function coverageMaterial( + shader: Readonly<{ color: THREE.Node<'vec3'>; opacity: THREE.Node<'float'> }>, + position: THREE.Node<'vec3'>, +): THREE.MeshBasicNodeMaterial { + const material = baseTextMaterial(); + material.positionNode = position; + material.colorNode = shader.color; + material.opacityNode = shader.opacity; + return material; +} + +function baseTextMaterial(): THREE.MeshBasicNodeMaterial { + return new THREE.MeshBasicNodeMaterial({ + blending: THREE.NormalBlending, + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + }); +} + function bitmapPage(resource: ThreeTextEngineResource): BitmapPageData { if (resource.technique !== bitmap.id || !('page' in resource)) { throw new Error('this Three plan target checkpoint realizes Bitmap draws only'); diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts index a4dde31d..6198823d 100644 --- a/packages/text/src/three/engine-runtime.ts +++ b/packages/text/src/three/engine-runtime.ts @@ -7,6 +7,7 @@ import type { TextRuntime } from '../text-runtime.js'; import { firstPartyFontBindingBytes } from '../internal/font-binding-wire.js'; import { firstPartyThreeRenderPolicyBytes } from '../internal/render-policy-wire.js'; import { TextEngineHost, type TextEngineSession, type TextEngineSessionOptions } from '../internal/text-engine-host.js'; +import type { ThreeTextMaterial } from './material.js'; const POLICY_HANDLE = 1; const MAX_U32 = 0xffff_ffff; @@ -17,11 +18,22 @@ export interface ThreeTextEngineStackLease { release(): void; } +export interface ThreeTextMaterialLease { + readonly id: number; + release(): void; +} + interface RetainedStack { readonly handle: number; references: number; } +interface RetainedMaterial { + readonly id: number; + readonly material: ThreeTextMaterial; + references: number; +} + export type ThreeTextEngineResource = | Readonly<{ technique: typeof bitmap.id; page: BitmapPageData }> | Readonly<{ technique: typeof msdf.id; data: MsdfData }> @@ -33,9 +45,12 @@ export class ThreeTextEngineCoordinator { readonly #bindingHandles = new WeakMap, number>(); readonly #resources = new Map(); readonly #stacks = new Map(); + readonly #materialHandles = new WeakMap(); + readonly #materials = new Map(); #nextBindingHandle = 1; #nextStackHandle = 1; #nextSessionHandle = 1; + #nextMaterialHandle = 1; #disposed = false; constructor(runtime: Pick) { @@ -79,6 +94,34 @@ export class ThreeTextEngineCoordinator { return this.host.createSession({ ...options, handle: this.#allocateSessionHandle() }); } + acquireMaterial(material: ThreeTextMaterial): ThreeTextMaterialLease { + this.#assertActive(); + let retained = this.#materialHandles.get(material); + if (retained === undefined || retained.references === 0) { + retained = { id: this.#allocateMaterialHandle(), material, references: 0 }; + this.#materialHandles.set(material, retained); + this.#materials.set(retained.id, retained); + } + retained.references += 1; + let released = false; + return { + id: retained.id, + release: () => { + if (released) return; + released = true; + retained.references -= 1; + if (retained.references === 0) this.#materials.delete(retained.id); + }, + }; + } + + resolveMaterial(materialId: number): ThreeTextMaterial | undefined { + if (materialId === 0) return undefined; + const retained = this.#materials.get(materialId); + if (retained === undefined) throw new Error(`Three text command buffer references unknown material ${materialId}`); + return retained.material; + } + resolveResource(referenceId: number): ThreeTextEngineResource { const resource = this.#resources.get(referenceId); if (resource === undefined) throw new Error(`Three text command buffer references unknown resource ${referenceId}`); @@ -89,6 +132,7 @@ export class ThreeTextEngineCoordinator { if (this.#disposed) return; this.host.dispose(); this.#stacks.clear(); + this.#materials.clear(); this.#disposed = true; } @@ -145,6 +189,10 @@ export class ThreeTextEngineCoordinator { return allocateHandle(this.#nextSessionHandle, (next) => (this.#nextSessionHandle = next), 'text session'); } + #allocateMaterialHandle(): number { + return allocateHandle(this.#nextMaterialHandle, (next) => (this.#nextMaterialHandle = next), 'material'); + } + #assertActive(): void { if (this.#disposed) throw new Error('Three text engine coordinator is disposed'); } diff --git a/packages/text/src/three/material.ts b/packages/text/src/three/material.ts new file mode 100644 index 00000000..c26330b4 --- /dev/null +++ b/packages/text/src/three/material.ts @@ -0,0 +1,36 @@ +import type { Node, NodeMaterial } from 'three/webgpu'; + +import type { ThreeBitmapShaderOutput } from './bitmap-shader.js'; +import type { ThreeMsdfShaderOutput } from './msdf-shader.js'; +import type { ThreeSlugShaderOutput } from './slug-shader.js'; + +export type ThreeTextMaterialContext = + | Readonly<{ + technique: 'pmndrs.bitmap'; + shader: ThreeBitmapShaderOutput; + /** Final renderer-local position including policy-selected transform indirection. */ + position: Node<'vec3'>; + createDefaultMaterial(): NodeMaterial; + }> + | Readonly<{ + technique: 'pmndrs.msdf'; + shader: ThreeMsdfShaderOutput; + position: Node<'vec3'>; + createDefaultMaterial(): NodeMaterial; + }> + | Readonly<{ + technique: 'pmndrs.slug'; + shader: ThreeSlugShaderOutput; + position: Node<'vec3'>; + createDefaultMaterial(): NodeMaterial; + }>; + +export interface ThreeTextMaterial { + create(context: ThreeTextMaterialContext): NodeMaterial; +} + +/** Define one renderer-owned material factory carried through Rust as a numeric `materialId`. */ +export function defineTextMaterial(create: ThreeTextMaterial['create']): ThreeTextMaterial { + if (typeof create !== 'function') throw new TypeError('text material create must be a function'); + return Object.freeze({ create }); +} diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 30d91825..bc02655b 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -21,6 +21,7 @@ import { slug, slugDescriptor } from '../../dist/raster/slug-technique.js'; import { createRuntimeShaper } from '../../dist/shaper.js'; import { ThreeTextEngineCoordinator } from '../../dist/three/engine-runtime.js'; import { ThreeTextEnginePlanTarget } from '../../dist/three/engine-plan-target.js'; +import { defineTextMaterial } from '../../dist/three/material.js'; const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', import.meta.url); const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); @@ -133,6 +134,21 @@ test('Three coordinator shares shaping data across technique bindings and refere disposed: false, }; const coordinator = new ThreeTextEngineCoordinator({ shaper }); + const materialCalls = []; + const primaryMaterial = coordinator.acquireMaterial( + defineTextMaterial((context) => { + materialCalls.push(`primary:${context.technique}`); + const material = context.createDefaultMaterial(); + material.depthTest = true; + return material; + }), + ); + const secondaryMaterial = coordinator.acquireMaterial( + defineTextMaterial((context) => { + materialCalls.push(`secondary:${context.technique}`); + return context.createDefaultMaterial(); + }), + ); const first = coordinator.acquireFontStack([bitmapFont, msdfFont]); const bitmapReference = coordinator.host.wireIdentities.resolve(bitmapFont.data.strikes[0].pages[0].resource); const msdfReference = coordinator.host.wireIdentities.resolve(msdfFont.data.resource); @@ -181,7 +197,7 @@ test('Three coordinator shares shaping data across technique bindings and refere root: true, value: { fontStackHandle: first.handle, - materialId: 7, + materialId: primaryMaterial.id, fontSize: 16, rasterPixelRatio: 1, foregroundRgba: 0xffff_ffff, @@ -197,7 +213,7 @@ test('Three coordinator shares shaping data across technique bindings and refere root: true, value: { fontStackHandle: first.handle, - materialId: 8, + materialId: secondaryMaterial.id, fontSize: 16, rasterPixelRatio: 1, foregroundRgba: 0xffff_ffff, @@ -299,7 +315,7 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.equal(draws.count, 2, 'cluster identity must not split compatible paragraph draws'); assert.deepEqual( adjacentMaterialGroups(plan, draws, drawLayout.materialId), - [7, 8], + [primaryMaterial.id, secondaryMaterial.id], 'Rust gathers child paragraphs into one ordered command buffer', ); assert.deepEqual( @@ -325,6 +341,8 @@ test('Three coordinator shares shaping data across technique bindings and refere }); target.apply(publication); assert.equal(target.draws.length, 2); + assert.equal(target.draws[0].material.depthTest, true); + assert.deepEqual(materialCalls, ['primary:pmndrs.bitmap', 'secondary:pmndrs.bitmap']); assert.deepEqual( target.draws.map((draw) => draw.parent), [drawRoot, drawRoot], @@ -394,7 +412,7 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.equal(reorderedDraws.count, 2); assert.deepEqual( adjacentMaterialGroups(reorderedPlan, reorderedDraws, drawLayout.materialId), - [8, 7], + [secondaryMaterial.id, primaryMaterial.id], 'lifecycle-only reorder retains both paragraphs and changes shared draw order', ); assert.deepEqual( @@ -446,7 +464,7 @@ test('Three coordinator shares shaping data across technique bindings and refere root: true, value: { fontStackHandle: first.handle, - materialId: 7, + materialId: primaryMaterial.id, fontSize: 16, rasterPixelRatio: 1, foregroundRgba: 0xffff_ffff, @@ -497,7 +515,7 @@ test('Three coordinator shares shaping data across technique bindings and refere root: true, value: { fontStackHandle: reversed.handle, - materialId: 7, + materialId: primaryMaterial.id, fontSize: 16, rasterPixelRatio: 1, foregroundRgba: 0xffff_ffff, @@ -545,7 +563,7 @@ test('Three coordinator shares shaping data across technique bindings and refere root: true, value: { fontStackHandle: slugFirst.handle, - materialId: 7, + materialId: primaryMaterial.id, fontSize: 16, rasterPixelRatio: 1, foregroundRgba: 0xffff_ffff, @@ -564,6 +582,12 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); } assert.ok(target.gpuBytes > msdfGpuBytes, 'Slug page residency is included in command-buffer accounting'); + assert.deepEqual(materialCalls, [ + 'primary:pmndrs.bitmap', + 'secondary:pmndrs.bitmap', + 'primary:pmndrs.msdf', + 'primary:pmndrs.slug', + ]); assert.ok(target.gpuBytes > 0); target.dispose(); session.dispose(); @@ -578,6 +602,8 @@ test('Three coordinator shares shaping data across technique bindings and refere replacement.release(); reversed.release(); slugFirst.release(); + primaryMaterial.release(); + secondaryMaterial.release(); coordinator.dispose(); assert.throws(() => coordinator.acquireFontStack([bitmapFont]), /disposed/); shaper.dispose(); diff --git a/packages/text/tests/types/three-shader-api.test.ts b/packages/text/tests/types/three-shader-api.test.ts index 00cd6f24..72c75c14 100644 --- a/packages/text/tests/types/three-shader-api.test.ts +++ b/packages/text/tests/types/three-shader-api.test.ts @@ -4,6 +4,7 @@ import type { Node } from 'three/webgpu'; import { bitmapShader, + defineTextMaterial, msdfShader, slugShader, type ThreeBitmapInstanceNodes, @@ -25,6 +26,13 @@ const bitmapOutput = bitmapShader(bitmapInstance, bitmapResources); const mtsdfOutput = msdfShader(mtsdfInstance, mtsdfResources); const slugOutput = slugShader(slugInstance, slugResources); +const customMaterial = defineTextMaterial((context) => { + const material = context.createDefaultMaterial(); + material.depthTest = true; + if (context.technique === 'pmndrs.slug') material.opacityNode = context.shader.opacity; + return material; +}); + // Each technique publishes its coverage as a float a custom program may weight or threshold itself. const bitmapCoverage: Node<'float'> = bitmapOutput.coverage; const mtsdfOutlineCoverage: Node<'float'> = mtsdfOutput.outlineCoverage; @@ -53,10 +61,14 @@ const wrongColor: Node<'float'> = bitmapOutput.color; // @ts-expect-error Slug addresses are unsigned integers; a float storage read cannot stand in for one. slugShader({ ...slugInstance, curveBaseTexel: slugOutput.coverage }, slugResources); +// @ts-expect-error Material factories must return a Three NodeMaterial. +defineTextMaterial(() => ({})); + void bitmapCoverage; void mtsdfOutlineCoverage; void slugCoverage; void material; void bitmapMaterial; void wrongColor; +void customMaterial; void mtsdfOutput; From fc54a453a447ed4664d0544326fd8d5993baca4b Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 00:44:01 -0400 Subject: [PATCH 071/128] perf(text): retain Three render plan resources --- docs/log.md | 6 + docs/packages/text.md | 10 +- docs/planning/decision-register.md | 2 + packages/text/src/three/engine-plan-target.ts | 158 +++++++++++++++--- .../integration/three-engine-runtime.test.mjs | 51 +++++- 5 files changed, 199 insertions(+), 28 deletions(-) diff --git a/docs/log.md b/docs/log.md index 916fca46..fc25d121 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Bounded Three residency and retained draw identity** — Applied exact Rust buffer/resource retirements to dependent + material and texture realizations, retaining shared renderer resources until their final plan reference leaves. The + compiled-Wasm fixture now checks exact live storage-plus-resource bytes after Bitmap → MSDF → Slug transitions. + Lifecycle-only reorder retains the same meshes/geometries/materials and changes range/order metadata; coalescing + retains one compatible draw and retires only the other. Live backend submission still owns native-fence proof. + - **Carried Three material factories through Rust material IDs** — Added the public factory definition and a runtime- scoped identity registry while keeping Rust callback-free. The executor resolves each nonzero `materialId` only when a compatible realization is absent and supplies the canonical technique shader, final indexed-transform position, diff --git a/docs/packages/text.md b/docs/packages/text.md index ccb13b60..b4b7eb0b 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:c78cc5ab00af32f4455e0fa37790706b1f080e75f43a0e2309ee84b7ba98e0f1' +source_digest: 'sha256:ec1e94bdf0433862dd3c8bc9c9df6e2eef1d5c943f4cbdf46e65e36f7f092452' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -903,6 +903,14 @@ storage, reorder and coalescing reuse cached materials, and the same selected fa Bitmap, MSDF, and Slug. The `TextGroup`/`Text`/span property route and fence-bounded retirement remain part of public cutover, so this checkpoint does not claim that authored materials are available from `Text` yet. +The executor now bounds CPU/GPU realization residency from Rust retirement records. Retiring a physical buffer disposes +only materials that depend on its exact generation; retiring a plan resource disposes its technique texture only after +the final plan resource sharing that renderer reference leaves. Exact accounting in the compiled-Wasm fixture contains +only current policy storage, the transform sidecar, and the current Bitmap/MSDF/Slug resource after each technique +transition. Draw compatibility is range-independent: reorder retains exact meshes, geometries, and materials while +updating `recordIndex`, count, and render order; coalescing retains the one compatible draw and removes only the other. +Live WebGPU/WebGL2 submission still owns the final native-fence proof before public cutover. + The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index a3a498db..73f9acb7 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -298,6 +298,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-219 | Three material authority uses one public `defineTextMaterial(create)` definition interned by object identity into a nonzero `u32 materialId`; zero remains the canonical default. Rust treats the ID only as policy-directed draw compatibility and never invokes the factory. The Three factory receives a technique-discriminated canonical shader output, the exact renderer-local position after transform indirection, and a DRY `createDefaultMaterial()`. It runs once per compatible material/program/resource realization, must return a fresh `NodeMaterial`, and the adapter owns disposal. A compiled-Wasm fixture proves two IDs split draws over shared storage, reorder/coalescing causes no extra calls, and changing retained glyphs from Bitmap to MSDF to Slug invokes the selected factory once for each new technique realization. Public `TextGroup`/`Text`/span material properties and renderer-fence cache retirement remain cutover work. | Accepted | +| D-220 | The Three command-buffer executor treats Rust retirements as authoritative residency boundaries. Buffer retirement removes the exact generation and every dependent material realization; resource retirement removes the exact plan resource and disposes its Bitmap texture, MSDF atlas, or Slug page only when no remaining plan resource references the same renderer resource. Slot-range and output-byte retirements require no Three allocation action. Draw realization compatibility excludes mutable `recordIndex`, `recordCount`, and render order: lifecycle-only reorder retains the same meshes/geometries/materials and updates their range uniforms/order, while coalescing retains one compatible draw and retires only the incompatible draw. The compiled-Wasm fixture checks exact live storage-plus-resource byte accounting after Bitmap → MSDF → Slug transitions and strict draw identity across reorder/coalescing. Native GPU fence evidence remains part of the live backend cutover gate. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index bcb139b9..7303c9ae 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -40,6 +40,13 @@ interface RetainedSlugPage extends ThreeSlugPageResources { dispose(): void; } +interface MaterialRealization { + readonly material: THREE.NodeMaterial; + readonly resourceId: number; + readonly resourceGeneration: number; + readonly buffers: readonly Readonly<{ id: number; generation: number }>[]; +} + export interface ThreeTextEnginePlanOwner { readonly drawRoot: THREE.Object3D; objectForTransform(transformId: number): THREE.Object3D; @@ -56,7 +63,7 @@ export class ThreeTextEnginePlanTarget { readonly #bitmapTextures = new Map(); readonly #msdfAtlases = new Map(); readonly #slugPages = new Map(); - readonly #materials = new Map(); + readonly #materials = new Map(); readonly #ownedMaterials = new WeakSet(); readonly #activeTransformIndices = new Set(); readonly #rootInverse = new THREE.Matrix4(); @@ -64,6 +71,7 @@ export class ThreeTextEnginePlanTarget { #transformAttribute = transformAttribute(1); #transformGeneration = 1; #draws: THREE.Mesh[] = []; + #drawKeys: string[] = []; #disposed = false; constructor(coordinator: ThreeTextEngineCoordinator, owner: ThreeTextEnginePlanOwner) { @@ -142,7 +150,7 @@ export class ThreeTextEnginePlanTarget { if (this.#disposed) return; this.#disposed = true; this.#disposeDraws(); - for (const material of this.#materials.values()) material.dispose(); + for (const realization of this.#materials.values()) realization.material.dispose(); for (const texture of this.#bitmapTextures.values()) texture.dispose(); for (const atlas of this.#msdfAtlases.values()) atlas.dispose(); for (const page of this.#slugPages.values()) page.dispose(); @@ -260,6 +268,15 @@ export class ThreeTextEnginePlanTarget { const bufferLayout = textShaperAbi.layouts.engineBuffer; const resourceLayout = textShaperAbi.layouts.engineResource; const next: THREE.Mesh[] = []; + const nextKeys: string[] = []; + const previous = new Map(); + for (let index = 0; index < this.#draws.length; index += 1) { + const key = this.#drawKeys[index]!; + const matches = previous.get(key) ?? []; + matches.push(this.#draws[index]!); + previous.set(key, matches); + } + const reused = new Set(); const transformIndices = this.#collectTransformIndices(plan, draws, primitives, buffers); this.#ensureTransformCapacity(transformIndices); try { @@ -286,28 +303,56 @@ export class ThreeTextEnginePlanTarget { if (resource === undefined) throw new Error('draw references an unknown retained resource'); const materialId = plan.u32(draw + drawLayout.materialId); const material = this.#material(resource, byPolicyId, materialId); + const recordIndex = plan.u32(primitive + primitiveLayout.recordIndex); + const recordCount = plan.u16(primitive + primitiveLayout.recordCount); + const key = drawRealizationKey( + plan.u32(draw + drawLayout.programId), + resource, + materialId, + byPolicyId, + plan.u32(draw + drawLayout.clipId), + plan.u32(draw + drawLayout.depthKey), + this.#transformGeneration, + ); + const reusable = previous.get(key)?.shift(); + if (reusable !== undefined) { + if (!(reusable.geometry instanceof THREE.InstancedBufferGeometry)) { + throw new TypeError('retained text draw lost its instanced geometry'); + } + reusable.geometry.instanceCount = recordCount; + reusable.userData.pmndrsTextRunStart = recordIndex; + reusable.renderOrder = this.#owner.renderOrderBase + index; + if (reusable.parent !== this.#owner.drawRoot) this.#owner.drawRoot.add(reusable); + reused.add(reusable); + next.push(reusable); + nextKeys.push(key); + continue; + } const geometry = unitQuad(); - geometry.instanceCount = plan.u16(primitive + primitiveLayout.recordCount); + geometry.instanceCount = recordCount; for (const buffer of byPolicyId.values()) { geometry.setAttribute(`_pmndrsText_${buffer.policyBufferId}`, buffer.attribute); } geometry.setAttribute('_pmndrsTextTransforms', this.#transformAttribute); const mesh = new THREE.Mesh(geometry, material); - mesh.userData.pmndrsTextRunStart = plan.u32(primitive + primitiveLayout.recordIndex); + mesh.userData.pmndrsTextRunStart = recordIndex; mesh.frustumCulled = false; mesh.renderOrder = this.#owner.renderOrderBase + index; this.#owner.drawRoot.add(mesh); next.push(mesh); + nextKeys.push(key); } } catch (error) { for (const mesh of next) { + if (reused.has(mesh)) continue; mesh.removeFromParent(); mesh.geometry.dispose(); } throw error; } - this.#disposeDraws(); + this.#disposeDraws(reused); this.#draws = next; + this.#drawKeys = nextKeys; this.#activeTransformIndices.clear(); for (const transformIndex of transformIndices) this.#activeTransformIndices.add(transformIndex); } @@ -358,7 +403,7 @@ export class ThreeTextEnginePlanTarget { while (capacity < requiredRecords) capacity *= 2; this.#transformAttribute = transformAttribute(capacity / 4); this.#transformGeneration += 1; - for (const material of this.#materials.values()) material.dispose(); + for (const realization of this.#materials.values()) realization.material.dispose(); this.#materials.clear(); } @@ -382,8 +427,8 @@ export class ThreeTextEnginePlanTarget { const key = `${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; - let material = this.#materials.get(key); - if (material !== undefined) return material; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; const texture = this.#bitmapTexture(resource.referenceId, page); const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, @@ -411,13 +456,13 @@ export class ThreeTextEnginePlanTarget { this.#transformAttribute, instance, ); - material = this.#createMaterial(materialId, { + const material = this.#createMaterial(materialId, { technique: bitmap.id, shader, position, createDefaultMaterial: () => bitmapMaterial(shader, position), }); - this.#materials.set(key, material); + this.#retainMaterial(key, material, resource, [...required, transformIndices]); return material; } @@ -449,8 +494,8 @@ export class ThreeTextEnginePlanTarget { const key = `msdf:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; - let material = this.#materials.get(key); - if (material !== undefined) return material; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); @@ -485,13 +530,13 @@ export class ThreeTextEnginePlanTarget { this.#transformAttribute, instance, ); - material = this.#createMaterial(materialId, { + const material = this.#createMaterial(materialId, { technique: msdf.id, shader, position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#materials.set(key, material); + this.#retainMaterial(key, material, resource, [...required, transformIndices]); return material; } @@ -549,8 +594,8 @@ export class ThreeTextEnginePlanTarget { const key = `slug:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; - let material = this.#materials.get(key); - if (material !== undefined) return material; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); @@ -592,13 +637,13 @@ export class ThreeTextEnginePlanTarget { }, ); const position = transform.position(shader.position); - material = this.#createMaterial(materialId, { + const material = this.#createMaterial(materialId, { technique: slug.id, shader, position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#materials.set(key, material); + this.#retainMaterial(key, material, resource, [...required, transformIndices]); return material; } @@ -653,14 +698,67 @@ export class ThreeTextEnginePlanTarget { return material; } + #retainMaterial( + key: string, + material: THREE.NodeMaterial, + resource: RetainedResource, + buffers: readonly RetainedBuffer[], + ): void { + this.#materials.set(key, { + material, + resourceId: resource.id, + resourceGeneration: resource.generation, + buffers: buffers.map(({ id, generation }) => ({ id, generation })), + }); + } + + #disposeMaterials(predicate: (realization: MaterialRealization) => boolean): void { + for (const [key, realization] of this.#materials) { + if (!predicate(realization)) continue; + realization.material.dispose(); + this.#materials.delete(key); + } + } + + #disposeResource(referenceId: number): void { + if ([...this.#resources.values()].some((resource) => resource.referenceId === referenceId)) return; + const bitmapTexture = this.#bitmapTextures.get(referenceId); + bitmapTexture?.dispose(); + this.#bitmapTextures.delete(referenceId); + const msdfAtlas = this.#msdfAtlases.get(referenceId); + msdfAtlas?.dispose(); + this.#msdfAtlases.delete(referenceId); + const retainedSlugPage = this.#slugPages.get(referenceId); + retainedSlugPage?.dispose(); + this.#slugPages.delete(referenceId); + } + #applyRetirements(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { const layout = textShaperAbi.layouts.engineRetirement; + const kinds = textShaperAbi.engine.retirementKinds; for (let index = 0; index < table.count; index += 1) { const record = plan.record(table, index); - if (plan.u16(record + layout.kind) !== textShaperAbi.engine.retirementKinds.buffer) continue; + const kind = plan.u16(record + layout.kind); const id = plan.u32(record + layout.id); const generation = plan.u32(record + layout.generation); - if (this.#buffers.get(id)?.generation === generation) this.#buffers.delete(id); + if (kind === kinds.buffer) { + if (this.#buffers.get(id)?.generation !== generation) continue; + this.#disposeMaterials((realization) => + realization.buffers.some((buffer) => buffer.id === id && buffer.generation === generation), + ); + this.#buffers.delete(id); + continue; + } + if (kind === kinds.resource) { + const resource = this.#resources.get(id); + if (resource?.generation !== generation) continue; + this.#disposeMaterials( + (realization) => + realization.resourceId === resource.id && realization.resourceGeneration === resource.generation, + ); + this.#resources.delete(id); + this.#disposeResource(resource.referenceId); + } } } @@ -672,15 +770,33 @@ export class ThreeTextEnginePlanTarget { return buffer; } - #disposeDraws(): void { + #disposeDraws(retained: ReadonlySet = new Set()): void { for (const draw of this.#draws) { + if (retained.has(draw)) continue; draw.removeFromParent(); draw.geometry.dispose(); } this.#draws = []; + this.#drawKeys = []; } } +function drawRealizationKey( + programId: number, + resource: RetainedResource, + materialId: number, + buffers: ReadonlyMap, + clipId: number, + depthKey: number, + transformGeneration: number, +): string { + const bufferKey = [...buffers] + .sort(([left], [right]) => left - right) + .map(([policyId, buffer]) => `${policyId}:${buffer.id}:${buffer.generation}`) + .join(','); + return `${programId}:${resource.id}:${resource.generation}:${materialId}:${clipId}:${depthKey}:${transformGeneration}:${bufferKey}`; +} + function bitmapMaterial( shader: Readonly<{ clipPosition: THREE.Node<'vec4'>; diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index bc02655b..c9a9c983 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -423,10 +423,8 @@ test('Three coordinator shares shaping data across technique bindings and refere ); const previousDraws = [...target.draws]; target.apply(reorderedPublication); - assert.ok( - previousDraws.every((draw) => draw.parent === null), - 'superseded command-buffer draws detach', - ); + assert.equal(target.draws[0], previousDraws[1], 'reorder retains the secondary draw object'); + assert.equal(target.draws[1], previousDraws[0], 'reorder retains the primary draw object'); assert.deepEqual( target.draws.map((draw) => draw.parent), [drawRoot, drawRoot], @@ -435,6 +433,7 @@ test('Three coordinator shares shaping data across technique bindings and refere target.draws.map((draw) => draw.renderOrder), [10, 11], ); + const reorderedTargetDraws = [...target.draws]; const coalescedPublication = session.update( compileTextEngineFrameUpdate({ sessionId: session.handle, @@ -478,6 +477,8 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.equal(coalescedDraws.count, 1, 'same-material paragraphs coalesce across indexed transforms'); assert.equal(coalescedPlan.u32(coalescedPlan.record(coalescedDraws, 0) + drawLayout.transformId), 0); target.apply(coalescedPublication); + assert.equal(target.draws[0], reorderedTargetDraws[1], 'coalescing retains the compatible primary draw'); + assert.equal(reorderedTargetDraws[0].parent, null, 'coalescing retires only the incompatible secondary draw'); assert.equal(target.draws.length, 1); assert.equal(target.draws[0].geometry.instanceCount, 6); const coalescedTransformIndices = target.draws[0].geometry.getAttribute('_pmndrsText_15').array; @@ -533,7 +534,13 @@ test('Three coordinator shares shaping data across technique bindings and refere for (const policyBufferId of [1, 2, 3, 4, 5, 6, 7, 15]) { assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); } - assert.ok(target.gpuBytes > bitmapGpuBytes, 'MSDF atlas residency is included in command-buffer accounting'); + assert.equal( + target.gpuBytes, + textStorageBytes(target.draws) + + msdfFont.data.binding.width * msdfFont.data.binding.height * msdfFont.data.binding.layers * 4, + 'retired Bitmap state is excluded and the live MSDF atlas is included', + ); + assert.notEqual(target.gpuBytes, bitmapGpuBytes); const msdfGpuBytes = target.gpuBytes; const slugPublication = session.update( compileTextEngineFrameUpdate({ @@ -581,7 +588,17 @@ test('Three coordinator shares shaping data across technique bindings and refere for (const policyBufferId of [1, 2, 3, 4, 5, 6, 7, 15]) { assert.ok(target.draws[0].geometry.getAttribute(`_pmndrsText_${policyBufferId}`)); } - assert.ok(target.gpuBytes > msdfGpuBytes, 'Slug page residency is included in command-buffer accounting'); + const resourceLayout = textShaperAbi.layouts.engineResource; + const draw = slugPlan.record(slugDraws, 0); + const resource = slugPlan.record(slugPlan.table('resources'), slugPlan.u32(draw + drawLayout.resourceStart)); + const slugResource = coordinator.resolveResource(slugPlan.u32(resource + resourceLayout.referenceId)); + assert.equal(slugResource.technique, slug.id); + assert.equal( + target.gpuBytes, + textStorageBytes(target.draws) + slugPageGpuBytes(slugResource.page), + 'retired MSDF state is excluded and the live Slug page is included', + ); + assert.notEqual(target.gpuBytes, msdfGpuBytes); assert.deepEqual(materialCalls, [ 'primary:pmndrs.bitmap', 'secondary:pmndrs.bitmap', @@ -618,3 +635,25 @@ function adjacentMaterialGroups(plan, draws, materialOffset) { } return groups; } + +function textStorageBytes(draws) { + const arrays = new Set(); + for (const draw of draws) { + for (const [name, attribute] of Object.entries(draw.geometry.attributes)) { + if (name.startsWith('_pmndrsText')) arrays.add(attribute.array); + } + } + return [...arrays].reduce((bytes, array) => bytes + array.byteLength, 0); +} + +function slugPageGpuBytes(page) { + const references = new Uint16Array( + page.referenceBytes.buffer, + page.referenceBytes.byteOffset, + page.referenceBytes.byteLength / 2, + ); + const texels = Math.ceil(references.length / 2); + const width = Math.min(page.referenceWidth, texels); + const height = Math.ceil(texels / width); + return page.curveBytes.byteLength + page.headerBytes.byteLength + width * height * 4; +} From b04cd3524265e1d542840799e947691ff73362bf Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 00:56:32 -0400 Subject: [PATCH 072/128] feat(text): execute policy-selected transforms --- docs/log.md | 6 + docs/packages/text.md | 10 +- docs/planning/decision-register.md | 2 + .../text/src/internal/render-policy-wire.ts | 68 +++- packages/text/src/three/engine-plan-target.ts | 139 ++++++-- packages/text/src/three/engine-runtime.ts | 14 +- .../integration/three-engine-runtime.test.mjs | 330 ++++++++++-------- 7 files changed, 371 insertions(+), 198 deletions(-) diff --git a/docs/log.md b/docs/log.md index fc25d121..36dafc53 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Executed both policy-selected transform modes in Three** — Generalized the command-buffer target across indexed + and direct transform realizations for Bitmap, MSDF, and Slug. The same compiled-Wasm fixture now registers a direct + first-party policy: Rust emits draw transforms `[1,2]`, omits transform buffers, and Three updates retained draw + matrices from their scene objects. The existing indexed policy still emits draw transforms `[0,0]`, buffer 15, and + the shared matrix sidecar. The engine policy chooses the contract; Three does not rebatch the plan. + - **Bounded Three residency and retained draw identity** — Applied exact Rust buffer/resource retirements to dependent material and texture realizations, retaining shared renderer resources until their final plan reference leaves. The compiled-Wasm fixture now checks exact live storage-plus-resource bytes after Bitmap → MSDF → Slug transitions. diff --git a/docs/packages/text.md b/docs/packages/text.md index b4b7eb0b..72faf965 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:ec1e94bdf0433862dd3c8bc9c9df6e2eef1d5c943f4cbdf46e65e36f7f092452' +source_digest: 'sha256:51054483c8717b0cdc059992ce1c7d732f304071b5c85931660d0fe03e9a2559' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -894,6 +894,14 @@ program-3 draw without resending text or geometry. Browser shader compilation/pi material factories, public cutover, and end-to-end latency remain open. The optimized shaper remains 1,083,255 raw / 411,409 gzip / 324,539 Brotli bytes; all 129 Rust tests and the focused compiled-Wasm/Three integration test pass. +The executor also realizes transform-split programs without an indexed sidecar. A direct first-party policy includes +transform in its draw key, so Rust emits nonzero draw-level IDs and omits policy buffer 15. Three retains each resulting +mesh under the shared draw root, disables automatic local-matrix composition, and updates its relative matrix from the +corresponding scene object without a Wasm call. Bitmap and MSDF use the ordinary model transform; Slug supplies the +ordinary model-view-projection matrix to its dilation graph. The compiled-Wasm fixture proves direct IDs `[1,2]`, no +transform-index/table geometry attributes, exact initial matrices, and one changed retained matrix after a scene-only +transform update. Indexed and direct are policy program contracts; the target does not reinterpret or merge them. + Three material definitions now have one public construction function and one runtime-scoped numeric identity registry. The executor resolves Rust's `materialId` to a factory only when a compatible technique/program/resource material is missing. The context carries the exact canonical shader output, the final renderer-local position after indexed diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 73f9acb7..3cc99fc4 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -300,6 +300,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-220 | The Three command-buffer executor treats Rust retirements as authoritative residency boundaries. Buffer retirement removes the exact generation and every dependent material realization; resource retirement removes the exact plan resource and disposes its Bitmap texture, MSDF atlas, or Slug page only when no remaining plan resource references the same renderer resource. Slot-range and output-byte retirements require no Three allocation action. Draw realization compatibility excludes mutable `recordIndex`, `recordCount`, and render order: lifecycle-only reorder retains the same meshes/geometries/materials and updates their range uniforms/order, while coalescing retains one compatible draw and retires only the incompatible draw. The compiled-Wasm fixture checks exact live storage-plus-resource byte accounting after Bitmap → MSDF → Slug transitions and strict draw identity across reorder/coalescing. Native GPU fence evidence remains part of the live backend cutover gate. | Accepted | +| D-221 | Three executes the transform realization selected by each policy program. Indexed packets have zero draw-level transform, require the Rust-packed `u32` buffer 15, share a renderer matrix sidecar, and may merge compatible instances across scene objects. Direct packets have a nonzero Rust draw `transformId`, omit the index/table attributes, and retain one mesh whose relative matrix follows that scene object without another Wasm call. Bitmap, MSDF, and Slug all select their matching position/MVP graph; the target neither invents a transform boundary nor merges across one. A compiled-Wasm fixture runs the same two-paragraph input through indexed and direct first-party policies and observes `[0,0]` versus `[1,2]`, exact attribute presence/absence, and scene-only matrix updates. A public per-text batching switch is not introduced: policy/program selection remains renderer-integration authority. Live backend pixels and public `Text` cutover remain open. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index 6a66e82d..b62d1fa8 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -99,6 +99,8 @@ export interface FirstPartyTechniqueWireIds { readonly slug: number; } +export type ThreeTransformMode = 'direct' | 'indexed'; + export const firstPartyTechniqueWireIds: FirstPartyTechniqueWireIds = Object.freeze({ bitmap: renderWireId('pmndrs.bitmap'), msdf: renderWireId('pmndrs.msdf'), @@ -108,11 +110,16 @@ export const firstPartyTechniqueWireIds: FirstPartyTechniqueWireIds = Object.fre /** Compiler-mapped Three policy covering every first-party raster technique in one registration. */ export function firstPartyThreeRenderPolicyBytes( identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), + transformMode: ThreeTransformMode = 'indexed', ): Uint8Array { const bitmap = identities.resolve('pmndrs.bitmap'); const msdf = identities.resolve('pmndrs.msdf'); const slug = identities.resolve('pmndrs.slug'); - const programs = [bitmapProgram(bitmap, 1), msdfProgram(msdf, 2), slugProgram(slug, 3)]; + const programs = [ + bitmapProgram(bitmap, 1, transformMode), + msdfProgram(msdf, 2, transformMode), + slugProgram(slug, 3, transformMode), + ]; if (new Set(programs.map((program) => program.techniqueId)).size !== programs.length) { throw new TypeError('first-party raster technique wire identities collide'); } @@ -136,7 +143,7 @@ function threeCapabilitySet(): PolicyCapabilitySet { }; } -function bitmapProgram(techniqueId: number, programId: number): PolicyProgram { +function bitmapProgram(techniqueId: number, programId: number, transformMode: ThreeTransformMode): PolicyProgram { const context = programContext('strike', 8, 0); const { loadF32, loadU32, binary, storeF32, storeU32 } = context; loadF32(15); @@ -154,11 +161,19 @@ function bitmapProgram(techniqueId: number, programId: number): PolicyProgram { [4, [13, 14]], [5, [3, 4, 5, 6]], ]); - storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); - return createProgram(techniqueId, programId, context, [...floatBuffers([2, 2, 2, 2, 4]), transformIndexBuffer()]); + if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + return createProgram( + techniqueId, + programId, + context, + transformMode === 'indexed' + ? [...floatBuffers([2, 2, 2, 2, 4]), transformIndexBuffer()] + : floatBuffers([2, 2, 2, 2, 4]), + transformMode, + ); } -function msdfProgram(techniqueId: number, programId: number): PolicyProgram { +function msdfProgram(techniqueId: number, programId: number, transformMode: ThreeTransformMode): PolicyProgram { const context = programContext('glyph', 10, 1); const { operations, loadF32, loadU32, binary, constantF32, storeF32, storeU32 } = context; loadF32(17); @@ -181,14 +196,17 @@ function msdfProgram(techniqueId: number, programId: number): PolicyProgram { [6, [25, 25, 25, 25]], [7, [25, 25, 25, 24]], ]); - storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); - return createProgram(techniqueId, programId, context, [ - ...floatBuffers([4, 4, 4, 4, 4, 4, 4]), - transformIndexBuffer(), - ]); + if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + return createProgram( + techniqueId, + programId, + context, + [...floatBuffers([4, 4, 4, 4, 4, 4, 4]), ...(transformMode === 'indexed' ? [transformIndexBuffer()] : [])], + transformMode, + ); } -function slugProgram(techniqueId: number, programId: number): PolicyProgram { +function slugProgram(techniqueId: number, programId: number, transformMode: ThreeTransformMode): PolicyProgram { const context = programContext('glyph', 8, 6, true); const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; loadF32(16); @@ -213,12 +231,18 @@ function slugProgram(techniqueId: number, programId: number): PolicyProgram { [6, [21, 22, 23, 24]], [7, [25, 26, 29, 29]], ]); - storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); - return createProgram(techniqueId, programId, context, [ - ...floatBuffers([4, 4, 4, 4, 4]), - ...u32Buffers([4, 4], 6), - transformIndexBuffer(), - ]); + if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + return createProgram( + techniqueId, + programId, + context, + [ + ...floatBuffers([4, 4, 4, 4, 4]), + ...u32Buffers([4, 4], 6), + ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), + ], + transformMode, + ); } interface ProgramContext { @@ -308,6 +332,7 @@ function createProgram( programId: number, context: ProgramContext, buffers: readonly PolicyBuffer[], + transformMode: ThreeTransformMode, ): PolicyProgram { const batch = textShaperAbi.policy.batchFields; return { @@ -320,7 +345,14 @@ function createProgram( operations: context.operations, storageKeyMask: batch.technique | batch.program | batch.resource, drawKeyMask: - batch.technique | batch.program | batch.resource | batch.material | batch.clip | batch.depth | batch.order, + batch.technique | + batch.program | + batch.resource | + batch.material | + batch.clip | + batch.depth | + batch.order | + (transformMode === 'direct' ? batch.transform : 0), }; } diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 7303c9ae..f11415cf 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -47,6 +47,10 @@ interface MaterialRealization { readonly buffers: readonly Readonly<{ id: number; generation: number }>[]; } +type TransformRealization = + | Readonly<{ kind: 'direct'; transformId: number }> + | Readonly<{ kind: 'indexed'; indices: RetainedBuffer }>; + export interface ThreeTextEnginePlanOwner { readonly drawRoot: THREE.Object3D; objectForTransform(transformId: number): THREE.Object3D; @@ -126,11 +130,13 @@ export class ThreeTextEnginePlanTarget { /** Upload changed scene transforms without crossing into Wasm or invalidating text layout. */ syncTransforms(): number { - if (this.#activeTransformIndices.size === 0) return 0; + const hasDirectTransforms = this.#draws.some((draw) => directTransformId(draw) !== 0); + if (this.#activeTransformIndices.size === 0 && !hasDirectTransforms) return 0; this.#owner.drawRoot.updateWorldMatrix(true, false); this.#rootInverse.copy(this.#owner.drawRoot.matrixWorld).invert(); const target = this.#transformAttribute.array as Float32Array; let changed = 0; + let indexedChanged = 0; for (const index of this.#activeTransformIndices) { const object = this.#owner.objectForTransform(index); object.updateWorldMatrix(true, false); @@ -139,10 +145,24 @@ export class ThreeTextEnginePlanTarget { target.set(this.#relativeTransform.elements, index * 16); this.#transformAttribute.addUpdateRange(index * 16, 16); changed += 1; + indexedChanged += 1; + } + for (const draw of this.#draws) { + const transformId = directTransformId(draw); + if (transformId === 0) continue; + const object = this.#owner.objectForTransform(transformId); + object.updateWorldMatrix(true, false); + this.#relativeTransform.multiplyMatrices(this.#rootInverse, object.matrixWorld); + if (draw.matrix.equals(this.#relativeTransform)) continue; + draw.matrix.copy(this.#relativeTransform); + draw.matrixWorldNeedsUpdate = true; + changed += 1; } if (changed === 0) return 0; - this.#transformAttribute.needsUpdate = true; - invalidatePboTexture(this.#transformAttribute); + if (indexedChanged !== 0) { + this.#transformAttribute.needsUpdate = true; + invalidatePboTexture(this.#transformAttribute); + } return changed; } @@ -302,7 +322,9 @@ export class ThreeTextEnginePlanTarget { const resource = this.#resources.get(plan.u32(resourceRecord + resourceLayout.id)); if (resource === undefined) throw new Error('draw references an unknown retained resource'); const materialId = plan.u32(draw + drawLayout.materialId); - const material = this.#material(resource, byPolicyId, materialId); + const transformId = plan.u32(draw + drawLayout.transformId); + const transform = this.#transformRealization(byPolicyId, transformId); + const material = this.#material(resource, byPolicyId, materialId, transform); const recordIndex = plan.u32(primitive + primitiveLayout.recordIndex); const recordCount = plan.u16(primitive + primitiveLayout.recordCount); const key = drawRealizationKey( @@ -312,6 +334,7 @@ export class ThreeTextEnginePlanTarget { byPolicyId, plan.u32(draw + drawLayout.clipId), plan.u32(draw + drawLayout.depthKey), + transform, this.#transformGeneration, ); const reusable = previous.get(key)?.shift(); @@ -321,6 +344,8 @@ export class ThreeTextEnginePlanTarget { } reusable.geometry.instanceCount = recordCount; reusable.userData.pmndrsTextRunStart = recordIndex; + reusable.userData.pmndrsTextTransformId = transformId; + reusable.matrixAutoUpdate = transform.kind !== 'direct'; reusable.renderOrder = this.#owner.renderOrderBase + index; if (reusable.parent !== this.#owner.drawRoot) this.#owner.drawRoot.add(reusable); reused.add(reusable); @@ -333,9 +358,11 @@ export class ThreeTextEnginePlanTarget { for (const buffer of byPolicyId.values()) { geometry.setAttribute(`_pmndrsText_${buffer.policyBufferId}`, buffer.attribute); } - geometry.setAttribute('_pmndrsTextTransforms', this.#transformAttribute); + if (transform.kind === 'indexed') geometry.setAttribute('_pmndrsTextTransforms', this.#transformAttribute); const mesh = new THREE.Mesh(geometry, material); mesh.userData.pmndrsTextRunStart = recordIndex; + mesh.userData.pmndrsTextTransformId = transformId; + mesh.matrixAutoUpdate = transform.kind !== 'direct'; mesh.frustumCulled = false; mesh.renderOrder = this.#owner.renderOrderBase + index; this.#owner.drawRoot.add(mesh); @@ -357,6 +384,15 @@ export class ThreeTextEnginePlanTarget { for (const transformIndex of transformIndices) this.#activeTransformIndices.add(transformIndex); } + #transformRealization(buffers: ReadonlyMap, transformId: number): TransformRealization { + if (transformId !== 0) return { kind: 'direct', transformId }; + const indices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); + if (indices === undefined || !(indices.array instanceof Uint32Array)) { + throw new Error('indexed Three draw is missing its u32 transform-index buffer'); + } + return { kind: 'indexed', indices }; + } + #collectTransformIndices( plan: TextEngineRenderPlanView, draws: RenderPlanTable, @@ -369,6 +405,7 @@ export class ThreeTextEnginePlanTarget { const result = new Set(); for (let drawIndex = 0; drawIndex < draws.count; drawIndex += 1) { const draw = plan.record(draws, drawIndex); + if (plan.u32(draw + drawLayout.transformId) !== 0) continue; const primitive = plan.record(primitives, plan.u32(draw + drawLayout.primitiveStart)); const bufferStart = plan.u32(draw + drawLayout.bufferStart); const bufferEnd = bufferStart + plan.u32(draw + drawLayout.bufferCount); @@ -411,6 +448,7 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, + transform: TransformRealization, ): THREE.NodeMaterial { const resolved = this.#coordinator.resolveResource(resource.referenceId); if (resolved.technique !== bitmap.id) { @@ -422,11 +460,9 @@ export class ThreeTextEnginePlanTarget { if (buffer === undefined) throw new Error(`Bitmap draw is missing policy buffer ${id}`); return buffer; }); - const transformIndices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); - if (transformIndices === undefined) throw new Error('Bitmap draw is missing its transform-index buffer'); const key = `${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) - .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}`; const cached = this.#materials.get(key); if (cached !== undefined) return cached.material; const texture = this.#bitmapTexture(resource.referenceId, page); @@ -450,19 +486,22 @@ export class ThreeTextEnginePlanTarget { }, { page: texture }, ); - const position = indexedTransformPosition( - shader.position, - transformIndices.attribute, - this.#transformAttribute, - instance, - ); + const position = + transform.kind === 'indexed' + ? indexedTransformPosition(shader.position, transform.indices.attribute, this.#transformAttribute, instance) + : shader.position; const material = this.#createMaterial(materialId, { technique: bitmap.id, shader, position, createDefaultMaterial: () => bitmapMaterial(shader, position), }); - this.#retainMaterial(key, material, resource, [...required, transformIndices]); + this.#retainMaterial( + key, + material, + resource, + transform.kind === 'indexed' ? [...required, transform.indices] : required, + ); return material; } @@ -470,11 +509,12 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, + transform: TransformRealization, ): THREE.NodeMaterial { const resolved = this.#coordinator.resolveResource(resource.referenceId); - if (resolved.technique === bitmap.id) return this.#bitmapMaterial(resource, buffers, materialId); - if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId); - if (resolved.technique === slug.id) return this.#slugMaterial(resource, buffers, materialId); + if (resolved.technique === bitmap.id) return this.#bitmapMaterial(resource, buffers, materialId, transform); + if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId, transform); + if (resolved.technique === slug.id) return this.#slugMaterial(resource, buffers, materialId, transform); throw new Error('this Three plan target does not recognize the draw technique'); } @@ -482,6 +522,7 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, + transform: TransformRealization, ): THREE.NodeMaterial { const data = msdfData(this.#coordinator.resolveResource(resource.referenceId)); const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { @@ -489,11 +530,9 @@ export class ThreeTextEnginePlanTarget { if (buffer === undefined) throw new Error(`MSDF draw is missing policy buffer ${id}`); return buffer; }); - const transformIndices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); - if (transformIndices === undefined) throw new Error('MSDF draw is missing its transform-index buffer'); const key = `msdf:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) - .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}`; const cached = this.#materials.get(key); if (cached !== undefined) return cached.material; const runStart = TSL.uniform(0, 'uint').onObjectUpdate( @@ -524,19 +563,22 @@ export class ThreeTextEnginePlanTarget { pixelRange: data.pixelRange, }, ); - const position = indexedTransformPosition( - shader.position, - transformIndices.attribute, - this.#transformAttribute, - instance, - ); + const position = + transform.kind === 'indexed' + ? indexedTransformPosition(shader.position, transform.indices.attribute, this.#transformAttribute, instance) + : shader.position; const material = this.#createMaterial(materialId, { technique: msdf.id, shader, position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#retainMaterial(key, material, resource, [...required, transformIndices]); + this.#retainMaterial( + key, + material, + resource, + transform.kind === 'indexed' ? [...required, transform.indices] : required, + ); return material; } @@ -582,6 +624,7 @@ export class ThreeTextEnginePlanTarget { resource: RetainedResource, buffers: ReadonlyMap, materialId: number, + transformRealization: TransformRealization, ): THREE.NodeMaterial { const page = slugPage(this.#coordinator.resolveResource(resource.referenceId)); const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { @@ -589,11 +632,9 @@ export class ThreeTextEnginePlanTarget { if (buffer === undefined) throw new Error(`Slug draw is missing policy buffer ${id}`); return buffer; }); - const transformIndices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); - if (transformIndices === undefined) throw new Error('Slug draw is missing its transform-index buffer'); const key = `slug:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) - .join(',')}:${transformIndices.id}:${transformIndices.generation}:transform:${this.#transformGeneration}`; + .join(',')}:${transformProgramKey(transformRealization, this.#transformGeneration)}`; const cached = this.#materials.get(key); if (cached !== undefined) return cached.material; const runStart = TSL.uniform(0, 'uint').onObjectUpdate( @@ -609,8 +650,14 @@ export class ThreeTextEnginePlanTarget { const counts = TSL.storage(required[6]!.attribute, 'uvec4', required[6]!.attribute.count) .setPBO(true) .element(instance); - const transform = indexedTransformNodes(transformIndices.attribute, this.#transformAttribute, instance); - const modelViewProjection = TSL.cameraProjectionMatrix.mul(TSL.modelViewMatrix).mul(transform.matrix); + const indexedTransform = + transformRealization.kind === 'indexed' + ? indexedTransformNodes(transformRealization.indices.attribute, this.#transformAttribute, instance) + : undefined; + const modelViewProjection = + indexedTransform === undefined + ? TSL.cameraProjectionMatrix.mul(TSL.modelViewMatrix) + : TSL.cameraProjectionMatrix.mul(TSL.modelViewMatrix).mul(indexedTransform.matrix); const viewport = TSL.uniform(new THREE.Vector2(1, 1)).onRenderUpdate(({ renderer }, self) => renderer?.getDrawingBufferSize(self.value), ); @@ -636,14 +683,19 @@ export class ThreeTextEnginePlanTarget { modelViewProjection, }, ); - const position = transform.position(shader.position); + const position = indexedTransform?.position(shader.position) ?? shader.position; const material = this.#createMaterial(materialId, { technique: slug.id, shader, position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#retainMaterial(key, material, resource, [...required, transformIndices]); + this.#retainMaterial( + key, + material, + resource, + transformRealization.kind === 'indexed' ? [...required, transformRealization.indices] : required, + ); return material; } @@ -788,13 +840,28 @@ function drawRealizationKey( buffers: ReadonlyMap, clipId: number, depthKey: number, + transform: TransformRealization, transformGeneration: number, ): string { const bufferKey = [...buffers] .sort(([left], [right]) => left - right) .map(([policyId, buffer]) => `${policyId}:${buffer.id}:${buffer.generation}`) .join(','); - return `${programId}:${resource.id}:${resource.generation}:${materialId}:${clipId}:${depthKey}:${transformGeneration}:${bufferKey}`; + const transformKey = + transform.kind === 'direct' + ? `direct:${transform.transformId}` + : transformProgramKey(transform, transformGeneration); + return `${programId}:${resource.id}:${resource.generation}:${materialId}:${clipId}:${depthKey}:${transformKey}:${bufferKey}`; +} + +function transformProgramKey(transform: TransformRealization, generation: number): string { + return transform.kind === 'direct' + ? 'direct' + : `indexed:${transform.indices.id}:${transform.indices.generation}:table:${generation}`; +} + +function directTransformId(draw: THREE.Mesh): number { + return (draw.userData.pmndrsTextTransformId as number | undefined) ?? 0; } function bitmapMaterial( diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts index 6198823d..d220857b 100644 --- a/packages/text/src/three/engine-runtime.ts +++ b/packages/text/src/three/engine-runtime.ts @@ -5,7 +5,7 @@ import { slug, type SlugData, type SlugPageData } from '../raster/slug-technique import type { AnyRasterTechnique } from '../raster-technique.js'; import type { TextRuntime } from '../text-runtime.js'; import { firstPartyFontBindingBytes } from '../internal/font-binding-wire.js'; -import { firstPartyThreeRenderPolicyBytes } from '../internal/render-policy-wire.js'; +import { firstPartyThreeRenderPolicyBytes, type ThreeTransformMode } from '../internal/render-policy-wire.js'; import { TextEngineHost, type TextEngineSession, type TextEngineSessionOptions } from '../internal/text-engine-host.js'; import type { ThreeTextMaterial } from './material.js'; @@ -39,6 +39,11 @@ export type ThreeTextEngineResource = | Readonly<{ technique: typeof msdf.id; data: MsdfData }> | Readonly<{ technique: typeof slug.id; page: SlugPageData }>; +export interface ThreeTextEngineCoordinatorOptions { + /** Renderer-policy choice; indexed is the first-party high-throughput default. */ + readonly transformMode?: ThreeTransformMode; +} + /** Three-owned cold registrations shared by every text batch using one renderer-neutral runtime. */ export class ThreeTextEngineCoordinator { readonly host: TextEngineHost; @@ -53,9 +58,12 @@ export class ThreeTextEngineCoordinator { #nextMaterialHandle = 1; #disposed = false; - constructor(runtime: Pick) { + constructor(runtime: Pick, options: ThreeTextEngineCoordinatorOptions = {}) { this.host = new TextEngineHost(runtime.shaper); - this.host.registerPolicy(POLICY_HANDLE, firstPartyThreeRenderPolicyBytes(this.host.wireIdentities)); + this.host.registerPolicy( + POLICY_HANDLE, + firstPartyThreeRenderPolicyBytes(this.host.wireIdentities, options.transformMode), + ); } get policyHandle(): number { diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index c9a9c983..2e951c38 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -12,6 +12,7 @@ import { validateMsdfArtifact } from '../../dist/bakers/msdf-validator.js'; import { validateSlugArtifact } from '../../dist/bakers/slug-validator.js'; import { textShaperAbi } from '../../dist/generated/text-shaper-abi.js'; import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; +import { firstPartyThreeRenderPolicyBytes } from '../../dist/internal/render-policy-wire.js'; import { TextEngineRenderPlanView } from '../../dist/internal/render-plan-view.js'; import { FontRegistry } from '../../dist/loader.js'; import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; @@ -160,148 +161,147 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.equal(shared.handle, first.handle); assert.notEqual(reversed.handle, first.handle, 'fallback order is part of stack identity'); const session = coordinator.createSession({ requestCapacity: 4_096, resultCapacity: 1024 * 1024, textCapacity: 16 }); - const publication = session.update( - compileTextEngineFrameUpdate({ - sessionId: session.handle, - policyHandle: coordinator.policyHandle, - capabilitySet: 1, - expectedEngineRevision: 0, - consumedPlanRevision: 0, - acknowledgedPublicationGeneration: 0, - limits: { - maxParagraphs: 2, - maxClusters: 16, - maxLines: 8, - maxRegions: 2, - maxExclusions: 1, - maxInlineObjects: 1, - maxSlotsPerBand: 2, - maxOutputBytes: 1024 * 1024, - }, - paragraphMutations: [ - { opcode: 'upsert', paragraphId: 1, order: 0 }, - { opcode: 'upsert', paragraphId: 2, order: 1 }, - ], - textMutations: [ - { paragraphId: 1, start: 0, deleteCount: 0, insert: 'abc' }, - { paragraphId: 2, start: 0, deleteCount: 0, insert: 'def' }, - ], - styleMutations: [ - { - opcode: 'upsert', - paragraphId: 1, - styleId: 1, - cascadeOrder: 0, - start: 0, - end: 3, - root: true, - value: { - fontStackHandle: first.handle, - materialId: primaryMaterial.id, - fontSize: 16, - rasterPixelRatio: 1, - foregroundRgba: 0xffff_ffff, - }, - }, - { - opcode: 'upsert', - paragraphId: 2, - styleId: 1, - cascadeOrder: 0, - start: 0, - end: 3, - root: true, - value: { - fontStackHandle: first.handle, - materialId: secondaryMaterial.id, - fontSize: 16, - rasterPixelRatio: 1, - foregroundRgba: 0xffff_ffff, - }, - }, - ], - constraints: [ - { - paragraphId: 1, - flowThreadId: 1, - geometryRevision: 1, - width: 256, - height: 128, - viewportBlockStart: 0, - viewportBlockEnd: 128, - resumeBlockOffset: 0, - maxLines: 8, - regionStart: 0, - resumeCluster: 0, - regionCount: 1, - resumeRegion: 0, - widthMode: 'at-most', - heightMode: 'at-most', - wrap: 'word', - align: 'start', - overflow: 'visible', - blockAlign: 'start', - }, - { - paragraphId: 2, - flowThreadId: 2, - geometryRevision: 1, - width: 256, - height: 128, - viewportBlockStart: 0, - viewportBlockEnd: 128, - resumeBlockOffset: 0, - maxLines: 8, - regionStart: 1, - resumeCluster: 0, - regionCount: 1, - resumeRegion: 0, - widthMode: 'at-most', - heightMode: 'at-most', - wrap: 'word', - align: 'start', - overflow: 'visible', - blockAlign: 'start', - }, - ], - regions: [ - { - id: 1, - geometryRevision: 1, - shape: 'rectangle', - exclusionStart: 0, - exclusionCount: 0, - writingMode: 'horizontal-tb', - textOrientation: 'mixed', - inlineStart: 0, - blockStart: 0, - inlineEnd: 256, - blockEnd: 128, - clipInlineStart: 0, - clipBlockStart: 0, - clipInlineEnd: 256, - clipBlockEnd: 128, + const initialRequest = compileTextEngineFrameUpdate({ + sessionId: session.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + paragraphMutations: [ + { opcode: 'upsert', paragraphId: 1, order: 0 }, + { opcode: 'upsert', paragraphId: 2, order: 1 }, + ], + textMutations: [ + { paragraphId: 1, start: 0, deleteCount: 0, insert: 'abc' }, + { paragraphId: 2, start: 0, deleteCount: 0, insert: 'def' }, + ], + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 1, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: primaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, }, - { - id: 2, - geometryRevision: 1, - shape: 'rectangle', - exclusionStart: 0, - exclusionCount: 0, - writingMode: 'horizontal-tb', - textOrientation: 'mixed', - inlineStart: 0, - blockStart: 0, - inlineEnd: 256, - blockEnd: 128, - clipInlineStart: 0, - clipBlockStart: 0, - clipInlineEnd: 256, - clipBlockEnd: 128, + }, + { + opcode: 'upsert', + paragraphId: 2, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: first.handle, + materialId: secondaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, }, - ], - }), - ); + }, + ], + constraints: [ + { + paragraphId: 1, + flowThreadId: 1, + geometryRevision: 1, + width: 256, + height: 128, + viewportBlockStart: 0, + viewportBlockEnd: 128, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 0, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'at-most', + heightMode: 'at-most', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + { + paragraphId: 2, + flowThreadId: 2, + geometryRevision: 1, + width: 256, + height: 128, + viewportBlockStart: 0, + viewportBlockEnd: 128, + resumeBlockOffset: 0, + maxLines: 8, + regionStart: 1, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: 'at-most', + heightMode: 'at-most', + wrap: 'word', + align: 'start', + overflow: 'visible', + blockAlign: 'start', + }, + ], + regions: [ + { + id: 1, + geometryRevision: 1, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 256, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 256, + clipBlockEnd: 128, + }, + { + id: 2, + geometryRevision: 1, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd: 256, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 256, + clipBlockEnd: 128, + }, + ], + }); + const publication = session.update(initialRequest); const plan = new TextEngineRenderPlanView().bind(publication); for (const name of ['resources', 'buffers', 'patches', 'primitives', 'draws']) { assert.ok(plan.table(name).count > 0, `${name} must come from the Rust publication`); @@ -606,6 +606,56 @@ test('Three coordinator shares shaping data across technique bindings and refere 'primary:pmndrs.slug', ]); assert.ok(target.gpuBytes > 0); + + const directPolicyHandle = 2; + coordinator.host.registerPolicy( + directPolicyHandle, + firstPartyThreeRenderPolicyBytes(coordinator.host.wireIdentities, 'direct'), + ); + const directSession = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: 1024 * 1024, + textCapacity: 16, + }); + const directRequest = initialRequest.slice(); + const requestLayout = textShaperAbi.layouts.engineUpdateRequest; + const directRequestView = new DataView(directRequest.buffer, directRequest.byteOffset, directRequest.byteLength); + directRequestView.setUint32(requestLayout.sessionId, directSession.handle, true); + directRequestView.setUint32(requestLayout.policyHandle, directPolicyHandle, true); + const directPublication = directSession.update(directRequest); + const directPlan = plan.bind(directPublication); + const directDraws = directPlan.table('draws'); + assert.deepEqual( + Array.from({ length: directDraws.count }, (_, index) => + directPlan.u32(directPlan.record(directDraws, index) + drawLayout.transformId), + ), + [1, 2], + 'the direct policy makes transform identity an authoritative Rust draw boundary', + ); + const directTarget = new ThreeTextEnginePlanTarget(coordinator, { + drawRoot, + renderOrderBase: 20, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + }); + directTarget.apply(directPublication); + assert.equal(directTarget.draws.length, 2); + for (const [index, draw] of directTarget.draws.entries()) { + assert.equal(draw.geometry.getAttribute('_pmndrsText_15'), undefined); + assert.equal(draw.geometry.getAttribute('_pmndrsTextTransforms'), undefined); + assert.equal(draw.matrixAutoUpdate, false); + assert.equal(draw.matrix.elements[12], index === 0 ? 4 : 7); + } + assert.equal(directTarget.syncTransforms(), 0); + paragraphObjects.get(2).position.x = 9; + assert.equal(directTarget.syncTransforms(), 1); + assert.equal(directTarget.draws[1].matrix.elements[12], 9); + directTarget.dispose(); + directSession.dispose(); + target.dispose(); session.dispose(); first.release(); From 596336420438fe18fee3349c4295811dec0d038d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 01:04:28 -0400 Subject: [PATCH 073/128] test(text): prove mixed transform policies --- docs/log.md | 4 +- docs/packages/text.md | 5 +- docs/planning/decision-register.md | 2 +- .../text/src/internal/render-policy-wire.ts | 18 +++- .../integration/three-engine-runtime.test.mjs | 95 +++++++++++++++++++ 5 files changed, 117 insertions(+), 7 deletions(-) diff --git a/docs/log.md b/docs/log.md index 36dafc53..9c22bd2b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -6,7 +6,9 @@ and direct transform realizations for Bitmap, MSDF, and Slug. The same compiled-Wasm fixture now registers a direct first-party policy: Rust emits draw transforms `[1,2]`, omits transform buffers, and Three updates retained draw matrices from their scene objects. The existing indexed policy still emits draw transforms `[0,0]`, buffer 15, and - the shared matrix sidecar. The engine policy chooses the contract; Three does not rebatch the plan. + the shared matrix sidecar. A hybrid policy additionally publishes indexed Bitmap and direct MSDF draws together; + scene-only synchronization updates both realizations without Wasm. The engine policy chooses each program contract; + Three does not rebatch the plan. - **Bounded Three residency and retained draw identity** — Applied exact Rust buffer/resource retirements to dependent material and texture realizations, retaining shared renderer resources until their final plan reference leaves. The diff --git a/docs/packages/text.md b/docs/packages/text.md index 72faf965..39e81d21 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:51054483c8717b0cdc059992ce1c7d732f304071b5c85931660d0fe03e9a2559' +source_digest: 'sha256:b6dce9879ae98132ac2c7f11b3f0fba957ffee93af5698a01ee4c65c994e299f' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -901,6 +901,9 @@ corresponding scene object without a Wasm call. Bitmap and MSDF use the ordinary ordinary model-view-projection matrix to its dilation graph. The compiled-Wasm fixture proves direct IDs `[1,2]`, no transform-index/table geometry attributes, exact initial matrices, and one changed retained matrix after a scene-only transform update. Indexed and direct are policy program contracts; the target does not reinterpret or merge them. +One hybrid-policy publication further proves the modes compose: program-1 Bitmap carries draw transform zero and both +indexed attributes while program-2 MSDF carries draw transform two and neither attribute. Updating both corresponding +scene objects changes the shared table lane and direct retained mesh matrix in the same renderer synchronization. Three material definitions now have one public construction function and one runtime-scoped numeric identity registry. The executor resolves Rust's `materialId` to a factory only when a compatible technique/program/resource material is diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 3cc99fc4..7a6f8be0 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -300,7 +300,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-220 | The Three command-buffer executor treats Rust retirements as authoritative residency boundaries. Buffer retirement removes the exact generation and every dependent material realization; resource retirement removes the exact plan resource and disposes its Bitmap texture, MSDF atlas, or Slug page only when no remaining plan resource references the same renderer resource. Slot-range and output-byte retirements require no Three allocation action. Draw realization compatibility excludes mutable `recordIndex`, `recordCount`, and render order: lifecycle-only reorder retains the same meshes/geometries/materials and updates their range uniforms/order, while coalescing retains one compatible draw and retires only the incompatible draw. The compiled-Wasm fixture checks exact live storage-plus-resource byte accounting after Bitmap → MSDF → Slug transitions and strict draw identity across reorder/coalescing. Native GPU fence evidence remains part of the live backend cutover gate. | Accepted | -| D-221 | Three executes the transform realization selected by each policy program. Indexed packets have zero draw-level transform, require the Rust-packed `u32` buffer 15, share a renderer matrix sidecar, and may merge compatible instances across scene objects. Direct packets have a nonzero Rust draw `transformId`, omit the index/table attributes, and retain one mesh whose relative matrix follows that scene object without another Wasm call. Bitmap, MSDF, and Slug all select their matching position/MVP graph; the target neither invents a transform boundary nor merges across one. A compiled-Wasm fixture runs the same two-paragraph input through indexed and direct first-party policies and observes `[0,0]` versus `[1,2]`, exact attribute presence/absence, and scene-only matrix updates. A public per-text batching switch is not introduced: policy/program selection remains renderer-integration authority. Live backend pixels and public `Text` cutover remain open. | Accepted | +| D-221 | Three executes the transform realization selected by each policy program. Indexed packets have zero draw-level transform, require the Rust-packed `u32` buffer 15, share a renderer matrix sidecar, and may merge compatible instances across scene objects. Direct packets have a nonzero Rust draw `transformId`, omit the index/table attributes, and retain one mesh whose relative matrix follows that scene object without another Wasm call. Bitmap, MSDF, and Slug all select their matching position/MVP graph; the target neither invents a transform boundary nor merges across one. A compiled-Wasm fixture runs the same two-paragraph input through indexed and direct first-party policies and observes `[0,0]` versus `[1,2]`, exact attribute presence/absence, and scene-only matrix updates. A hybrid policy then publishes indexed Bitmap `[program 1, transform 0]` and direct MSDF `[program 2, transform 2]` together and updates both renderer transform realizations without Wasm. A public per-text batching switch is not introduced: policy/program selection remains renderer-integration authority. Live backend pixels and public `Text` cutover remain open. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index b62d1fa8..6bc87106 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -101,6 +101,12 @@ export interface FirstPartyTechniqueWireIds { export type ThreeTransformMode = 'direct' | 'indexed'; +export interface ThreeTechniqueTransformModes { + readonly bitmap: ThreeTransformMode; + readonly msdf: ThreeTransformMode; + readonly slug: ThreeTransformMode; +} + export const firstPartyTechniqueWireIds: FirstPartyTechniqueWireIds = Object.freeze({ bitmap: renderWireId('pmndrs.bitmap'), msdf: renderWireId('pmndrs.msdf'), @@ -110,15 +116,19 @@ export const firstPartyTechniqueWireIds: FirstPartyTechniqueWireIds = Object.fre /** Compiler-mapped Three policy covering every first-party raster technique in one registration. */ export function firstPartyThreeRenderPolicyBytes( identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), - transformMode: ThreeTransformMode = 'indexed', + transformMode: ThreeTransformMode | ThreeTechniqueTransformModes = 'indexed', ): Uint8Array { const bitmap = identities.resolve('pmndrs.bitmap'); const msdf = identities.resolve('pmndrs.msdf'); const slug = identities.resolve('pmndrs.slug'); + const modes = + typeof transformMode === 'string' + ? { bitmap: transformMode, msdf: transformMode, slug: transformMode } + : transformMode; const programs = [ - bitmapProgram(bitmap, 1, transformMode), - msdfProgram(msdf, 2, transformMode), - slugProgram(slug, 3, transformMode), + bitmapProgram(bitmap, 1, modes.bitmap), + msdfProgram(msdf, 2, modes.msdf), + slugProgram(slug, 3, modes.slug), ]; if (new Set(programs.map((program) => program.techniqueId)).size !== programs.length) { throw new TypeError('first-party raster technique wire identities collide'); diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 2e951c38..8af1faf5 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -656,6 +656,101 @@ test('Three coordinator shares shaping data across technique bindings and refere directTarget.dispose(); directSession.dispose(); + const hybridPolicyHandle = 3; + coordinator.host.registerPolicy( + hybridPolicyHandle, + firstPartyThreeRenderPolicyBytes(coordinator.host.wireIdentities, { + bitmap: 'indexed', + msdf: 'direct', + slug: 'indexed', + }), + ); + const hybridSession = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: 1024 * 1024, + textCapacity: 16, + }); + const hybridRequest = initialRequest.slice(); + const hybridRequestView = new DataView(hybridRequest.buffer, hybridRequest.byteOffset, hybridRequest.byteLength); + hybridRequestView.setUint32(requestLayout.sessionId, hybridSession.handle, true); + hybridRequestView.setUint32(requestLayout.policyHandle, hybridPolicyHandle, true); + const hybridInitialPublication = hybridSession.update(hybridRequest); + const hybridTarget = new ThreeTextEnginePlanTarget(coordinator, { + drawRoot, + renderOrderBase: 30, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + }); + hybridTarget.apply(hybridInitialPublication); + const hybridPublication = hybridSession.update( + compileTextEngineFrameUpdate({ + sessionId: hybridSession.handle, + policyHandle: hybridPolicyHandle, + capabilitySet: 1, + expectedEngineRevision: hybridInitialPublication.engineRevision, + consumedPlanRevision: hybridInitialPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: [ + { + opcode: 'upsert', + paragraphId: 2, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 3, + root: true, + value: { + fontStackHandle: reversed.handle, + materialId: secondaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + ], + }), + ); + const hybridPlan = plan.bind(hybridPublication); + const hybridDraws = hybridPlan.table('draws'); + assert.deepEqual( + Array.from({ length: hybridDraws.count }, (_, index) => { + const hybridDraw = hybridPlan.record(hybridDraws, index); + return [hybridPlan.u32(hybridDraw + drawLayout.programId), hybridPlan.u32(hybridDraw + drawLayout.transformId)]; + }), + [ + [1, 0], + [2, 2], + ], + 'one Rust publication may mix indexed and direct program contracts', + ); + hybridTarget.apply(hybridPublication); + const [hybridIndexedDraw, hybridDirectDraw] = hybridTarget.draws; + assert.ok(hybridIndexedDraw.geometry.getAttribute('_pmndrsText_15')); + assert.ok(hybridIndexedDraw.geometry.getAttribute('_pmndrsTextTransforms')); + assert.equal(hybridDirectDraw.geometry.getAttribute('_pmndrsText_15'), undefined); + assert.equal(hybridDirectDraw.geometry.getAttribute('_pmndrsTextTransforms'), undefined); + assert.equal(hybridDirectDraw.matrix.elements[12], 9); + paragraphObjects.get(1).position.x = 6; + paragraphObjects.get(2).position.x = 10; + assert.equal(hybridTarget.syncTransforms(), 2); + assert.equal(hybridIndexedDraw.geometry.getAttribute('_pmndrsTextTransforms').array[1 * 16 + 12], 6); + assert.equal(hybridDirectDraw.matrix.elements[12], 10); + hybridTarget.dispose(); + hybridSession.dispose(); + target.dispose(); session.dispose(); first.release(); From 75b0657a4b94da7aa746194f2661f7fb944140c2 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 01:47:19 -0400 Subject: [PATCH 074/128] feat(text): cut Three over to Rust plans --- docs/log.md | 9 + docs/packages/text.md | 17 +- docs/planning/decision-register.md | 2 + packages/text/src/three.ts | 3 +- packages/text/src/three/engine-plan-target.ts | 5 +- packages/text/src/three/text.ts | 591 ++++++++++++++---- .../tests/integration/text-spans.test.mjs | 20 +- .../integration/three-engine-runtime.test.mjs | 18 +- .../tests/integration/three-shader.test.mjs | 160 +---- .../text/tests/integration/three-v1.test.mjs | 62 +- 10 files changed, 607 insertions(+), 280 deletions(-) diff --git a/docs/log.md b/docs/log.md index 9c22bd2b..d9d0b5f4 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-09 +- **Cut imperative Three rendering over to the Rust command buffer** — Replaced the public binding's private + `ParagraphBatch` plus attachment `prepare`/`commit` state machine with one retained Rust session and renderer + executor. A `TextGroup` now submits every descendant paragraph in one update and owns shared draws; standalone text + uses the same path with its own root. Public `material` definitions resolve through Rust `materialId`, while scene + transforms and render-order bases remain renderer-local. Focused compiled-Wasm tests prove mixed-font spans, one + indexed draw across two public text transforms, retained custom material realization, reparenting, and disposal. + Rendering deliberately does not publish layout arrays, and the old Three layout/snapshot/origin surface is removed; + a future interaction or measurement query remains separate. + - **Executed both policy-selected transform modes in Three** — Generalized the command-buffer target across indexed and direct transform realizations for Bitmap, MSDF, and Slug. The same compiled-Wasm fixture now registers a direct first-party policy: Rust emits draw transforms `[1,2]`, omits transform buffers, and Three updates retained draw diff --git a/docs/packages/text.md b/docs/packages/text.md index 39e81d21..b59b6da0 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:b6dce9879ae98132ac2c7f11b3f0fba957ffee93af5698a01ee4c65c994e299f' +source_digest: 'sha256:57721f96d448668e6e1382d7e3db7bf01d288dbc93a5d4828ae3f88c11689536' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -911,8 +911,19 @@ missing. The context carries the exact canonical shader output, the final render transforms, and a function that constructs the canonical default material. Factories must return fresh `NodeMaterial` instances and Three owns their disposal. The compiled-Wasm fixture proves distinct material draws share physical glyph storage, reorder and coalescing reuse cached materials, and the same selected factory is instantiated once for each of -Bitmap, MSDF, and Slug. The `TextGroup`/`Text`/span property route and fence-bounded retirement remain part of public -cutover, so this checkpoint does not claim that authored materials are available from `Text` yet. +Bitmap, MSDF, and Slug. Public `Text`, inherited `TextGroup`, and explicit span material properties now acquire those +numeric identities and flow through the same Rust publication; an unchanged frame retains both its draw and realized +material. Fence-bounded retirement remains part of the live backend gate. + +The imperative Three binding no longer constructs a TypeScript `ParagraphBatch` or drives the attachment +`prepare`/`commit` state machine. One `TextGroup` owns one retained Rust session for all descendant text paragraphs; +standalone `Text` owns the same path with a private session. Rust receives paragraph/text/style/constraint mutations, +publishes one command-buffer delta, and compatible group children become one indexed draw under the group root. The +executor retains only renderer resources needed to apply later deltas. It does not receive paragraph layout arrays; +Three's old layout, glyph-snapshot, and glyph-origin override surface is removed. Any later interaction or measurement +API must query retained Rust layout state separately and on demand. Focused integration covers mixed-font spans, custom material factories, two-child +indexed batching, renderer-local transform updates, reparenting, and disposal. R3F, TypeGPU, the portable legacy core, +and mixed-technique public font stacks still own the remaining cutover and deletion work. The executor now bounds CPU/GPU realization residency from Rust retirement records. Retiring a physical buffer disposes only materials that depend on its exact generation; retiring a plan resource disposes its technique texture only after diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 7a6f8be0..5ba50d62 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -302,6 +302,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-221 | Three executes the transform realization selected by each policy program. Indexed packets have zero draw-level transform, require the Rust-packed `u32` buffer 15, share a renderer matrix sidecar, and may merge compatible instances across scene objects. Direct packets have a nonzero Rust draw `transformId`, omit the index/table attributes, and retain one mesh whose relative matrix follows that scene object without another Wasm call. Bitmap, MSDF, and Slug all select their matching position/MVP graph; the target neither invents a transform boundary nor merges across one. A compiled-Wasm fixture runs the same two-paragraph input through indexed and direct first-party policies and observes `[0,0]` versus `[1,2]`, exact attribute presence/absence, and scene-only matrix updates. A hybrid policy then publishes indexed Bitmap `[program 1, transform 0]` and direct MSDF `[program 2, transform 2]` together and updates both renderer transform realizations without Wasm. A public per-text batching switch is not introduced: policy/program selection remains renderer-integration authority. Live backend pixels and public `Text` cutover remain open. | Accepted | +| D-222 | Public Three `TextGroup` and standalone `Text` now render through one retained Rust engine session and the Rust command-buffer executor rather than constructing a TypeScript `ParagraphBatch`, attaching a target, and negotiating `prepare`/`commit` revisions. A group owns one session for every descendant paragraph and realizes compatible children as one indexed draw beneath the shared group root; standalone text owns the same path with its own draw root. Authored `material` definitions are interned to `materialId` on root text, group inheritance, or spans and resolved only by the renderer. Scene-transform and group render-order changes remain renderer-local. Rendering requests no paragraph layout arrays, so Three's legacy `layout`, glyph snapshot, and glyph-origin override surface is removed; any future measurement, caret, selection, or hit-test surface must be a separate demand-shaped Rust query rather than a render-plan table. Focused compiled-Wasm integration proves shared two-paragraph batching, mixed-font span draws, retained custom-material realization, reparenting, and disposal. The old TypeScript paragraph/batch implementation remains only for non-cutover core/TypeGPU/R3F consumers and is scheduled for deletion after those public paths move. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index d3b693ec..930b485a 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -9,8 +9,7 @@ export type { UnboundSpanTag, } from './formatted-text.js'; export type { FontSelection, FontStack, LoadedFont } from './loaded-font.js'; -export type { GlyphBufferCapacity, GlyphOriginUpdate, GlyphSnapshot, ParagraphContentBox } from './paragraph-batch.js'; -export type { ParagraphLayout } from './layout.js'; +export type { GlyphBufferCapacity, ParagraphContentBox } from './paragraph-batch.js'; export type { ParagraphStyle } from './paragraph.js'; export { bitmapShader } from './three/bitmap-shader.js'; export type { diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index f11415cf..a0edf07f 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -58,7 +58,7 @@ export interface ThreeTextEnginePlanOwner { } /** Applies retained Rust command-buffer deltas to Three storage attributes and draw objects. */ -export class ThreeTextEnginePlanTarget { +export class ThreeTextRenderPlanExecutor { readonly #coordinator: ThreeTextEngineCoordinator; readonly #owner: ThreeTextEnginePlanOwner; readonly #view = new TextEngineRenderPlanView(); @@ -130,6 +130,9 @@ export class ThreeTextEnginePlanTarget { /** Upload changed scene transforms without crossing into Wasm or invalidating text layout. */ syncTransforms(): number { + for (const [index, draw] of this.#draws.entries()) { + draw.renderOrder = this.#owner.renderOrderBase + index; + } const hasDirectTransforms = this.#draws.some((draw) => directTransformId(draw) !== 0); if (this.#activeTransformIndices.size === 0 && !hasDirectTransforms) return 0; this.#owner.drawRoot.updateWorldMatrix(true, false); diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 3660fb95..366f12c3 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -11,24 +11,31 @@ import { } from '../loaded-font.js'; import type { GlyphBufferCapacity, - GlyphOriginUpdate, - GlyphSnapshot, - Paragraph, - ParagraphBatch, ParagraphContentBox, - ParagraphLayout, ParagraphProperties, ParagraphStyle, ParagraphUpdate, } from '../index.js'; -import type { ParagraphBatchTarget, ParagraphBatchTargetRevision } from '../paragraph-batch-attachment.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; import type { TextRuntime } from '../text-runtime.js'; import { - threeRasterProgram, - type ThreeRasterTargetAccounting, - type ThreeRasterTargetOwner, -} from './program-registry.js'; + compileTextEngineFrameUpdate, + type TextEngineConstraint, + type TextEngineFrameLimits, + type TextEngineRegion, + type TextEngineStyleMutation, + type TextEngineStyleValue, + type TextEngineTextMutation, +} from '../internal/engine-frame-wire.js'; +import type { TextEnginePublication, TextEngineSession } from '../internal/text-engine-host.js'; +import { ThreeTextRenderPlanExecutor } from './engine-plan-target.js'; +import { + threeTextEngineCoordinator, + type ThreeTextMaterialLease, + type ThreeTextEngineCoordinator, + type ThreeTextEngineStackLease, +} from './engine-runtime.js'; +import type { ThreeTextMaterial } from './material.js'; export interface ThreeRenderVariant { readonly effects?: readonly unknown[]; @@ -37,12 +44,14 @@ export interface ThreeRenderVariant { export type TextSpan = ParagraphSpan< Technique, Variant ->; +> & + Readonly<{ material?: ThreeTextMaterial }>; export type TextProperties = ParagraphProperties< Technique, Variant ->; +> & + Readonly<{ material?: ThreeTextMaterial }>; export type StandaloneTextProperties< Technique extends AnyRasterTechnique, @@ -52,13 +61,15 @@ export type StandaloneTextProperties< export type TextUpdate = ParagraphUpdate< Technique, Variant ->; +> & + Readonly<{ material?: ThreeTextMaterial }>; export interface TextGroupOptions { readonly technique: Technique; readonly capacity?: GlyphBufferCapacity; readonly renderOrder?: number; readonly renderVariant?: Variant; + readonly material?: ThreeTextMaterial; } type SameType = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; @@ -85,6 +96,7 @@ interface DesiredTextState { readonly paint: GlyphPaintInput; readonly rasterPixelRatio?: number; readonly renderVariant?: Variant; + readonly material?: ThreeTextMaterial; } export class Text extends THREE.Object3D { @@ -94,7 +106,6 @@ export class Text[]; #standaloneCapacity: GlyphBufferCapacity; #binding: ThreeTextBatchBinding | undefined; - #paragraph: Paragraph | undefined; #textGroup: TextGroup | undefined; #desiredRevision = 0; #appliedRevision = -1; @@ -118,14 +129,11 @@ export class Text); + } set renderVariant(value: Variant | undefined) { this.set({ renderVariant: value } as TextUpdate); } @@ -211,21 +225,6 @@ export class Text, - paragraph: Paragraph, - group: TextGroup | undefined, - ): void { + bind(binding: ThreeTextBatchBinding, group: TextGroup | undefined): void { if (this.#binding !== binding) this.#unbind(); this.#binding = binding; - this.#paragraph = paragraph; this.#textGroup = group; this.#appliedRevision = this.#desiredRevision; } unbindFrom(binding: ThreeTextBatchBinding): void { if (this.#binding !== binding) return; this.#binding = undefined; - this.#paragraph = undefined; this.#textGroup = undefined; } - setParagraph(paragraph: Paragraph): void { - this.#paragraph = paragraph; - } get runtime(): TextRuntime { return this.#runtime; } @@ -316,7 +306,6 @@ export class Text | undefined; #disposed = false; #error: unknown; @@ -341,6 +331,7 @@ export class TextGroup( ...children: CompatibleTextChildren @@ -436,78 +434,81 @@ export class TextGroup implements ThreeRasterTargetOwner { +class ThreeTextBatchBinding { readonly #runtime: TextRuntime; readonly #group: TextGroup | undefined; - readonly #batch: ParagraphBatch; - readonly #paragraphs = new Map, Paragraph>(); + readonly #coordinator: ThreeTextEngineCoordinator; + readonly #session: TextEngineSession; + readonly #target: ThreeTextRenderPlanExecutor; + readonly #paragraphs = new Map, RetainedEngineParagraph>(); readonly #textsByParagraph = new Map>(); - readonly #renderOrders = new Map, number>(); - readonly #target: ThreeRasterTargetAccounting; - readonly #attachment: ThreeTargetAttachment; + readonly #removed: RetainedEngineParagraph[] = []; + #nextParagraphId = 1; + #engineRevision = 0; + #planRevision = 0; + #acknowledgedPublicationGeneration = 0; + #lastPublication: TextEnginePublication | undefined; + #requestCapacity: number; + #resultCapacity: number; + #textCapacity: number; + #materialInvalidated = false; #disposed = false; constructor( runtime: TextRuntime, - technique: Technique, + _technique: Technique, capacity: GlyphBufferCapacity, group: TextGroup | undefined, ) { this.#runtime = runtime; this.#group = group; - this.#batch = runtime.createParagraphBatch({ - technique, - capacity, - ...(group?.renderVariant === undefined ? {} : { renderVariant: group.renderVariant }), + this.#coordinator = threeTextEngineCoordinator(runtime); + this.#requestCapacity = Math.max(64 * 1024, capacity.size * 32); + this.#resultCapacity = Math.max(256 * 1024, capacity.size * 160); + this.#textCapacity = capacity.size; + this.#session = this.#coordinator.createSession({ + requestCapacity: this.#requestCapacity, + resultCapacity: this.#resultCapacity, + textCapacity: this.#textCapacity, + }); + const owner = this; + this.#target = new ThreeTextRenderPlanExecutor(this.#coordinator, { + get drawRoot() { + return owner.#drawRoot(); + }, + get renderOrderBase() { + return owner.#renderOrderBase(); + }, + objectForTransform(transformId) { + const text = owner.#textsByParagraph.get(transformId); + if (text === undefined) throw new Error(`Three command buffer references unknown transform ${transformId}`); + return text; + }, }); - const program = threeRasterProgram(technique); - if (program === undefined) { - throw new TypeError( - `no Three raster program is registered for "${technique.id}"; register one with registerThreeRasterProgram`, - ); - } - const target = program(this); - const attached = target as unknown as ParagraphBatchTarget< - AnyRasterTechnique, - Variant, - ParagraphBatchTargetRevision - >; - const batch = this.#batch as unknown as ParagraphBatch; - this.#target = target; - this.#attachment = batch.attach(attached) as unknown as ThreeTargetAttachment; } get textCount(): number { return this.#paragraphs.size; } get error(): unknown { - return this.#batch.preparationError ?? this.#attachment.error; + return undefined; } get gpuBytes(): number { - const bytes = this.#target.gpuBytes; - return typeof bytes === 'number' ? bytes : 0; + return this.#target.gpuBytes; } get renderOrderBase(): number { return this.#group?.renderOrder ?? 0; } - objectForParagraph(id: number): THREE.Object3D { - const text = this.#textsByParagraph.get(id); - if (text === undefined) throw new Error('Three target cannot resolve a paragraph transform'); - return text; - } reconcile(texts: readonly Text[]): void { const desired = new Set(texts); for (const text of [...this.#paragraphs.keys()]) if (!desired.has(text)) this.removeText(text); @@ -518,58 +519,421 @@ class ThreeTextBatchBinding imple } synchronize(): void { if (this.#disposed) return; - for (const [text, paragraph] of this.#paragraphs) { - if (text.needsApply()) { - paragraph.set(text.coreProperties() as ParagraphUpdate); - text.markApplied(); + const ordered = [...this.#paragraphs.entries()].sort( + ([leftText, left], [rightText, right]) => leftText.renderOrder - rightText.renderOrder || left.id - right.id, + ); + const changed = ordered.flatMap(([text, paragraph], order) => + paragraph.created || this.#materialInvalidated || text.needsApply() || paragraph.order !== order + ? [{ text, paragraph, order }] + : [], + ); + if (changed.length === 0 && this.#removed.length === 0) { + this.#target.syncTransforms(); + return; + } + const paragraphMutations = [ + ...this.#removed.map((paragraph) => ({ opcode: 'remove' as const, paragraphId: paragraph.id })), + ...changed.map(({ paragraph, order }) => ({ + opcode: 'upsert' as const, + paragraphId: paragraph.id, + order, + })), + ]; + const textMutations: TextEngineTextMutation[] = []; + const styleMutations: TextEngineStyleMutation[] = []; + const constraints: TextEngineConstraint[] = []; + const regions: TextEngineRegion[] = []; + const pendingLeases = new Map(); + const pendingMaterials = new Map(); + let committed = false; + try { + for (const { text, paragraph } of changed) { + const properties = text.coreProperties(); + const content = properties.text as string; + textMutations.push({ + paragraphId: paragraph.id, + start: 0, + deleteCount: paragraph.textLength, + insert: content, + }); + const leases: ThreeTextEngineStackLease[] = []; + const materials: ThreeTextMaterialLease[] = []; + pendingLeases.set(paragraph, leases); + pendingMaterials.set(paragraph, materials); + const styles = compileEngineStyles( + this.#coordinator, + paragraph.id, + properties, + this.#group?.material, + leases, + materials, + ); + styleMutations.push(...styles); + for (let styleId = styles.length + 1; styleId <= paragraph.styleCount; styleId += 1) { + styleMutations.push({ opcode: 'remove', paragraphId: paragraph.id, styleId }); + } + const geometry = compileEngineGeometry(paragraph, properties.contentBox, regions.length, content.length); + constraints.push(geometry.constraint); + regions.push(geometry.region); } - if (this.#renderOrders.get(text) !== text.renderOrder) { - paragraph.order = text.renderOrder; - this.#renderOrders.set(text, text.renderOrder); + const totalTextLength = [...this.#paragraphs.keys()].reduce((total, text) => total + text.text.length, 0); + const limits = engineLimits( + this.#paragraphs.size, + totalTextLength, + Math.max(regions.length, this.#paragraphs.size), + this.#resultCapacity, + ); + const publication = this.#session.update( + compileTextEngineFrameUpdate({ + sessionId: this.#session.handle, + policyHandle: this.#coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: this.#engineRevision, + consumedPlanRevision: this.#planRevision, + acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + limits, + paragraphMutations, + textMutations, + styleMutations, + constraints, + regions, + }), + ); + this.#engineRevision = publication.engineRevision; + this.#planRevision = publication.planRevision; + for (const removed of this.#removed) releaseStackLeases(removed.stackLeases); + for (const removed of this.#removed) releaseMaterialLeases(removed.materialLeases); + this.#removed.length = 0; + for (const [order, [text, paragraph]] of ordered.entries()) { + if (!pendingLeases.has(paragraph) && paragraph.order === order) continue; + const nextLeases = pendingLeases.get(paragraph); + if (nextLeases !== undefined) { + releaseStackLeases(paragraph.stackLeases); + releaseMaterialLeases(paragraph.materialLeases); + paragraph.stackLeases = nextLeases; + paragraph.materialLeases = pendingMaterials.get(paragraph) ?? []; + paragraph.textLength = text.text.length; + paragraph.styleCount = 1 + text.spans.length; + paragraph.geometryRevision += 1; + paragraph.created = false; + text.markApplied(); + } + paragraph.order = order; + } + this.#materialInvalidated = false; + committed = true; + try { + this.#target.apply(publication); + this.#lastPublication = undefined; + } catch (error) { + this.#lastPublication = ownPublication(publication); + throw error; } + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + } catch (error) { + if (!committed) { + for (const leases of pendingLeases.values()) releaseStackLeases(leases); + for (const leases of pendingMaterials.values()) releaseMaterialLeases(leases); + } + throw error; } - this.#runtime.update(); - this.#attachment.prepare(); - this.#attachment.commit()?.setRenderOrderBase(this.renderOrderBase); } setCapacity(value: GlyphBufferCapacity): void { - this.#batch.setCapacity(value); + this.#requestCapacity = Math.max(this.#requestCapacity, value.size * 32); + this.#resultCapacity = Math.max(this.#resultCapacity, value.size * 160); + this.#textCapacity = Math.max(this.#textCapacity, value.size); + this.#session.reserve(this.#requestCapacity, this.#resultCapacity, this.#textCapacity); } - setRenderVariant(value: Variant | undefined): void { - this.#batch.renderVariant = value; + setRenderVariant(_value: Variant | undefined): void { + // Removed with the legacy target state machine. Renderer material IDs replace variants. + } + invalidateMaterial(): void { + this.#materialInvalidated = true; } retry(): void { - this.#attachment.retry(); + const publication = this.#lastPublication; + if (publication === undefined) return; + this.#target.apply(publication); + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + this.#lastPublication = undefined; } removeText(text: Text): void { const paragraph = this.#paragraphs.get(text); if (paragraph === undefined) return; this.#paragraphs.delete(text); this.#textsByParagraph.delete(paragraph.id); - this.#renderOrders.delete(text); - paragraph.dispose(); + this.#removed.push(paragraph); text.unbindFrom(this); } dispose(): void { if (this.#disposed) return; this.#disposed = true; for (const text of this.#paragraphs.keys()) text.unbindFrom(this); - this.#batch.dispose(); + this.#target.dispose(); + this.#session.dispose(); + for (const paragraph of this.#paragraphs.values()) releaseStackLeases(paragraph.stackLeases); + for (const paragraph of this.#removed) releaseStackLeases(paragraph.stackLeases); + for (const paragraph of this.#paragraphs.values()) releaseMaterialLeases(paragraph.materialLeases); + for (const paragraph of this.#removed) releaseMaterialLeases(paragraph.materialLeases); this.#paragraphs.clear(); this.#textsByParagraph.clear(); - this.#renderOrders.clear(); + this.#removed.length = 0; } #ensureText(text: Text, group: TextGroup | undefined): void { - validateBinding(this.#runtime, this.#batch.technique, text); + validateBinding(this.#runtime, text.technique, text); let paragraph = this.#paragraphs.get(text); if (paragraph === undefined) { - paragraph = this.#batch.add(text.coreProperties()); + const id = this.#nextParagraphId++; + paragraph = { + id, + textLength: 0, + styleCount: 0, + order: -1, + geometryRevision: 0, + created: true, + stackLeases: [], + materialLeases: [], + }; this.#paragraphs.set(text, paragraph); - this.#textsByParagraph.set(paragraph.id, text); - this.#renderOrders.set(text, text.renderOrder); - text.bind(this, paragraph, group); + this.#textsByParagraph.set(id, text); + text.bind(this, group); } } + + #drawRoot(): THREE.Object3D { + if (this.#group !== undefined) return this.#group; + const text = this.#paragraphs.keys().next().value as Text | undefined; + if (text === undefined) throw new Error('standalone text command buffer has no draw root'); + return text; + } + + #renderOrderBase(): number { + if (this.#group !== undefined) return this.#group.renderOrder; + return this.#paragraphs.keys().next().value?.renderOrder ?? 0; + } +} + +function compileEngineStyles( + coordinator: ThreeTextEngineCoordinator, + paragraphId: number, + properties: ParagraphProperties, + groupMaterial: ThreeTextMaterial | undefined, + leases: ThreeTextEngineStackLease[], + materialLeases: ThreeTextMaterialLease[], +): TextEngineStyleMutation[] { + const text = properties.text as string; + const rootStack = acquireEngineStack(coordinator, properties.font, leases); + const rootMaterial = (properties as TextProperties).material ?? groupMaterial; + const rootMaterialId = acquireEngineMaterial(coordinator, rootMaterial, materialLeases); + const styles: TextEngineStyleMutation[] = [ + { + opcode: 'upsert', + paragraphId, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: text.length, + root: true, + value: engineStyleValue(properties.style ?? {}, properties.paint, 0, text.length, { + fontStackHandle: rootStack, + ...(rootMaterialId === undefined ? {} : { materialId: rootMaterialId }), + fontSize: properties.style?.fontSize ?? 16, + rasterPixelRatio: properties.rasterPixelRatio ?? 1, + }), + }, + ]; + for (const [index, span] of (properties.spans ?? []).entries()) { + const fontStackHandle = span.font === undefined ? undefined : acquireEngineStack(coordinator, span.font, leases); + const materialId = acquireEngineMaterial( + coordinator, + (span as TextSpan).material, + materialLeases, + ); + styles.push({ + opcode: 'upsert', + paragraphId, + styleId: index + 2, + cascadeOrder: index + 1, + start: span.start, + end: span.end, + value: engineStyleValue(span.style ?? {}, span.paint, span.start, span.end, { + ...(fontStackHandle === undefined ? {} : { fontStackHandle }), + ...(materialId === undefined ? {} : { materialId }), + }), + }); + } + return styles; +} + +function acquireEngineMaterial( + coordinator: ThreeTextEngineCoordinator, + material: ThreeTextMaterial | undefined, + leases: ThreeTextMaterialLease[], +): number | undefined { + if (material === undefined) return undefined; + const lease = coordinator.acquireMaterial(material); + leases.push(lease); + return lease.id; +} + +function acquireEngineStack( + coordinator: ThreeTextEngineCoordinator, + selection: FontSelection, + leases: ThreeTextEngineStackLease[], +): number { + const fonts = concreteFonts(selection) as readonly [ + LoadedFont, + ...LoadedFont[], + ]; + const lease = coordinator.acquireFontStack(fonts); + leases.push(lease); + return lease.handle; +} + +function engineStyleValue( + style: ParagraphStyle, + paint: GlyphPaintInput | undefined, + start: number, + end: number, + base: TextEngineStyleValue, +): TextEngineStyleValue { + return { + ...base, + ...(style.fontSize === undefined ? {} : { fontSize: style.fontSize }), + ...(style.lineHeight === undefined ? {} : { lineHeight: style.lineHeight }), + ...(style.letterSpacing === undefined ? {} : { letterSpacing: style.letterSpacing }), + ...(style.language === undefined ? {} : { language: style.language }), + ...(style.direction === undefined ? {} : { direction: style.direction }), + ...(style.features === undefined + ? {} + : { + features: style.features.map((feature) => ({ + tag: feature.tag, + value: feature.value ?? 1, + start: feature.start ?? start, + end: feature.end ?? end, + })), + }), + ...(paint === undefined ? {} : { foregroundRgba: packedForeground(paint) }), + }; +} + +function compileEngineGeometry( + paragraph: RetainedEngineParagraph, + contentBox: ParagraphContentBox | undefined, + regionStart: number, + textLength: number, +): { readonly constraint: TextEngineConstraint; readonly region: TextEngineRegion } { + const width = axis(contentBox?.width); + const height = axis(contentBox?.height); + const inlineEnd = width.mode === 'unconstrained' ? 0x01_00_00_00 : width.size; + const blockEnd = height.mode === 'unconstrained' ? 0x01_00_00_00 : height.size; + const maxLines = contentBox?.maxLines ?? Math.max(1, textLength); + const geometryRevision = paragraph.geometryRevision + 1; + return { + constraint: { + paragraphId: paragraph.id, + flowThreadId: paragraph.id, + geometryRevision, + width: width.size, + height: height.size, + viewportBlockStart: 0, + viewportBlockEnd: blockEnd, + resumeBlockOffset: 0, + maxLines, + regionStart, + resumeCluster: 0, + regionCount: 1, + resumeRegion: 0, + widthMode: width.mode, + heightMode: height.mode, + wrap: contentBox?.wrap ?? 'word', + align: contentBox?.align ?? 'start', + overflow: contentBox?.overflow ?? 'visible', + blockAlign: 'start', + }, + region: { + id: paragraph.id, + geometryRevision, + transformIndex: paragraph.id, + shape: 'rectangle', + exclusionStart: 0, + exclusionCount: 0, + writingMode: 'horizontal-tb', + textOrientation: 'mixed', + inlineStart: 0, + blockStart: 0, + inlineEnd, + blockEnd, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: inlineEnd, + clipBlockEnd: blockEnd, + }, + }; +} + +function axis(value: ParagraphContentBox['width'] | undefined): { + readonly mode: 'unconstrained' | 'at-most' | 'exact'; + readonly size: number; +} { + if (value === undefined || value.mode === 'unconstrained') return { mode: 'unconstrained', size: 0 }; + return { mode: value.mode, size: value.size }; +} + +function engineLimits( + paragraphCount: number, + textLength: number, + regionCount: number, + maxOutputBytes: number, +): TextEngineFrameLimits { + return { + maxParagraphs: Math.max(1, paragraphCount), + maxClusters: Math.max(1, textLength * 2), + maxLines: Math.max(1, textLength), + maxRegions: Math.max(1, regionCount), + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 8, + maxOutputBytes, + }; +} + +function packedForeground(paint: GlyphPaintInput): number { + const opacity = paint.opacity ?? 1; + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) { + throw new RangeError('opacity must be in [0, 1]'); + } + const input = paint.color ?? '#ffffff'; + const rgba = typeof input === 'string' ? parseHexColor(input) : input; + const channel = (value: number): number => Math.round(Math.min(1, Math.max(0, value)) * 255); + return ( + (channel(rgba[0]) | (channel(rgba[1]) << 8) | (channel(rgba[2]) << 16) | (channel(rgba[3] * opacity) << 24)) >>> 0 + ); +} + +function parseHexColor(value: string): readonly [number, number, number, number] { + const match = /^#([0-9a-f]{6}|[0-9a-f]{8})$/iu.exec(value); + if (match === null) throw new TypeError('colors must be #rrggbb, #rrggbbaa, or linear RGBA'); + const hex = match[1]!; + const linear = (at: number): number => { + const srgb = Number.parseInt(hex.slice(at, at + 2), 16) / 255; + return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + }; + return [linear(0), linear(2), linear(4), hex.length === 8 ? Number.parseInt(hex.slice(6), 16) / 255 : 1]; +} + +function releaseStackLeases(leases: readonly ThreeTextEngineStackLease[]): void { + for (const lease of leases) lease.release(); +} + +function releaseMaterialLeases(leases: readonly ThreeTextMaterialLease[]): void { + for (const lease of leases) lease.release(); +} + +function ownPublication(publication: TextEnginePublication): TextEnginePublication { + const bytes = publication.bytes.slice(); + return { ...publication, bytes, memoryBuffer: bytes.buffer, memoryGrew: false }; } /** @@ -601,6 +965,7 @@ function normalizeDesired( paint: Object.freeze({ ...(properties.paint ?? {}) }), ...(properties.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: properties.rasterPixelRatio }), ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), + ...(properties.material === undefined ? {} : { material: properties.material }), }); } function selectedFonts( diff --git a/packages/text/tests/integration/text-spans.test.mjs b/packages/text/tests/integration/text-spans.test.mjs index c17b8377..1edef401 100644 --- a/packages/text/tests/integration/text-spans.test.mjs +++ b/packages/text/tests/integration/text-spans.test.mjs @@ -204,18 +204,9 @@ test('Three Text shapes and draws a formatted literal through the real render li [{ start: 6, end: 9, font: devanagari, style: { fontSize: 18 }, paint: { color: '#ff00ff' } }], ); - const layout = label.layout; - assert.deepEqual([...layout.fontHandles], [inter.font.handle, devanagari.font.handle]); - assert.deepEqual( - [...layout.glyphFontSlots], - [0, 0, 0, 0, 0, 0, 1, 1, 1, 0], - 'only the span range may shape from the span font', - ); - assert.deepEqual([...layout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 18, 18, 18, 16]); - assert.deepEqual([...layout.clusters], [0, 1, 2, 3, 4, 5, 6, 6, 8, 9]); - assert.equal(layout.glyphIds.includes(0), false, 'every span glyph must resolve in its selected font'); - - const draws = label.children.filter((child) => child.isMesh); + assert.equal(label.layout, undefined, 'the render lifecycle must not request layout readback'); + assert.equal(group.error, undefined); + const draws = group.children.filter((child) => child.isMesh); assert.deepEqual( draws.map((mesh) => [mesh.userData.pmndrsTextRunStart, mesh.geometry.instanceCount]), [ @@ -231,11 +222,10 @@ test('Three Text shapes and draws a formatted literal through the real render li label.text = 'Alert'; scene.updateMatrixWorld(); assert.deepEqual(label.spans, []); - assert.deepEqual([...label.layout.fontHandles], [inter.font.handle]); - assert.equal(label.layout.glyphIds.length, 5); + assert.equal(label.layout, undefined); assert.equal(group.error, undefined); assert.deepEqual( - label.children.filter((child) => child.isMesh).map((mesh) => mesh.geometry.instanceCount), + group.children.filter((child) => child.isMesh).map((mesh) => mesh.geometry.instanceCount), [5], ); diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 8af1faf5..140eb560 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -21,7 +21,7 @@ import { defineRasterResourceId } from '../../dist/raster-technique.js'; import { slug, slugDescriptor } from '../../dist/raster/slug-technique.js'; import { createRuntimeShaper } from '../../dist/shaper.js'; import { ThreeTextEngineCoordinator } from '../../dist/three/engine-runtime.js'; -import { ThreeTextEnginePlanTarget } from '../../dist/three/engine-plan-target.js'; +import { ThreeTextRenderPlanExecutor } from '../../dist/three/engine-plan-target.js'; import { defineTextMaterial } from '../../dist/three/material.js'; const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/rendering/', import.meta.url); @@ -330,7 +330,7 @@ test('Three coordinator shares shaping data across technique bindings and refere ]); paragraphObjects.get(1).position.x = 3; paragraphObjects.get(2).position.x = 7; - const target = new ThreeTextEnginePlanTarget(coordinator, { + const target = new ThreeTextRenderPlanExecutor(coordinator, { drawRoot, renderOrderBase: 10, objectForTransform(transformId) { @@ -632,7 +632,7 @@ test('Three coordinator shares shaping data across technique bindings and refere [1, 2], 'the direct policy makes transform identity an authoritative Rust draw boundary', ); - const directTarget = new ThreeTextEnginePlanTarget(coordinator, { + const directTarget = new ThreeTextRenderPlanExecutor(coordinator, { drawRoot, renderOrderBase: 20, objectForTransform(transformId) { @@ -643,11 +643,11 @@ test('Three coordinator shares shaping data across technique bindings and refere }); directTarget.apply(directPublication); assert.equal(directTarget.draws.length, 2); - for (const [index, draw] of directTarget.draws.entries()) { - assert.equal(draw.geometry.getAttribute('_pmndrsText_15'), undefined); - assert.equal(draw.geometry.getAttribute('_pmndrsTextTransforms'), undefined); - assert.equal(draw.matrixAutoUpdate, false); - assert.equal(draw.matrix.elements[12], index === 0 ? 4 : 7); + for (const [index, directDraw] of directTarget.draws.entries()) { + assert.equal(directDraw.geometry.getAttribute('_pmndrsText_15'), undefined); + assert.equal(directDraw.geometry.getAttribute('_pmndrsTextTransforms'), undefined); + assert.equal(directDraw.matrixAutoUpdate, false); + assert.equal(directDraw.matrix.elements[12], index === 0 ? 4 : 7); } assert.equal(directTarget.syncTransforms(), 0); paragraphObjects.get(2).position.x = 9; @@ -675,7 +675,7 @@ test('Three coordinator shares shaping data across technique bindings and refere hybridRequestView.setUint32(requestLayout.sessionId, hybridSession.handle, true); hybridRequestView.setUint32(requestLayout.policyHandle, hybridPolicyHandle, true); const hybridInitialPublication = hybridSession.update(hybridRequest); - const hybridTarget = new ThreeTextEnginePlanTarget(coordinator, { + const hybridTarget = new ThreeTextRenderPlanExecutor(coordinator, { drawRoot, renderOrderBase: 30, objectForTransform(transformId) { diff --git a/packages/text/tests/integration/three-shader.test.mjs b/packages/text/tests/integration/three-shader.test.mjs index c32392f7..d6c5176d 100644 --- a/packages/text/tests/integration/three-shader.test.mjs +++ b/packages/text/tests/integration/three-shader.test.mjs @@ -2,9 +2,9 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { createRuntimeShaper, createTextRuntime, defineRasterTechnique, FontRegistry } from '@pmndrs/text'; +import { createRuntimeShaper, createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; -import { bitmapShader, msdfShader, registerThreeRasterProgram, slugShader, Text } from '@pmndrs/text/three'; +import { bitmapShader, defineTextMaterial, msdfShader, slugShader, Text } from '@pmndrs/text/three'; import * as TSL from 'three/tsl'; import * as THREE from 'three/webgpu'; @@ -16,45 +16,51 @@ test('the canonical technique shaders are exported as callable node builders', ( assert.equal(typeof slugShader, 'function'); }); -test('a custom Three program composes over the exported Bitmap shader in the real draw path', async () => { +test('a custom Three material composes over the Bitmap shader in the Rust command-buffer draw path', async () => { const registry = new FontRegistry(); const shaper = await createRuntimeShaper({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); const runtime = await createTextRuntime({ registry, shaper }); - const composedBitmap = defineRasterTechnique({ ...bitmap, id: 'tests.composed-bitmap' }); - const built = []; - registerThreeRasterProgram(composedBitmap, (owner) => new ComposedTarget(owner, composedBitmap, built)); - const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, - raster: { technique: composedBitmap, options: { strikes: [16] } }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const built = []; + const material = defineTextMaterial((context) => { + const composed = context.createDefaultMaterial(); + composed.colorNode = TSL.vec3(context.shader.color.r, 0, context.shader.color.b); + built.push({ context, material: composed }); + return composed; }); const scene = new THREE.Scene(); - const label = new Text({ font, text: 'Composed' }); + const label = new Text({ font, material, text: 'Composed' }); scene.add(label); scene.updateMatrixWorld(); + assert.equal(label.error, undefined); const draws = label.children.filter((child) => child.isMesh); - assert.equal(draws.length, 1, 'the custom program must produce a real draw through the retained lifecycle'); - assert.equal(built.length, 1, 'the custom program must build exactly one composed material'); - const { shader, material } = built[0]; - - assert.deepEqual( - Object.keys(shader).sort(), - ['atlasUv', 'clipPosition', 'color', 'coverage', 'opacity', 'position'], - 'the canonical Bitmap shader must return its documented named outputs', - ); - for (const [name, node] of Object.entries(shader)) { + assert.equal(draws.length, 1, 'the Rust publication must produce one real custom-material draw'); + assert.equal(built.length, 1, 'the material factory must run once for one retained realization'); + const { context, material: realized } = built[0]; + assert.equal(context.technique, bitmap.id); + assert.deepEqual(Object.keys(context.shader).sort(), [ + 'atlasUv', + 'clipPosition', + 'color', + 'coverage', + 'opacity', + 'position', + ]); + for (const [name, node] of Object.entries(context.shader)) { assert.ok(node?.isNode === true, `canonical Bitmap output "${name}" must be a TSL node`); } + assert.equal(draws[0].material, realized); - assert.equal(draws[0].material, material); - assert.equal(material.positionNode, shader.position, 'the program must reuse the canonical vertex placement'); - assert.equal(material.vertexNode, shader.clipPosition, 'the program must inherit the canonical pixel snapping'); - assert.equal(material.opacityNode, shader.opacity, 'the program must reuse the canonical coverage and paint alpha'); - assert.notEqual(material.colorNode, shader.color, 'the program must be free to emit its own final colour'); + scene.updateMatrixWorld(); + assert.equal(label.children[0], draws[0], 'an unchanged frame must retain the draw and material realization'); + assert.equal(built.length, 1); label.removeFromParent(); label.dispose(); @@ -62,112 +68,6 @@ test('a custom Three program composes over the exported Bitmap shader in the rea runtime.dispose(); }); -class ComposedTarget { - #owner; - #built; - #textures = new Map(); - - constructor(owner, technique, built) { - this.technique = technique; - this.#owner = owner; - this.#built = built; - } - - stage(_previous, next) { - const resources = new Map(); - for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch)); - const draws = []; - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]; - const resource = resources.get(run.batch); - const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material); - mesh.userData.pmndrsTextRunStart = run.start; - mesh.frustumCulled = false; - mesh.renderOrder = this.#owner.renderOrderBase + index; - draws.push({ mesh, paragraph: run.paragraph }); - } - const dispose = () => { - for (const { mesh } of draws) { - mesh.removeFromParent(); - mesh.geometry.dispose(); - } - for (const resource of resources.values()) resource.material.dispose(); - }; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - for (const { mesh, paragraph } of draws) this.#owner.objectForParagraph(paragraph).add(mesh); - return { sourceRevision: next.revision, dispose }; - }, - abort: dispose, - }, - }; - } - - dispose() { - for (const texture of this.#textures.values()) texture.dispose(); - this.#textures.clear(); - } - - #createResource(batch) { - const page = batch.font.data.strikes[batch.binding.strike].pages[batch.binding.page]; - const attributes = { - origins: storageAttribute(batch.storage.origins, 2), - sizes: storageAttribute(batch.storage.sizes, 2), - uvOrigins: storageAttribute(batch.storage.uvOrigins, 2), - uvSizes: storageAttribute(batch.storage.uvSizes, 2), - colors: storageAttribute(batch.storage.colors, 4), - }; - const instance = TSL.instanceIndex.add(TSL.uniform(0, 'uint')); - const shader = bitmapShader( - { - origin: TSL.storage(attributes.origins, 'vec2', attributes.origins.count).element(instance), - size: TSL.storage(attributes.sizes, 'vec2', attributes.sizes.count).element(instance), - uvOrigin: TSL.storage(attributes.uvOrigins, 'vec2', attributes.uvOrigins.count).element(instance), - uvSize: TSL.storage(attributes.uvSizes, 'vec2', attributes.uvSizes.count).element(instance), - color: TSL.storage(attributes.colors, 'vec4', attributes.colors.count).element(instance), - }, - { page: this.#texture(page) }, - ); - const material = new THREE.MeshBasicNodeMaterial({ transparent: true }); - material.positionNode = shader.position; - material.vertexNode = shader.clipPosition; - material.colorNode = shader.color.mul(TSL.vec3(1, 0, 0)); - material.opacityNode = shader.opacity; - this.#built.push({ shader, material }); - return { - material, - geometry(count) { - const geometry = new THREE.InstancedBufferGeometry(); - geometry.setAttribute( - 'position', - new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0], 3), - ); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); - geometry.instanceCount = count; - return geometry; - }, - }; - } - - #texture(page) { - let texture = this.#textures.get(page.resource); - if (texture !== undefined) return texture; - texture = new THREE.DataTexture(page.bytes, page.width, page.height, THREE.RedFormat, THREE.UnsignedByteType); - texture.needsUpdate = true; - this.#textures.set(page.resource, texture); - return texture; - } -} - -function storageAttribute(array, itemSize) { - const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(array), itemSize); - attribute.needsUpdate = true; - return attribute; -} - function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 0b97954f..0f6a88cb 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -34,9 +34,11 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(label.bound, true); assert.equal(label.textGroup, group); assert.equal(group.textCount, 1); - assert.ok(label.layout.glyphIds.length > 0); - const firstDraws = label.children.filter((child) => child.isMesh); + assert.equal(label.layout, undefined, 'rendering must not materialize layout readback'); + assert.equal(group.error, undefined); + const firstDraws = group.children.filter((child) => child.isMesh); assert.ok(firstDraws.length > 0); + assert.equal(firstDraws[0].geometry.instanceCount, 10, 'the GPU plan omits the non-rendering space glyph'); assert.equal(firstDraws[0].renderOrder, 12); group.renderOrder = 20; @@ -45,15 +47,15 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr label.renderOrder = 7; scene.updateMatrixWorld(); - assert.equal(label.children.filter((child) => child.isMesh)[0].renderOrder, 20); - assert.equal(label.layout.glyphIds.length, 11, 'render-order-only updates must preserve the shaped paragraph'); + assert.equal(group.children.filter((child) => child.isMesh)[0].renderOrder, 20); + assert.equal(firstDraws[0].geometry.instanceCount, 10, 'render-order-only updates must preserve the Rust plan'); label.text = 'Only the final desired value'; label.text = 'Updated'; scene.updateMatrixWorld(); - assert.equal(label.layout.glyphIds.length, 7); - assert.ok(label.children.some((child) => child.isMesh)); - assert.equal(label.children.filter((child) => child.isMesh)[0], firstDraws[0]); + assert.equal(label.layout, undefined); + assert.ok(group.children.some((child) => child.isMesh)); + assert.equal(group.children.filter((child) => child.isMesh)[0], firstDraws[0]); assert.equal( firstDraws[0].geometry.instanceCount, 7, @@ -84,6 +86,52 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr runtime.dispose(); }); +test('TextGroup realizes two public Text objects as one indexed Rust draw', async () => { + const registry = new FontRegistry(); + const shaper = await createRuntimeShaper({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const runtime = await createTextRuntime({ registry, shaper }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup({ technique: bitmap, renderOrder: 3 }); + const left = new Text({ font, text: 'AB' }); + const right = new Text({ font, text: 'CD' }); + left.position.x = 2; + right.position.x = 5; + group.add(left, right); + scene.add(group); + scene.updateMatrixWorld(); + + assert.equal(group.error, undefined); + const draws = group.children.filter((child) => child.isMesh); + assert.equal(draws.length, 1, 'compatible paragraphs must batch in Rust before Three sees the plan'); + assert.equal(draws[0].geometry.instanceCount, 4); + const start = draws[0].userData.pmndrsTextRunStart; + const indices = draws[0].geometry.getAttribute('_pmndrsText_15').array; + assert.deepEqual(Array.from(indices.subarray(start, start + 4)), [1, 1, 2, 2]); + const transforms = draws[0].geometry.getAttribute('_pmndrsTextTransforms'); + assert.equal(transforms.array[1 * 16 + 12], 2); + assert.equal(transforms.array[2 * 16 + 12], 5); + + const version = transforms.version; + right.position.x = 7; + scene.updateMatrixWorld(); + assert.equal(group.children.filter((child) => child.isMesh)[0], draws[0]); + assert.equal(transforms.version, version + 1); + assert.equal(transforms.array[2 * 16 + 12], 7); + + group.dispose(); + left.dispose(); + right.dispose(); + font.dispose(); + runtime.dispose(); +}); + function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } From c2c24b1c746c1201719dc946872798de3fec7c40 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 02:03:13 -0400 Subject: [PATCH 075/128] feat(text): let Rust plan mixed techniques --- .../targets/conformance/rich-text-spans.ts | 2 +- .../targets/product/external-raster-proof.ts | 2 +- .../benchmark/scenes/comparison-workload.ts | 5 +- docs/log.md | 6 + docs/packages/benchmarks.md | 15 +- docs/packages/text.md | 18 ++- docs/planning/decision-register.md | 2 + packages/text/src/loaded-font.ts | 56 +++++-- packages/text/src/r3f.ts | 7 +- packages/text/src/three/text.ts | 142 ++++++++---------- .../tests/integration/text-spans.test.mjs | 39 ++++- .../text/tests/integration/three-v1.test.mjs | 4 +- packages/text/tests/types/r3f-v1-api.test.ts | 2 +- .../text/tests/types/text-runtime-api.test.ts | 7 +- .../text/tests/types/three-v1-api.test.ts | 5 +- 15 files changed, 187 insertions(+), 125 deletions(-) diff --git a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts index a59cdf25..54ee0c49 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts @@ -128,7 +128,7 @@ export function createRichTextSpansConformanceTarget(): BenchmarkTarget { const scene = new THREE.Scene(); // One group so every case packs through the same batch the live workload uses, rather than through a // standalone-Text path the workload never takes. - const group = new TextGroup({ technique: bitmap, capacity: { size: 4_096, policy: 'grow' } }); + const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } }); scene.add(group); const evidence = new Map(); try { diff --git a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts index 7897b8d3..f727a291 100644 --- a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts +++ b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts @@ -151,7 +151,7 @@ async function createResources( // The caller-owned parent stays a plain `THREE.Group`: Three derives a render list's `groupOrder` from `isGroup`, // so this is the boundary that must order the whole text above the cover. The `TextGroup` inside it owns only the // text-local render-order base, which is a separate contract this target also checks. - textGroup = new TextGroup({ technique: glyphExample, renderOrder: 200 }); + textGroup = new TextGroup({ renderOrder: 200 }); textGroup.add(text); const callerGroup = new THREE.Group(); callerGroup.renderOrder = 200; diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index df9b7355..62532710 100644 --- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -606,7 +606,7 @@ async function createComparisonWorkloadRuntime( initialIconWindow?.scrollX ?? (workloadChanged ? 0 : -scene.position.x), initialIconWindow?.scrollY ?? (workloadChanged ? 0 : scene.position.y), ); - const nextRoot = createBatchRoot(next.workload, activeFont().loaded); + const nextRoot = createBatchRoot(next.workload); const scheduledAt = performance.now(); try { // The staged root is published off-scene: a TextGroup shapes, lays out, and packs its whole workload inside @@ -1085,12 +1085,11 @@ async function createComparisonWorkloadRuntime( * batch that owns one set of GPU resources. A single-paragraph workload gets a plain Group and keeps its own implicit * batch of one, which is what it already was. */ -function createBatchRoot(workload: ComparisonWorkloadId, font: WorkloadFont): THREE.Object3D { +function createBatchRoot(workload: ComparisonWorkloadId): THREE.Object3D { if (comparisonWorkloadDefinition(workload).batching === 'standalone') return new THREE.Group(); // `grow` keeps one buffer per physical resource. A chunked batch would split a paragraph's glyph run at every chunk // boundary and turn one draw into several, which would make the batched lanes look worse than the standalone one. return new TextGroup({ - technique: font.technique, capacity: { size: 4_096, policy: 'grow' }, }); } diff --git a/docs/log.md b/docs/log.md index d9d0b5f4..3ec98de2 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Removed authored technique from Three grouping and font stacks** — `createFontStack` now accepts heterogeneous + Bitmap/MSDF/Slug fonts from one runtime and preserves their technique union, while the legacy single-technique + `ParagraphBatch` rejects that union at its own boundary. `TextGroup` and its R3F wrapper no longer accept or expose a + technique. A compiled-Wasm public lifecycle fixture proves one Bitmap-root paragraph with an MSDF span is partitioned + by the Rust policy/plan into two draws and resolves one custom material factory under both technique contexts. + - **Cut imperative Three rendering over to the Rust command buffer** — Replaced the public binding's private `ParagraphBatch` plus attachment `prepare`/`commit` state machine with one retained Rust session and renderer executor. A `TextGroup` now submits every descendant paragraph in one update and owns shared draws; standalone text diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index c10c1143..a210bf7c 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:d618b88fc753aa8e14d913555e6332bee03d2033e23264b0c42d9437eb738297' +source_digest: 'sha256:39a7e521f9c8c5fd9732e49a7688ff8a1695e95d96bac53d3c61a398370af032' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -283,15 +283,16 @@ already produced, so no comparison scene names or loads a raster module. Type er `LoadedFont` is covariant in its technique, so a concrete `LoadedFont` widens to `LoadedFont` and every `Text`, `TextGroup`, and `TextUpdate` downstream is uniformly erased without a cast. Erasing at the `Text` instead does not compile: the `set` method and the `font` accessor make `Text` invariant in -its technique. +its technique. `TextGroup` no longer receives the selected technique at construction; its shared Rust session and +renderer policy derive each draw's technique from the loaded font binding. Batching is a per-workload policy on the definition rather than a host-wide rule. Text ladder, Zoom text, Icon grid, Off-axis / 3D, Dynamic layout, and Paint & effects mount under one shared `TextGroup`, so every paragraph in the workload -prepares and packs into a single batch owning one set of GPU resources; Icon grid's recycled icon and label Texts share -that batch across two font fixtures because both load through the one registry-scoped runtime. Paragraph stress stays -standalone: it is a single `Text` holding a large repeated-ipsum body, already a batch of one, and keeping it standalone -holds both adapter paths under test. The group takes a `grow` capacity because a chunked batch would split a paragraph's -glyph run at each chunk boundary and turn one draw into several. +enters one Rust frame transaction and shared render plan; compatible policy packets may share physical storage and one +draw. Icon grid's recycled icon and label Texts share that session across two font fixtures because both load through +the one registry-scoped runtime. Paragraph stress stays standalone: it is a single `Text` holding a large repeated-ipsum +body, already a session of one, and keeping it standalone holds both adapter paths under test. The group takes a `grow` +capacity so its retained staging and output regions settle for the workload rather than repeatedly growing. Batching shares preparation and GPU resources, not draws. Target-v1 emits one mesh per packed glyph run and a run never spans paragraphs, so the shared group leaves the draw topology of each paragraph exactly as it was. Measured at the diff --git a/docs/packages/text.md b/docs/packages/text.md index b59b6da0..13405275 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:57721f96d448668e6e1382d7e3db7bf01d288dbc93a5d4828ae3f88c11689536' +source_digest: 'sha256:9dfe0999d0f417e5e8b3ec5756fdf0e3e3a51a830538b3ea0f44c1ca2f4e94fe' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -921,9 +921,19 @@ standalone `Text` owns the same path with a private session. Rust receives parag publishes one command-buffer delta, and compatible group children become one indexed draw under the group root. The executor retains only renderer resources needed to apply later deltas. It does not receive paragraph layout arrays; Three's old layout, glyph-snapshot, and glyph-origin override surface is removed. Any later interaction or measurement -API must query retained Rust layout state separately and on demand. Focused integration covers mixed-font spans, custom material factories, two-child -indexed batching, renderer-local transform updates, reparenting, and disposal. R3F, TypeGPU, the portable legacy core, -and mixed-technique public font stacks still own the remaining cutover and deletion work. +API must query retained Rust layout state separately and on demand. Focused integration covers mixed-font spans, custom +material factories, two-child indexed batching, renderer-local transform updates, reparenting, and disposal. R3F, +TypeGPU, and the portable legacy core still own the remaining cutover and deletion work. + +Public font stacks no longer repeat a raster technique or require every fallback font to share one. Their generic type +is the union of the concrete loaded-font techniques, while runtime construction still requires one text-runtime domain, +unique font identities, and live font leases. Public Three `TextGroup` likewise has no authored technique: its retained +Rust session resolves each selected glyph's actual font binding and the policy partitions the plan by supported +technique, resource, program, material, and transform. A compiled-Wasm public lifecycle fixture uses one paragraph with +a Bitmap root stack and an explicit MSDF span, observes both canonical material contexts, and realizes two draws from +one Rust publication. The older renderer-neutral `ParagraphBatch` remains deliberately single-technique until it is +removed; its type and runtime boundary reject a heterogeneous stack rather than silently selecting the primary font's +technique. The executor now bounds CPU/GPU realization residency from Rust retirement records. Retiring a physical buffer disposes only materials that depend on its exact generation; retiring a plan resource disposes its technique texture only after diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 5ba50d62..2f12dc11 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -304,6 +304,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-222 | Public Three `TextGroup` and standalone `Text` now render through one retained Rust engine session and the Rust command-buffer executor rather than constructing a TypeScript `ParagraphBatch`, attaching a target, and negotiating `prepare`/`commit` revisions. A group owns one session for every descendant paragraph and realizes compatible children as one indexed draw beneath the shared group root; standalone text owns the same path with its own draw root. Authored `material` definitions are interned to `materialId` on root text, group inheritance, or spans and resolved only by the renderer. Scene-transform and group render-order changes remain renderer-local. Rendering requests no paragraph layout arrays, so Three's legacy `layout`, glyph snapshot, and glyph-origin override surface is removed; any future measurement, caret, selection, or hit-test surface must be a separate demand-shaped Rust query rather than a render-plan table. Focused compiled-Wasm integration proves shared two-paragraph batching, mixed-font span draws, retained custom-material realization, reparenting, and disposal. The old TypeScript paragraph/batch implementation remains only for non-cutover core/TypeGPU/R3F consumers and is scheduled for deletion after those public paths move. | Accepted | +| D-223 | Raster technique is owned by each loaded font binding and is no longer repeated by `FontStack`, imperative Three `Text`, or `TextGroup`. `createFontStack` accepts unique live fonts from one runtime and preserves the union of their techniques in its type. The Rust engine resolves the actual binding per glyph; the renderer policy decides whether and how each technique is lowered, and the render plan supplies the resulting storage/draw partition. Three leases heterogeneous selections by runtime rather than enforcing one technique, while the legacy `ParagraphBatch` retains its explicit single-technique boundary and rejects a heterogeneous union. A compiled-Wasm public fixture realizes one Bitmap-root paragraph with an MSDF span as two policy-selected draws and invokes the same `material` factory under both canonical technique contexts. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/loaded-font.ts b/packages/text/src/loaded-font.ts index d357c6c6..fe539f24 100644 --- a/packages/text/src/loaded-font.ts +++ b/packages/text/src/loaded-font.ts @@ -16,10 +16,11 @@ export interface LoadedFont { export type FontSelection = LoadedFont | FontStack; export interface FontStack { - readonly technique: Technique; readonly fonts: readonly [LoadedFont, ...LoadedFont[]]; } +type TechniqueOfLoadedFont = Font extends LoadedFont ? Technique : never; + export class FontLeaseError extends Error { readonly leaseCount: number; @@ -39,11 +40,15 @@ interface LoadedFontState { const loadedFontState = new WeakMap, LoadedFontState>(); -export function createFontStack( - primary: LoadedFont, - ...fallback: readonly LoadedFont>[] -): FontStack { - const fonts = [primary, ...fallback] as [LoadedFont, ...LoadedFont[]]; +export function createFontStack< + Primary extends AnyRasterTechnique, + const Fallback extends readonly LoadedFont[], +>( + primary: LoadedFont, + ...fallback: Fallback +): FontStack> { + type Technique = Primary | TechniqueOfLoadedFont; + const fonts = [primary, ...fallback] as unknown as [LoadedFont, ...LoadedFont[]]; assertLoadedFont(primary); const unique = new Set>([primary]); for (const font of fallback) { @@ -52,10 +57,8 @@ export function createFontStack( unique.add(font); if (font.runtime !== primary.runtime) throw new TypeError('font stack members must belong to the same text runtime'); - if (font.technique !== primary.technique) - throw new TypeError('font stack members must use the same raster technique'); } - return Object.freeze({ technique: primary.technique, fonts: Object.freeze(fonts) }); + return Object.freeze({ fonts: Object.freeze(fonts) }); } export class LoadedFontImpl implements LoadedFont { @@ -119,6 +122,24 @@ export function acquireFontSelection( } } +/** @internal Acquire one renderer lease without imposing a single raster technique on the selection. */ +export function acquireFontSelectionForRuntime( + selection: FontSelection, + runtime: TextRuntime, +): void { + const acquired: LoadedFont[] = []; + try { + for (const font of concreteFonts(selection)) { + assertFontForRuntime(font, runtime); + stateOf(font).leases += 1; + acquired.push(font); + } + } catch (error) { + for (const font of acquired) stateOf(font).leases -= 1; + throw error; + } +} + /** @internal Release one retained paragraph lease from every concrete font. */ export function releaseFontSelection(selection: FontSelection): void { for (const font of concreteFonts(selection)) { @@ -134,12 +155,17 @@ export function assertFontSelection( runtime: TextRuntime, technique: Technique, ): void { - if (isFontStack(selection) && selection.technique !== technique) { - throw new TypeError('font stack does not use the paragraph batch technique'); - } for (const font of concreteFonts(selection)) assertCompatibleFont(font, runtime, technique); } +/** @internal Validate renderer ownership without imposing a single raster technique on the selection. */ +export function assertFontSelectionForRuntime( + selection: FontSelection, + runtime: TextRuntime, +): void { + for (const font of concreteFonts(selection)) assertFontForRuntime(font, runtime); +} + /** @internal Return the immutable concrete fallback order. */ export function concreteFonts( selection: FontSelection, @@ -184,9 +210,13 @@ function assertCompatibleFont( runtime: TextRuntime, technique: Technique, ): void { + assertFontForRuntime(font, runtime); + if (font.technique !== technique) throw new TypeError('font does not use the paragraph batch technique'); +} + +function assertFontForRuntime(font: LoadedFont, runtime: TextRuntime): void { assertLoadedFont(font); if (font.runtime !== runtime) throw new TypeError('font belongs to another text runtime'); - if (font.technique !== technique) throw new TypeError('font does not use the paragraph batch technique'); } function assertLoadedFont(font: LoadedFont): void { diff --git a/packages/text/src/r3f.ts b/packages/text/src/r3f.ts index 5b61f0f7..8ea6b6e7 100644 --- a/packages/text/src/r3f.ts +++ b/packages/text/src/r3f.ts @@ -146,7 +146,6 @@ export function TextGroup { const created = new ThreeTextGroup({ - technique: properties.technique, ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), ...(properties.renderOrder === undefined ? {} : { renderOrder: properties.renderOrder }), ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), @@ -168,13 +167,11 @@ export function TextGroup { if (object === undefined) return; - if (properties.technique !== object.technique) - throw new TypeError('changing an R3F TextGroup technique requires a new keyed component'); if (properties.capacity !== undefined && !sameCapacity(properties.capacity, object)) object.setCapacity(properties.capacity); object.setRenderVariant(properties.renderVariant); invalidate(); - }, [invalidate, object, properties.capacity, properties.renderVariant, properties.technique]); + }, [invalidate, object, properties.capacity, properties.renderVariant]); useLayoutEffect(() => { onErrorRef.current = properties.onError; @@ -352,7 +349,7 @@ function groupObjectProperties( properties: R3fTextGroupProps, ): Object3DProps { const object = { ...properties } as Record; - for (const key of ['technique', 'capacity', 'renderVariant', 'children', 'onError', 'ref']) delete object[key]; + for (const key of ['capacity', 'renderVariant', 'children', 'onError', 'ref']) delete object[key]; return object as Object3DProps; } diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 366f12c3..2a3727f5 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -2,8 +2,8 @@ import * as THREE from 'three/webgpu'; import type { FormattedText, GlyphPaintInput, ParagraphSpan, TextInput } from '../formatted-text.js'; import { - acquireFontSelection, - assertFontSelection, + acquireFontSelectionForRuntime, + assertFontSelectionForRuntime, concreteFonts, releaseFontSelection, type FontSelection, @@ -64,29 +64,16 @@ export type TextUpdate & Readonly<{ material?: ThreeTextMaterial }>; -export interface TextGroupOptions { - readonly technique: Technique; +export interface TextGroupOptions< + _Technique extends AnyRasterTechnique = AnyRasterTechnique, + Variant = ThreeRenderVariant, +> { readonly capacity?: GlyphBufferCapacity; readonly renderOrder?: number; readonly renderVariant?: Variant; readonly material?: ThreeTextMaterial; } -type SameType = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; -type CompatibleTextChildren< - Technique extends AnyRasterTechnique, - Variant, - Children extends readonly THREE.Object3D[], -> = { - readonly [Index in keyof Children]: Children[Index] extends Text - ? SameType extends true - ? SameType extends true - ? Children[Index] - : never - : never - : Children[Index]; -}; - interface DesiredTextState { readonly font: FontSelection; readonly text: string; @@ -101,12 +88,11 @@ interface DesiredTextState { export class Text extends THREE.Object3D { readonly #runtime: TextRuntime; - readonly #technique: Technique; #desired: DesiredTextState; #leasedFonts: readonly LoadedFont[]; #standaloneCapacity: GlyphBufferCapacity; - #binding: ThreeTextBatchBinding | undefined; - #textGroup: TextGroup | undefined; + #binding: ThreeTextBatchBinding | undefined; + #textGroup: TextGroup | undefined; #desiredRevision = 0; #appliedRevision = -1; #disposed = false; @@ -118,14 +104,13 @@ export class Text | undefined { + get textGroup(): TextGroup | undefined { return this.#textGroup; } get bound(): boolean { @@ -202,7 +187,7 @@ export class Text); const fonts = selectedFonts(next); - acquireFonts(fonts, this.#runtime, this.#technique); + acquireFonts(fonts, this.#runtime); releaseFonts(this.#leasedFonts); this.#leasedFonts = fonts; this.#desired = next; @@ -244,13 +229,13 @@ export class Text, group: TextGroup | undefined): void { + bind(binding: ThreeTextBatchBinding, group: TextGroup | undefined): void { if (this.#binding !== binding) this.#unbind(); this.#binding = binding; this.#textGroup = group; this.#appliedRevision = this.#desiredRevision; } - unbindFrom(binding: ThreeTextBatchBinding): void { + unbindFrom(binding: ThreeTextBatchBinding): void { if (this.#binding !== binding) return; this.#binding = undefined; this.#textGroup = undefined; @@ -299,36 +284,33 @@ export class Text extends THREE.Object3D { - readonly technique: Technique; +export class TextGroup< + Technique extends AnyRasterTechnique = AnyRasterTechnique, + Variant = ThreeRenderVariant, +> extends THREE.Object3D { #capacity: GlyphBufferCapacity; #renderVariant: Variant | undefined; #material: ThreeTextMaterial | undefined; - #binding: ThreeTextBatchBinding | undefined; + #binding: ThreeTextBatchBinding | undefined; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; - constructor(options: TextGroupOptions) { + constructor(options: TextGroupOptions = {}) { super(); - if (options?.technique === undefined) throw new TypeError('TextGroup requires a raster technique'); - this.technique = options.technique; this.#capacity = normalizeCapacity(options.capacity ?? { size: 4_096, policy: 'chunk' }); this.#renderVariant = options.renderVariant; this.#material = options.material; @@ -367,12 +349,11 @@ export class TextGroup( - ...children: CompatibleTextChildren - ): this { + override add(...children: THREE.Object3D[]): this { this.#assertActive(); - for (const child of children) if (child instanceof Text) validateText(this, child as Text); - return super.add(...(children as readonly THREE.Object3D[])); + for (const child of children) + if (child instanceof Text) validateText(child as Text); + return super.add(...children); } setCapacity(capacity: GlyphBufferCapacity): void { this.#assertActive(); @@ -395,8 +376,8 @@ export class TextGroup(this); if (texts.length !== 0) { const first = texts[0]!; - validateText(this, first); - this.#binding ??= new ThreeTextBatchBinding(first.runtime, this.technique, this.#capacity, this); + validateText(first); + this.#binding ??= new ThreeTextBatchBinding(first.runtime, this.#capacity, this); this.#binding.reconcile(texts); try { this.#binding.synchronize(); @@ -419,9 +400,9 @@ export class TextGroup): void { + bindText(text: Text): void { if (this.#disposed) return; - validateText(this, text); + validateText(text); } dispose(): void { if (this.#disposed) return; @@ -445,14 +426,14 @@ interface RetainedEngineParagraph { materialLeases: ThreeTextMaterialLease[]; } -class ThreeTextBatchBinding { +class ThreeTextBatchBinding { readonly #runtime: TextRuntime; - readonly #group: TextGroup | undefined; + readonly #group: TextGroup | undefined; readonly #coordinator: ThreeTextEngineCoordinator; readonly #session: TextEngineSession; readonly #target: ThreeTextRenderPlanExecutor; - readonly #paragraphs = new Map, RetainedEngineParagraph>(); - readonly #textsByParagraph = new Map>(); + readonly #paragraphs = new Map, RetainedEngineParagraph>(); + readonly #textsByParagraph = new Map>(); readonly #removed: RetainedEngineParagraph[] = []; #nextParagraphId = 1; #engineRevision = 0; @@ -467,9 +448,8 @@ class ThreeTextBatchBinding { constructor( runtime: TextRuntime, - _technique: Technique, capacity: GlyphBufferCapacity, - group: TextGroup | undefined, + group: TextGroup | undefined, ) { this.#runtime = runtime; this.#group = group; @@ -509,12 +489,12 @@ class ThreeTextBatchBinding { get renderOrderBase(): number { return this.#group?.renderOrder ?? 0; } - reconcile(texts: readonly Text[]): void { + reconcile(texts: readonly Text[]): void { const desired = new Set(texts); for (const text of [...this.#paragraphs.keys()]) if (!desired.has(text)) this.removeText(text); for (const text of texts) this.#ensureText(text, this.#group); } - reconcileStandalone(text: Text): void { + reconcileStandalone(text: Text): void { this.#ensureText(text, undefined); } synchronize(): void { @@ -657,7 +637,7 @@ class ThreeTextBatchBinding { this.#acknowledgedPublicationGeneration = publication.publicationGeneration; this.#lastPublication = undefined; } - removeText(text: Text): void { + removeText(text: Text): void { const paragraph = this.#paragraphs.get(text); if (paragraph === undefined) return; this.#paragraphs.delete(text); @@ -679,8 +659,11 @@ class ThreeTextBatchBinding { this.#textsByParagraph.clear(); this.#removed.length = 0; } - #ensureText(text: Text, group: TextGroup | undefined): void { - validateBinding(this.#runtime, text.technique, text); + #ensureText( + text: Text, + group: TextGroup | undefined, + ): void { + validateBinding(this.#runtime, text); let paragraph = this.#paragraphs.get(text); if (paragraph === undefined) { const id = this.#nextParagraphId++; @@ -702,7 +685,7 @@ class ThreeTextBatchBinding { #drawRoot(): THREE.Object3D { if (this.#group !== undefined) return this.#group; - const text = this.#paragraphs.keys().next().value as Text | undefined; + const text = this.#paragraphs.keys().next().value; if (text === undefined) throw new Error('standalone text command buffer has no draw root'); return text; } @@ -979,12 +962,11 @@ function selectedFonts( function acquireFonts( fonts: readonly LoadedFont[], runtime: TextRuntime, - technique: Technique, ): void { const acquired: LoadedFont[] = []; try { for (const font of fonts) { - acquireFontSelection(font, runtime, technique); + acquireFontSelectionForRuntime(font, runtime); acquired.push(font); } } catch (error) { @@ -1004,39 +986,39 @@ function normalizeCapacity(value: GlyphBufferCapacity): GlyphBufferCapacity { } function nearestTextGroup( object: THREE.Object3D, -): TextGroup | undefined { +): TextGroup | undefined { let parent = object.parent; while (parent !== null) { - if (parent instanceof TextGroup) return parent as TextGroup; + if (parent instanceof TextGroup) return parent as TextGroup; parent = parent.parent; } return undefined; } +function eraseTextTechnique( + text: Text, +): Text { + return text as unknown as Text; +} function collectTextDescendants( group: TextGroup, -): Text[] { - const texts: Text[] = []; +): Text[] { + const texts: Text[] = []; for (const child of group.children) collect(child, texts); return texts; - function collect(object: THREE.Object3D, result: Text[]): void { + function collect(object: THREE.Object3D, result: Text[]): void { if (object instanceof TextGroup) return; - if (object instanceof Text) result.push(object as Text); + if (object instanceof Text) result.push(object as Text); for (const child of object.children) collect(child, result); } } -function validateText( - group: TextGroup, - text: Text, -): void { - validateBinding(text.runtime, group.technique, text); +function validateText(text: Text): void { + validateBinding(text.runtime, text); } -function validateBinding( +function validateBinding( runtime: TextRuntime, - technique: Technique, - text: Text, + text: Text, ): void { if (text.disposed) throw new TypeError('disposed text cannot be attached'); if (text.runtime !== runtime) throw new TypeError('text belongs to another Three font-cache domain'); - if (text.technique !== technique) throw new TypeError('text uses another raster technique'); - assertFontSelection(text.font, text.runtime, text.technique); + assertFontSelectionForRuntime(text.font, text.runtime); } diff --git a/packages/text/tests/integration/text-spans.test.mjs b/packages/text/tests/integration/text-spans.test.mjs index 1edef401..a3bd223d 100644 --- a/packages/text/tests/integration/text-spans.test.mjs +++ b/packages/text/tests/integration/text-spans.test.mjs @@ -14,7 +14,7 @@ import { } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; import { msdf } from '@pmndrs/text/three/msdf'; -import { Text, TextGroup } from '@pmndrs/text/three'; +import { defineTextMaterial, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; const interUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); @@ -189,7 +189,7 @@ test('Three Text shapes and draws a formatted literal through the real render li const [inter, devanagari] = await Promise.all([loadInter(runtime), loadDevanagari(runtime)]); const scene = new THREE.Scene(); - const group = new TextGroup({ technique: bitmap }); + const group = new TextGroup(); const warning = span(devanagari, { color: '#ff00ff', fontSize: 18 }); const label = new Text({ font: createFontStack(inter, devanagari), text: txt`Alert ${warning`देव`}!` }); group.add(label); @@ -237,6 +237,41 @@ test('Three Text shapes and draws a formatted literal through the real render li runtime.dispose(); }); +test('Three realizes mixed raster techniques from one Rust-planned paragraph', async () => { + const runtime = await createBitmapRuntime(); + const [bitmapInter, msdfInter] = await Promise.all([loadInter(runtime), loadMsdfInter(runtime)]); + const techniques = []; + const material = defineTextMaterial((context) => { + techniques.push(context.technique); + return context.createDefaultMaterial(); + }); + const label = new Text({ + font: createFontStack(bitmapInter, msdfInter), + text: 'AB', + spans: [{ start: 1, end: 2, font: msdfInter }], + material, + }); + const group = new TextGroup(); + group.add(label); + const scene = new THREE.Scene(); + scene.add(group); + scene.updateMatrixWorld(); + + assert.equal(group.error, undefined); + assert.deepEqual(new Set(techniques), new Set([bitmap.id, msdf.id])); + assert.equal( + group.children.filter((child) => child.isMesh).length, + 2, + 'the Rust plan must split one paragraph into renderer draws for both selected techniques', + ); + + group.dispose(); + label.dispose(); + bitmapInter.dispose(); + msdfInter.dispose(); + runtime.dispose(); +}); + test('a span keeps every surrounding paint property it does not state', async () => { const runtime = await createBitmapRuntime(); const inter = await loadMsdfInter(runtime); diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 0f6a88cb..95cf318d 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -22,7 +22,7 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr }); const scene = new THREE.Scene(); - const group = new TextGroup({ technique: bitmap, renderOrder: 12 }); + const group = new TextGroup({ renderOrder: 12 }); const container = new THREE.Object3D(); const label = new Text({ font, text: 'First frame' }); container.add(label); @@ -98,7 +98,7 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn raster: { technique: bitmap, options: { strikes: [16] } }, }); const scene = new THREE.Scene(); - const group = new TextGroup({ technique: bitmap, renderOrder: 3 }); + const group = new TextGroup({ renderOrder: 3 }); const left = new Text({ font, text: 'AB' }); const right = new Text({ font, text: 'CD' }); left.position.x = 2; diff --git a/packages/text/tests/types/r3f-v1-api.test.ts b/packages/text/tests/types/r3f-v1-api.test.ts index 57afb1e7..3974fec1 100644 --- a/packages/text/tests/types/r3f-v1-api.test.ts +++ b/packages/text/tests/types/r3f-v1-api.test.ts @@ -10,7 +10,7 @@ declare const mtsdfFont: LoadedFont; const inline = createElement(Text, { paint: { color: '#ff00ff' } }, 'span'); const label = createElement(Text, { font: bitmapFont }, 'Typed ', inline); -const labels = createElement(TextGroup, { technique: bitmap }, label); +const labels = createElement(TextGroup, null, label); function FontConsumer(): null { const loaded: LoadedFont = useFont({ diff --git a/packages/text/tests/types/text-runtime-api.test.ts b/packages/text/tests/types/text-runtime-api.test.ts index 7afc65bc..819f9b90 100644 --- a/packages/text/tests/types/text-runtime-api.test.ts +++ b/packages/text/tests/types/text-runtime-api.test.ts @@ -17,9 +17,7 @@ declare const bitmapFallback: LoadedFont; declare const mtsdfFont: LoadedFont; const uiFont = createFontStack(bitmapFont, bitmapFallback); - -// @ts-expect-error A FontStack cannot mix raster techniques. -createFontStack(bitmapFont, mtsdfFont); +const mixedRasterFont = createFontStack(bitmapFont, mtsdfFont); const labels = runtime.createParagraphBatch({ technique: bitmap, @@ -27,6 +25,9 @@ const labels = runtime.createParagraphBatch({ renderVariant: 'plain' as 'plain' | 'warning', }); +// @ts-expect-error The legacy renderer-neutral batch still owns one raster technique. +labels.add({ font: mixedRasterFont, text: 'Mixed techniques require a render-plan integration' }); + const warning = span(bitmapFallback, { color: '#ff00ff', fontSize: 18 }); const label: Paragraph = labels.add({ font: uiFont, diff --git a/packages/text/tests/types/three-v1-api.test.ts b/packages/text/tests/types/three-v1-api.test.ts index 7382fe4f..692bcc66 100644 --- a/packages/text/tests/types/three-v1-api.test.ts +++ b/packages/text/tests/types/three-v1-api.test.ts @@ -8,14 +8,13 @@ declare const mtsdfFont: LoadedFont; const emphasis = span(bitmapFont, { color: '#ff00ff' }); const label = new Text({ font: bitmapFont, text: txt`Typed ${emphasis`span`}` }); -const labels = new TextGroup({ technique: bitmap }); +const labels = new TextGroup(); labels.add(label); label.text = 'Updated'; label.setCapacity({ size: 64, policy: 'grow' }); labels.setCapacity({ size: 4_096, policy: 'chunk' }); -// @ts-expect-error Directly adding another technique is rejected statically. -labels.add(new Text({ font: mtsdfFont, text: 'Wrong technique' })); +labels.add(new Text({ font: mtsdfFont, text: 'Mixed technique' })); const loader = new FontLoader(); const loaded = loader.loadAsync({ From ab51f6e38b57c2a68847fa064ae1f995618a51a3 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 02:12:24 -0400 Subject: [PATCH 076/128] refactor(text): make material the renderer contract --- .../targets/conformance/rich-text-spans.ts | 4 +- .../targets/product/external-raster-proof.ts | 4 +- .../benchmark/scenes/comparison-workload.ts | 2 +- .../src/workloads/shared/scene-entry.ts | 2 +- docs/log.md | 5 + docs/packages/benchmarks.md | 2 +- docs/packages/text.md | 6 +- docs/planning/decision-register.md | 2 + packages/text/src/r3f.ts | 117 +++++----- packages/text/src/three.ts | 1 - packages/text/src/three/text.ts | 206 +++++++----------- packages/text/tests/types/r3f-v1-api.test.ts | 9 +- 12 files changed, 163 insertions(+), 197 deletions(-) diff --git a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts index 54ee0c49..090c3720 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts @@ -128,7 +128,7 @@ export function createRichTextSpansConformanceTarget(): BenchmarkTarget { const scene = new THREE.Scene(); // One group so every case packs through the same batch the live workload uses, rather than through a // standalone-Text path the workload never takes. - const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } }); + const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } }); scene.add(group); const evidence = new Map(); try { @@ -259,7 +259,7 @@ export function createRichTextSpansConformanceTarget(): BenchmarkTarget { } function measureCase( - group: TextGroup, + group: TextGroup, body: LoadedFont, companions: RichTextCompanionFonts, caseId: RichTextCaseId, diff --git a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts index f727a291..51220397 100644 --- a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts +++ b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts @@ -36,7 +36,7 @@ interface ExternalRasterResources { readonly scene: THREE.Scene; readonly camera: THREE.OrthographicCamera; readonly text: Text; - readonly textGroup: TextGroup; + readonly textGroup: TextGroup; readonly font: LoadedFont; readonly orderingGeometry: THREE.PlaneGeometry; readonly orderingMaterial: THREE.MeshBasicNodeMaterial; @@ -102,7 +102,7 @@ async function createResources( const renderer = borrowedRenderer ?? ownedRenderer!; let target: THREE.RenderTarget | undefined; let text: Text | undefined; - let textGroup: TextGroup | undefined; + let textGroup: TextGroup | undefined; let font: LoadedFont | undefined; let orderingGeometry: THREE.PlaneGeometry | undefined; let orderingMaterial: THREE.MeshBasicNodeMaterial | undefined; diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index 62532710..c7d0cfb5 100644 --- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -1089,7 +1089,7 @@ function createBatchRoot(workload: ComparisonWorkloadId): THREE.Object3D { if (comparisonWorkloadDefinition(workload).batching === 'standalone') return new THREE.Group(); // `grow` keeps one buffer per physical resource. A chunked batch would split a paragraph's glyph run at every chunk // boundary and turn one draw into several, which would make the batched lanes look worse than the standalone one. - return new TextGroup({ + return new TextGroup({ capacity: { size: 4_096, policy: 'grow' }, }); } diff --git a/apps/benchmarks/src/workloads/shared/scene-entry.ts b/apps/benchmarks/src/workloads/shared/scene-entry.ts index 73dd55ea..4c3c7e12 100644 --- a/apps/benchmarks/src/workloads/shared/scene-entry.ts +++ b/apps/benchmarks/src/workloads/shared/scene-entry.ts @@ -9,7 +9,7 @@ import type * as THREE from 'three/webgpu'; */ export type WorkloadFont = LoadedFont; export type WorkloadText = Text; -export type WorkloadTextGroup = TextGroup; +export type WorkloadTextGroup = TextGroup; export interface MutableSpanPaint { color: string; diff --git a/docs/log.md b/docs/log.md index 3ec98de2..ca8e74e9 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,11 @@ ## 2026-08-09 +- **Completed material naming through Three and R3F** — Removed the obsolete `ThreeRenderVariant` generic and every + `renderVariant` property, setter, span field, comparison, and no-op binding hook from the command-buffer-backed public + adapters. `material` is now the sole authored name through numeric Rust `materialId` and renderer factory realization; + legacy core/TypeGPU variants remain scoped to the path awaiting deletion. + - **Removed authored technique from Three grouping and font stacks** — `createFontStack` now accepts heterogeneous Bitmap/MSDF/Slug fonts from one runtime and preserves their technique union, while the legacy single-technique `ParagraphBatch` rejects that union at its own boundary. `TextGroup` and its R3F wrapper no longer accept or expose a diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index a210bf7c..eb42d146 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:39a7e521f9c8c5fd9732e49a7688ff8a1695e95d96bac53d3c61a398370af032' +source_digest: 'sha256:48ea94c9335c87ef013897164597d697aef46a0cea7615d559e53ad924f65d4c' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/text.md b/docs/packages/text.md index 13405275..98e93de4 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:9dfe0999d0f417e5e8b3ec5756fdf0e3e3a51a830538b3ea0f44c1ca2f4e94fe' +source_digest: 'sha256:c5708dd665b5ede78cde985af8dea48c4c5474fd745104310822f133b7313eb4' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -913,7 +913,9 @@ instances and Three owns their disposal. The compiled-Wasm fixture proves distin storage, reorder and coalescing reuse cached materials, and the same selected factory is instantiated once for each of Bitmap, MSDF, and Slug. Public `Text`, inherited `TextGroup`, and explicit span material properties now acquire those numeric identities and flow through the same Rust publication; an unchanged frame retains both its draw and realized -material. Fence-bounded retirement remains part of the live backend gate. +material. Imperative Three and R3F expose only `material`; their obsolete `renderVariant` generic, properties, setters, +and span cascade are removed rather than remaining as no-op compatibility state. Fence-bounded retirement remains part +of the live backend gate. The imperative Three binding no longer constructs a TypeScript `ParagraphBatch` or drives the attachment `prepare`/`commit` state machine. One `TextGroup` owns one retained Rust session for all descendant text paragraphs; diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 2f12dc11..d11031fc 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -306,6 +306,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-223 | Raster technique is owned by each loaded font binding and is no longer repeated by `FontStack`, imperative Three `Text`, or `TextGroup`. `createFontStack` accepts unique live fonts from one runtime and preserves the union of their techniques in its type. The Rust engine resolves the actual binding per glyph; the renderer policy decides whether and how each technique is lowered, and the render plan supplies the resulting storage/draw partition. Three leases heterogeneous selections by runtime rather than enforcing one technique, while the legacy `ParagraphBatch` retains its explicit single-technique boundary and rejects a heterogeneous union. A compiled-Wasm public fixture realizes one Bitmap-root paragraph with an MSDF span as two policy-selected draws and invokes the same `material` factory under both canonical technique contexts. | Accepted | +| D-224 | The Three and R3F command-buffer surfaces complete D-167's naming cutover: `material` is the only authored renderer customization property from group/text/span input through numeric Rust `materialId` and renderer factory realization. Their `ThreeRenderVariant` generic, `renderVariant` properties, setters, comparison logic, and no-op binding hook are deleted. The legacy portable core and TypeGPU variant state remains only until those implementations move to the Rust plan; it is not re-exported through the cut-over renderer APIs. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/text/src/r3f.ts b/packages/text/src/r3f.ts index 8ea6b6e7..760c58d2 100644 --- a/packages/text/src/r3f.ts +++ b/packages/text/src/r3f.ts @@ -14,7 +14,7 @@ import { type Ref, } from 'react'; -import type { GlyphPaintInput, ParagraphSpan } from './formatted-text.js'; +import type { GlyphPaintInput } from './formatted-text.js'; import type { FontSelection, LoadedFont } from './loaded-font.js'; import type { ParagraphContentBox } from './paragraph-batch.js'; import type { ParagraphStyle } from './paragraph.js'; @@ -26,49 +26,50 @@ import { TextGroup as ThreeTextGroup, type StandaloneTextProperties, type TextGroupOptions, - type ThreeRenderVariant, + type TextSpan, + type ThreeTextMaterial, } from './three.js'; type Object3DProps = Omit; -export type R3fTextChild = +export type R3fTextChild = | string | number | null | false - | ReactElement> - | readonly R3fTextChild[]; + | ReactElement> + | readonly R3fTextChild[]; -export type R3fTextProps = Object3DProps & { +export type R3fTextProps = Object3DProps & { readonly font?: FontSelection; - readonly children?: R3fTextChild; + readonly children?: R3fTextChild; readonly contentBox?: ParagraphContentBox; readonly style?: ParagraphStyle; readonly paint?: GlyphPaintInput; readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; - readonly capacity?: StandaloneTextProperties['capacity']; + readonly material?: ThreeTextMaterial; + readonly capacity?: StandaloneTextProperties['capacity']; readonly onError?: ((error: unknown) => void) | undefined; - readonly ref?: Ref>; + readonly ref?: Ref>; }; -export type R3fTextGroupProps = Object3DProps & - TextGroupOptions & { +export type R3fTextGroupProps = Object3DProps & + TextGroupOptions & { readonly children?: ReactNode; readonly onError?: ((error: unknown) => void) | undefined; - readonly ref?: Ref>; + readonly ref?: Ref; }; -interface FlattenedText { +interface FlattenedText { readonly text: string; - readonly spans: readonly ParagraphSpan[]; + readonly spans: readonly TextSpan[]; } -interface InlineProperties { +interface InlineProperties { readonly font?: FontSelection; readonly style?: ParagraphStyle; readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; + readonly material?: ThreeTextMaterial; } interface UseFont { @@ -82,21 +83,21 @@ const fontPromises = new Map>>(); const techniqueIds = new WeakMap(); let nextTechniqueId = 1; -export function Text( - input: R3fTextProps, +export function Text( + input: R3fTextProps, ): ReactElement | null { const { ref: forwardedRef, ...properties } = input; - const flattened = useMemo(() => flattenText(properties.children), [properties.children]); + const flattened = useMemo(() => flattenText(properties.children), [properties.children]); const desired = textProperties(properties, flattened); const appliedRef = useRef(undefined); const capacityRef = useRef(properties.capacity); - const [store] = useState(() => createObjectStore>()); + const [store] = useState(() => createObjectStore>()); const object = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); const invalidate = useThree((state) => state.invalidate); const onErrorRef = useRef(properties.onError); const createObject = useEffectEvent(() => { if (desired.font === undefined) throw new TypeError('an outer R3F Text requires a font'); - const created = new ThreeText(desired as StandaloneTextProperties); + const created = new ThreeText(desired as StandaloneTextProperties); created.onError = (error: unknown) => onErrorRef.current?.(error); appliedRef.current = desired; return created; @@ -117,7 +118,7 @@ export function Text); + object.set(update as StandaloneTextProperties); appliedRef.current = desired; } if (capacity !== undefined && !sameCapacity(capacity, capacityRef.current)) object.setCapacity(capacity); @@ -136,19 +137,17 @@ export function Text( - input: R3fTextGroupProps, -): ReactElement | null { +export function TextGroup(input: R3fTextGroupProps): ReactElement | null { const { ref: forwardedRef, ...properties } = input; - const [store] = useState(() => createObjectStore>()); + const [store] = useState(() => createObjectStore()); const object = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); const invalidate = useThree((state) => state.invalidate); const onErrorRef = useRef(properties.onError); const createObject = useEffectEvent(() => { - const created = new ThreeTextGroup({ + const created = new ThreeTextGroup({ ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), ...(properties.renderOrder === undefined ? {} : { renderOrder: properties.renderOrder }), - ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), + ...(properties.material === undefined ? {} : { material: properties.material }), }); created.onError = (error: unknown) => onErrorRef.current?.(error); return created; @@ -169,9 +168,9 @@ export function TextGroup { onErrorRef.current = properties.onError; @@ -257,14 +256,14 @@ function fontRequestKey(request: LoadedFon return JSON.stringify([input, techniqueId, request.raster.options ?? null]); } -function flattenText( - children: R3fTextChild | undefined, -): FlattenedText { +function flattenText( + children: R3fTextChild | undefined, +): FlattenedText { const chunks: string[] = []; - const spans: ParagraphSpan[] = []; + const spans: TextSpan[] = []; let length = 0; - const append = (child: R3fTextChild, inherited: InlineProperties): void => { + const append = (child: R3fTextChild, inherited: InlineProperties): void => { if (child === null || child === false) return; if (typeof child === 'string' || typeof child === 'number') { const value = String(child); @@ -276,7 +275,7 @@ function flattenText( for (const nested of child) append(nested, inherited); return; } - if (!isValidElement>(child) || child.type !== Text) + if (!isValidElement>(child) || child.type !== Text) throw new TypeError('R3F Text children must be text, numbers, arrays, or nested Text elements'); const inline = inlineProperties(child.props, inherited); const start = length; @@ -290,10 +289,10 @@ function flattenText( return Object.freeze({ text: chunks.join(''), spans: Object.freeze(spans) }); } -function inlineProperties( - properties: R3fTextProps, - inherited: InlineProperties, -): InlineProperties { +function inlineProperties( + properties: R3fTextProps, + inherited: InlineProperties, +): InlineProperties { return Object.freeze({ ...((properties.font ?? inherited.font) === undefined ? {} : { font: properties.font ?? inherited.font }), ...(properties.style === undefined && inherited.style === undefined @@ -302,16 +301,16 @@ function inlineProperties( ...(properties.paint === undefined && inherited.paint === undefined ? {} : { paint: Object.freeze({ ...inherited.paint, ...properties.paint }) }), - ...((properties.renderVariant ?? inherited.renderVariant) === undefined + ...((properties.material ?? inherited.material) === undefined ? {} - : { renderVariant: properties.renderVariant ?? inherited.renderVariant }), + : { material: properties.material ?? inherited.material }), }); } -function textProperties( - properties: R3fTextProps, - flattened: FlattenedText, -): Partial> & { readonly text: string } { +function textProperties( + properties: R3fTextProps, + flattened: FlattenedText, +): Partial> & { readonly text: string } { return Object.freeze({ ...(properties.font === undefined ? {} : { font: properties.font }), text: flattened.text, @@ -320,14 +319,12 @@ function textProperties( ...(properties.style === undefined ? {} : { style: properties.style }), ...(properties.paint === undefined ? {} : { paint: properties.paint }), ...(properties.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: properties.rasterPixelRatio }), - ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), + ...(properties.material === undefined ? {} : { material: properties.material }), ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), }); } -function objectProperties( - properties: R3fTextProps, -): Object3DProps { +function objectProperties(properties: R3fTextProps): Object3DProps { const object = { ...properties } as Record; for (const key of [ 'font', @@ -336,7 +333,7 @@ function objectProperties( 'style', 'paint', 'rasterPixelRatio', - 'renderVariant', + 'material', 'capacity', 'onError', 'ref', @@ -345,11 +342,9 @@ function objectProperties( return object as Object3DProps; } -function groupObjectProperties( - properties: R3fTextGroupProps, -): Object3DProps { +function groupObjectProperties(properties: R3fTextGroupProps): Object3DProps { const object = { ...properties } as Record; - for (const key of ['capacity', 'renderVariant', 'children', 'onError', 'ref']) delete object[key]; + for (const key of ['capacity', 'material', 'children', 'onError', 'ref']) delete object[key]; return object as Object3DProps; } @@ -367,16 +362,16 @@ function sameCapacity( return current?.size === capacity.size && current.policy === capacity.policy; } -function sameDesiredText( - left: (Partial> & { readonly text: string }) | undefined, - right: Partial> & { readonly text: string }, +function sameDesiredText( + left: (Partial> & { readonly text: string }) | undefined, + right: Partial> & { readonly text: string }, ): boolean { if ( left === undefined || left.font !== right.font || left.text !== right.text || left.rasterPixelRatio !== right.rasterPixelRatio || - left.renderVariant !== right.renderVariant || + left.material !== right.material || !sameSnapshot(left.contentBox, right.contentBox) || !sameSnapshot(left.style, right.style) || !sameSnapshot(left.paint, right.paint) @@ -392,7 +387,7 @@ function sameDesiredText( span.start === other.start && span.end === other.end && span.font === other.font && - span.renderVariant === other.renderVariant && + span.material === other.material && sameSnapshot(span.style, other.style) && sameSnapshot(span.paint, other.paint) ); diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 930b485a..d7d61809 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -47,5 +47,4 @@ export type { TextProperties, TextSpan, TextUpdate, - ThreeRenderVariant, } from './three/text.js'; diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 2a3727f5..d89a45c4 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -11,10 +11,10 @@ import { } from '../loaded-font.js'; import type { GlyphBufferCapacity, + ParagraphBaseProperties, ParagraphContentBox, ParagraphProperties, ParagraphStyle, - ParagraphUpdate, } from '../index.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; import type { TextRuntime } from '../text-runtime.js'; @@ -37,69 +37,62 @@ import { } from './engine-runtime.js'; import type { ThreeTextMaterial } from './material.js'; -export interface ThreeRenderVariant { - readonly effects?: readonly unknown[]; -} - -export type TextSpan = ParagraphSpan< - Technique, - Variant -> & +export type TextSpan = Omit, 'renderVariant'> & Readonly<{ material?: ThreeTextMaterial }>; -export type TextProperties = ParagraphProperties< - Technique, - Variant -> & - Readonly<{ material?: ThreeTextMaterial }>; +type TextBaseProperties = Omit< + ParagraphBaseProperties, + 'renderVariant' +>; -export type StandaloneTextProperties< - Technique extends AnyRasterTechnique, - Variant = ThreeRenderVariant, -> = TextProperties & Readonly<{ capacity?: GlyphBufferCapacity }>; +type TextContentProperties = + | Readonly<{ text: string; spans?: readonly TextSpan[] }> + | Readonly<{ text: FormattedText; spans?: never }>; -export type TextUpdate = ParagraphUpdate< - Technique, - Variant -> & +export type TextProperties = TextBaseProperties & + TextContentProperties & Readonly<{ material?: ThreeTextMaterial }>; -export interface TextGroupOptions< - _Technique extends AnyRasterTechnique = AnyRasterTechnique, - Variant = ThreeRenderVariant, -> { +export type StandaloneTextProperties = TextProperties & + Readonly<{ capacity?: GlyphBufferCapacity }>; + +export type TextUpdate = + | (Partial> & + Readonly<{ text?: string; spans?: readonly TextSpan[]; material?: ThreeTextMaterial }>) + | (Partial> & + Readonly<{ text: FormattedText; spans?: never; material?: ThreeTextMaterial }>); + +export interface TextGroupOptions { readonly capacity?: GlyphBufferCapacity; readonly renderOrder?: number; - readonly renderVariant?: Variant; readonly material?: ThreeTextMaterial; } -interface DesiredTextState { +interface DesiredTextState { readonly font: FontSelection; readonly text: string; - readonly spans: readonly TextSpan[]; + readonly spans: readonly TextSpan[]; readonly contentBox: ParagraphContentBox; readonly style: ParagraphStyle; readonly paint: GlyphPaintInput; readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; readonly material?: ThreeTextMaterial; } -export class Text extends THREE.Object3D { +export class Text extends THREE.Object3D { readonly #runtime: TextRuntime; - #desired: DesiredTextState; + #desired: DesiredTextState; #leasedFonts: readonly LoadedFont[]; #standaloneCapacity: GlyphBufferCapacity; - #binding: ThreeTextBatchBinding | undefined; - #textGroup: TextGroup | undefined; + #binding: ThreeTextBatchBinding | undefined; + #textGroup: TextGroup | undefined; #desiredRevision = 0; #appliedRevision = -1; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; - constructor(properties: StandaloneTextProperties) { + constructor(properties: StandaloneTextProperties) { super(); const normalized = normalizeDesired(properties); const primary = concreteFonts(normalized.font)[0]; @@ -110,7 +103,7 @@ export class Text | undefined { + get textGroup(): TextGroup | undefined { return this.#textGroup; } get bound(): boolean { @@ -135,12 +128,12 @@ export class Text) { - this.set({ text: value } as TextUpdate); + this.set({ text: value } as TextUpdate); } - get spans(): readonly TextSpan[] { + get spans(): readonly TextSpan[] { return this.#desired.spans; } - set spans(value: readonly TextSpan[]) { + set spans(value: readonly TextSpan[]) { this.set({ spans: value }); } get contentBox(): ParagraphContentBox { @@ -167,25 +160,16 @@ export class Text); - } - set renderVariant(value: Variant | undefined) { - this.set({ renderVariant: value } as TextUpdate); + this.set({ material: value } as TextUpdate); } - set(update: TextUpdate): void { + set(update: TextUpdate): void { this.#assertActive(); - const next = normalizeDesired({ ...this.#desired, ...replacedContent(update) } as TextProperties< - Technique, - Variant - >); + const next = normalizeDesired({ ...this.#desired, ...replacedContent(update) } as TextProperties); const fonts = selectedFonts(next); acquireFonts(fonts, this.#runtime); releaseFonts(this.#leasedFonts); @@ -194,7 +178,7 @@ export class Text): void { + setSpan(index: number, span: TextSpan): void { const spans = [...this.spans]; if (!Number.isSafeInteger(index) || index < 0 || index >= spans.length) throw new RangeError('span index is outside the text'); @@ -225,7 +209,7 @@ export class Text(this); + const boundary = nearestTextGroup(this); if (boundary?.disposed) { this.#unbind(); } else if (boundary !== undefined) { @@ -257,7 +241,7 @@ export class Text { + coreProperties(): ParagraphProperties { return { ...this.#desired, order: this.renderOrder, @@ -270,13 +254,13 @@ export class Text, group: TextGroup | undefined): void { + bind(binding: ThreeTextBatchBinding, group: TextGroup | undefined): void { if (this.#binding !== binding) this.#unbind(); this.#binding = binding; this.#textGroup = group; this.#appliedRevision = this.#desiredRevision; } - unbindFrom(binding: ThreeTextBatchBinding): void { + unbindFrom(binding: ThreeTextBatchBinding): void { if (this.#binding !== binding) return; this.#binding = undefined; this.#textGroup = undefined; @@ -297,22 +281,17 @@ export class Text extends THREE.Object3D { +export class TextGroup extends THREE.Object3D { #capacity: GlyphBufferCapacity; - #renderVariant: Variant | undefined; #material: ThreeTextMaterial | undefined; - #binding: ThreeTextBatchBinding | undefined; + #binding: ThreeTextBatchBinding | undefined; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; - constructor(options: TextGroupOptions = {}) { + constructor(options: TextGroupOptions = {}) { super(); this.#capacity = normalizeCapacity(options.capacity ?? { size: 4_096, policy: 'chunk' }); - this.#renderVariant = options.renderVariant; this.#material = options.material; if (options.renderOrder !== undefined) this.renderOrder = options.renderOrder; } @@ -331,16 +310,6 @@ export class TextGroup< get gpuBytes(): number { return this.#binding?.gpuBytes ?? 0; } - get renderVariant(): Variant | undefined { - return this.#renderVariant; - } - set renderVariant(value: Variant | undefined) { - this.#renderVariant = value; - this.#binding?.setRenderVariant(value); - } - setRenderVariant(value: Variant | undefined): void { - this.renderVariant = value; - } get material(): ThreeTextMaterial | undefined { return this.#material; } @@ -352,7 +321,7 @@ export class TextGroup< override add(...children: THREE.Object3D[]): this { this.#assertActive(); for (const child of children) - if (child instanceof Text) validateText(child as Text); + if (child instanceof Text) validateText(child as Text); return super.add(...children); } setCapacity(capacity: GlyphBufferCapacity): void { @@ -373,7 +342,7 @@ export class TextGroup< override updateMatrixWorld(force?: boolean): void { if (!this.#disposed) { - const texts = collectTextDescendants(this); + const texts = collectTextDescendants(this); if (texts.length !== 0) { const first = texts[0]!; validateText(first); @@ -400,7 +369,7 @@ export class TextGroup< super.updateMatrixWorld(force); } - bindText(text: Text): void { + bindText(text: Text): void { if (this.#disposed) return; validateText(text); } @@ -426,14 +395,14 @@ interface RetainedEngineParagraph { materialLeases: ThreeTextMaterialLease[]; } -class ThreeTextBatchBinding { +class ThreeTextBatchBinding { readonly #runtime: TextRuntime; - readonly #group: TextGroup | undefined; + readonly #group: TextGroup | undefined; readonly #coordinator: ThreeTextEngineCoordinator; readonly #session: TextEngineSession; readonly #target: ThreeTextRenderPlanExecutor; - readonly #paragraphs = new Map, RetainedEngineParagraph>(); - readonly #textsByParagraph = new Map>(); + readonly #paragraphs = new Map, RetainedEngineParagraph>(); + readonly #textsByParagraph = new Map>(); readonly #removed: RetainedEngineParagraph[] = []; #nextParagraphId = 1; #engineRevision = 0; @@ -449,7 +418,7 @@ class ThreeTextBatchBinding { constructor( runtime: TextRuntime, capacity: GlyphBufferCapacity, - group: TextGroup | undefined, + group: TextGroup | undefined, ) { this.#runtime = runtime; this.#group = group; @@ -489,12 +458,12 @@ class ThreeTextBatchBinding { get renderOrderBase(): number { return this.#group?.renderOrder ?? 0; } - reconcile(texts: readonly Text[]): void { + reconcile(texts: readonly Text[]): void { const desired = new Set(texts); for (const text of [...this.#paragraphs.keys()]) if (!desired.has(text)) this.removeText(text); for (const text of texts) this.#ensureText(text, this.#group); } - reconcileStandalone(text: Text): void { + reconcileStandalone(text: Text): void { this.#ensureText(text, undefined); } synchronize(): void { @@ -624,9 +593,6 @@ class ThreeTextBatchBinding { this.#textCapacity = Math.max(this.#textCapacity, value.size); this.#session.reserve(this.#requestCapacity, this.#resultCapacity, this.#textCapacity); } - setRenderVariant(_value: Variant | undefined): void { - // Removed with the legacy target state machine. Renderer material IDs replace variants. - } invalidateMaterial(): void { this.#materialInvalidated = true; } @@ -637,7 +603,7 @@ class ThreeTextBatchBinding { this.#acknowledgedPublicationGeneration = publication.publicationGeneration; this.#lastPublication = undefined; } - removeText(text: Text): void { + removeText(text: Text): void { const paragraph = this.#paragraphs.get(text); if (paragraph === undefined) return; this.#paragraphs.delete(text); @@ -660,8 +626,8 @@ class ThreeTextBatchBinding { this.#removed.length = 0; } #ensureText( - text: Text, - group: TextGroup | undefined, + text: Text, + group: TextGroup | undefined, ): void { validateBinding(this.#runtime, text); let paragraph = this.#paragraphs.get(text); @@ -696,17 +662,17 @@ class ThreeTextBatchBinding { } } -function compileEngineStyles( +function compileEngineStyles( coordinator: ThreeTextEngineCoordinator, paragraphId: number, - properties: ParagraphProperties, + properties: ParagraphProperties, groupMaterial: ThreeTextMaterial | undefined, leases: ThreeTextEngineStackLease[], materialLeases: ThreeTextMaterialLease[], ): TextEngineStyleMutation[] { const text = properties.text as string; const rootStack = acquireEngineStack(coordinator, properties.font, leases); - const rootMaterial = (properties as TextProperties).material ?? groupMaterial; + const rootMaterial = (properties as TextProperties).material ?? groupMaterial; const rootMaterialId = acquireEngineMaterial(coordinator, rootMaterial, materialLeases); const styles: TextEngineStyleMutation[] = [ { @@ -729,7 +695,7 @@ function compileEngineStyles( const fontStackHandle = span.font === undefined ? undefined : acquireEngineStack(coordinator, span.font, leases); const materialId = acquireEngineMaterial( coordinator, - (span as TextSpan).material, + (span as TextSpan).material, materialLeases, ); styles.push({ @@ -925,34 +891,33 @@ function ownPublication(publication: TextEnginePublication): TextEnginePublicati * against unrelated text, so an update that replaces text without stating spans * clears the ones it replaced. */ -function replacedContent( - update: TextUpdate, -): TextUpdate { +function replacedContent( + update: TextUpdate, +): TextUpdate { if (!('text' in update) || 'spans' in update) return update; - return { ...update, spans: [] } as TextUpdate; + return { ...update, spans: [] } as TextUpdate; } -function normalizeDesired( - properties: TextProperties, -): DesiredTextState { +function normalizeDesired( + properties: TextProperties, +): DesiredTextState { if (properties === undefined) throw new TypeError('Text properties are required'); const formatted = typeof properties.text === 'string' ? undefined : (properties.text as FormattedText); return Object.freeze({ font: properties.font, text: formatted?.text ?? (properties.text as string), spans: Object.freeze([ - ...((formatted?.spans as readonly TextSpan[]) ?? properties.spans ?? []), + ...((formatted?.spans as readonly TextSpan[]) ?? properties.spans ?? []), ]), contentBox: Object.freeze({ ...(properties.contentBox ?? {}) }), style: Object.freeze({ ...(properties.style ?? {}) }), paint: Object.freeze({ ...(properties.paint ?? {}) }), ...(properties.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: properties.rasterPixelRatio }), - ...(properties.renderVariant === undefined ? {} : { renderVariant: properties.renderVariant }), ...(properties.material === undefined ? {} : { material: properties.material }), }); } -function selectedFonts( - state: DesiredTextState, +function selectedFonts( + state: DesiredTextState, ): readonly LoadedFont[] { const fonts = new Set>(concreteFonts(state.font)); for (const span of state.spans) @@ -984,40 +949,33 @@ function normalizeCapacity(value: GlyphBufferCapacity): GlyphBufferCapacity { throw new TypeError('glyph capacity policy is invalid'); return Object.freeze({ size: value.size, policy: value.policy }); } -function nearestTextGroup( - object: THREE.Object3D, -): TextGroup | undefined { +function nearestTextGroup(object: THREE.Object3D): TextGroup | undefined { let parent = object.parent; while (parent !== null) { - if (parent instanceof TextGroup) return parent as TextGroup; + if (parent instanceof TextGroup) return parent; parent = parent.parent; } return undefined; } -function eraseTextTechnique( - text: Text, -): Text { - return text as unknown as Text; +function eraseTextTechnique( + text: Text, +): Text { + return text as unknown as Text; } -function collectTextDescendants( - group: TextGroup, -): Text[] { - const texts: Text[] = []; +function collectTextDescendants(group: TextGroup): Text[] { + const texts: Text[] = []; for (const child of group.children) collect(child, texts); return texts; - function collect(object: THREE.Object3D, result: Text[]): void { + function collect(object: THREE.Object3D, result: Text[]): void { if (object instanceof TextGroup) return; - if (object instanceof Text) result.push(object as Text); + if (object instanceof Text) result.push(object as Text); for (const child of object.children) collect(child, result); } } -function validateText(text: Text): void { +function validateText(text: Text): void { validateBinding(text.runtime, text); } -function validateBinding( - runtime: TextRuntime, - text: Text, -): void { +function validateBinding(runtime: TextRuntime, text: Text): void { if (text.disposed) throw new TypeError('disposed text cannot be attached'); if (text.runtime !== runtime) throw new TypeError('text belongs to another Three font-cache domain'); assertFontSelectionForRuntime(text.font, text.runtime); diff --git a/packages/text/tests/types/r3f-v1-api.test.ts b/packages/text/tests/types/r3f-v1-api.test.ts index 3974fec1..23aaf4a2 100644 --- a/packages/text/tests/types/r3f-v1-api.test.ts +++ b/packages/text/tests/types/r3f-v1-api.test.ts @@ -2,15 +2,17 @@ import { createElement } from 'react'; import type { LoadedFont } from '../../src/index.js'; import { Text, TextGroup, useFont } from '../../src/r3f.js'; +import type { ThreeTextMaterial } from '../../src/three.js'; import { bitmap } from '../../src/raster/bitmap-technique.js'; import { msdf } from '../../src/raster/msdf.js'; declare const bitmapFont: LoadedFont; declare const mtsdfFont: LoadedFont; +declare const material: ThreeTextMaterial; const inline = createElement(Text, { paint: { color: '#ff00ff' } }, 'span'); -const label = createElement(Text, { font: bitmapFont }, 'Typed ', inline); -const labels = createElement(TextGroup, null, label); +const label = createElement(Text, { font: bitmapFont, material }, 'Typed ', inline); +const labels = createElement(TextGroup, { material }, label); function FontConsumer(): null { const loaded: LoadedFont = useFont({ @@ -24,5 +26,8 @@ function FontConsumer(): null { // @ts-expect-error The selected font technique must match the Text technique. createElement(Text, { font: mtsdfFont }, 'wrong technique'); +// @ts-expect-error Material replaced the obsolete renderer-variant surface. +createElement(Text, { font: bitmapFont, renderVariant: 'old' }, 'old API'); + void labels; void FontConsumer; From c0aa460132e70bf35f664e603bf5328ade7b89db Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 02:44:53 -0400 Subject: [PATCH 077/128] feat(text): open Rust plans to raster plugins --- .../targets/product/external-raster-proof.ts | 29 +- docs/log.md | 8 + docs/packages/benchmarks.md | 11 +- docs/packages/glyph-example-raster.md | 27 +- docs/packages/text.md | 21 +- docs/planning/decision-register.md | 2 + packages/glyph-example-raster/src/three.ts | 461 ++++++------------ .../tests/glyph-example.test.ts | 52 ++ .../text/src/internal/font-binding-wire.ts | 62 +-- .../text/src/internal/render-policy-wire.ts | 22 +- packages/text/src/loaded-font.ts | 5 +- packages/text/src/r3f.ts | 6 +- packages/text/src/three.ts | 15 +- packages/text/src/three/engine-plan-target.ts | 88 +++- packages/text/src/three/engine-runtime.ts | 30 +- .../text/src/three/plan-program-registry.ts | 145 ++++++ packages/text/src/three/text.ts | 35 +- .../integration/three-engine-runtime.test.mjs | 16 + packages/text/tests/types/r3f-v1-api.test.ts | 3 - 19 files changed, 606 insertions(+), 432 deletions(-) create mode 100644 packages/text/src/three/plan-program-registry.ts diff --git a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts index 51220397..5eae5f85 100644 --- a/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts +++ b/apps/benchmarks/src/benchmark/targets/product/external-raster-proof.ts @@ -161,7 +161,10 @@ async function createResources( // `Text` reconciles while parented, so attaching and forcing one world update is what commits the first revision. textGroup.updateMatrixWorld(true); if (textGroup.error !== undefined) throw textGroup.error; - const retainedMesh = exactlyOne(text.children, 'external raster draw mesh'); + const retainedMesh = exactlyOne( + textGroup.children.filter((child) => child instanceof THREE.Mesh), + 'external raster draw mesh', + ); if (!(retainedMesh instanceof THREE.Mesh) || !(retainedMesh.geometry instanceof THREE.BufferGeometry)) { throw new TypeError('external raster did not publish a Three.js mesh'); } @@ -172,11 +175,12 @@ async function createResources( text.set({ text: UPDATED_TEXT }); textGroup.updateMatrixWorld(true); if (textGroup.error !== undefined) throw textGroup.error; - if (text.children[0] !== retainedMesh || retainedMesh.geometry !== retainedGeometry) { + if ( + textGroup.children.find((child) => child instanceof THREE.Mesh) !== retainedMesh || + retainedMesh.geometry !== retainedGeometry + ) { throw new Error('warm external raster update replaced its retained Three.js objects'); } - if (text.layout === undefined) - throw new Error('warm external raster update did not publish during object traversal'); textGroup.renderOrder = 600; textGroup.updateMatrixWorld(true); if (Number(retainedMesh.renderOrder) !== 600) @@ -204,7 +208,7 @@ async function createResources( orderingMaterial, retainedMesh, retainedGeometry, - glyphCount: text.layout.glyphIds.length, + glyphCount: [...UPDATED_TEXT].length, }; } catch (error) { text?.dispose(); @@ -268,9 +272,18 @@ async function renderResources(resources: ExternalRasterResources, signal?: Abor layeringPixels += 1; } } - if (litPixels < 100) throw new Error('external raster proof produced no visible glyph frames'); - if (layeringPixels < 100) throw new Error('external raster proof did not honor its caller-owned parent Group order'); - const liveMesh = exactlyOne(resources.text.children, 'retained external raster draw mesh'); + if (litPixels < 100) { + throw new Error(`external raster proof produced only ${litPixels} visible pixels`); + } + if (layeringPixels < 100) { + throw new Error( + `external raster proof changed only ${layeringPixels} pixels over the cover frame (${litPixels} visible pixels)`, + ); + } + const liveMesh = exactlyOne( + resources.textGroup.children.filter((child) => child instanceof THREE.Mesh), + 'retained external raster draw mesh', + ); if (liveMesh !== resources.retainedMesh || resources.retainedMesh.geometry !== resources.retainedGeometry) { throw new Error('external raster proof lost retained object or geometry identity'); } diff --git a/docs/log.md b/docs/log.md index ca8e74e9..764927c9 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Proved external Rust plan programs on both Three backends** — Replaced the glyph-example package's renderer-side + `ParagraphBatchTarget`, revision transfer, packing, dirty upload, and mesh transaction with a static policy program, + cold font-binding compiler, and plan-buffer material factory. A compiled-Wasm lifecycle proves Rust-packed buffers + and retained draw/geometry identity. The live product proof now passes twice on hardware WebGPU and forced WebGL2 + with the same `817495c4…ba9d46` frame. Its cover baseline exposed individual visibility as a batching concern; indexed + draws now suppress only the hidden text's matrix slot without a Wasm call or draw split, while direct draws mirror + object visibility. + - **Completed material naming through Three and R3F** — Removed the obsolete `ThreeRenderVariant` generic and every `renderVariant` property, setter, span field, comparison, and no-op binding hook from the command-buffer-backed public adapters. `material` is now the sole authored name through numeric Rust `materialId` and renderer factory realization; diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index eb42d146..cf9ce347 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:48ea94c9335c87ef013897164597d697aef46a0cea7615d559e53ad924f65d4c' +source_digest: 'sha256:9856ec47cea3a348fd12bb1678d3c51bde7982569a2d443eedacb1a36ffd00b9' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -199,7 +199,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5 - at: '2026-08-08T10:15:42Z' + at: '2026-08-09T06:36:53Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -350,8 +350,11 @@ Every live benchmark identity resolves through one typed catalog under `apps/ben The root `app.tsx` owns only runner detection, shell Suspense, and URL route selection. Both route branches render the same `routes/harness-route.tsx` component type, preserving one runtime-world identity while `controllers/harness-controller.tsx` owns URL revisions, post-preload transitions, presentation playback, shortcuts, and execution state. Persistent renderer provisioning and exclusive conformance-action adaptation live in `surfaces/harness/persistent-layout.tsx`; Benchmark/Conformance scene composition lives in `surfaces/harness/scene.tsx`; Main and Presentation chrome remains in `components/harness-layout.tsx`; and runtime control binding remains in `components/runtime-controls.tsx`. The three persistent Bitmap, MTSDF, and Slug live-text viewport controllers live under `surfaces/benchmark`, keeping their host lease, synchronous update path, staged font-fixture loading, loading state, telemetry, and probe contract beside the rendered surface. Their renderer imports remain literal dynamic boundaries: type-only references use `import type`, so the production build retains separate technique chunks rather than pulling renderer implementations into the route entry. Authored scenes load fixtures through `workloads/font-assets`: one discriminated adapter selects only the requested Bitmap, MTSDF, or Slug lane through literal dynamic imports, while each lane loads its fixture exactly once through the target-v1 `FontLoader` from `@pmndrs/text/three`, using the public raster technique and `@pmndrs/text/runtime-bake` entrypoint. Baked delivery authenticates the artifact first and then publishes those bytes as a blob URL, because `LoadedFontInput` names URLs rather than bytes; runtime delivery passes the measured core baker as the request's `runtimeBake`. Because the loader registers into the registry the caller supplies, `BenchmarkFontAsset.font` is a projection of `loaded.font` rather than a second registration, and the retained merged-v0 `raster` module resolves the raster key the load already attached instead of baking again. Loads that name no registry share one `THREE.LoadingManager`, so their fonts share one text runtime as a paragraph batch requires; each caller-supplied registry keeps its own manager, runtime, and loader, preserving the ownership isolation those surfaces already had. The adapter owns source-font URLs, baked transport URLs, gzip and SHA-256 authentication, and runtime progress and delivery metrics; renderer modules retain only live GPU lifecycle, configuration, statistics, and compatibility delegates. Delivery metrics instrument the technique's runtime baker through a clone, which still renders because the Three program registry resolves programs by stable technique ID rather than object identity. Direct font-baker imports and Wasm URLs remain prohibited from this workload-facing path. Conformance React composition lives under `surfaces/conformance`: both the retained comparison and finite captures can receive only the host-owned renderer, while executable low-level work lives below `benchmark/targets/conformance`, `benchmark/targets/product`, and `benchmark/targets/measurement`. The realtime MTSDF/Slug comparison, runtime-fallback capture, external raster proof, React reconciliation target, and finite Bitmap/MTSDF/Slug product lifecycles are owned by those explicit target trees rather than `renderer`. The finite product targets accept the runner's renderer and abort signal, lazily load their public `Text` scenes, render deterministic frames, and dispose only resources they own. Shared Bitmap line construction, exact CPU-reference composition, renderer-state restoration, and RGBA8 readback normalization live below `benchmark/low-level/raster`; MTSDF and Slug product scenes remain target-owned because they are executable benchmark examples. Pure CPU raster and source-outline oracles live in the same low-level tree, so renderer-adjacent finite capture code can share primitives without importing executable targets; a source-boundary regression prohibits renderer-to-target dependencies. Advanced Shaping lives in the conformance target hierarchy behind the registry's literal selected-target dynamic import. Bitmap, MTSDF, and Slug conformance dispatch now enters technique-owned target modules rather than live renderer files. MTSDF and Slug sampling plus source-outline targets implement the same warm session contract, preserving `load → capture → dispose` reuse and forwarding the borrowed renderer and abort signal unchanged; Bitmap's thin target wrapper reuses its neutral low-level finite scene. The target modules own CPU comparison, renderer-state restoration, standard visual captures, and Slug role/external-resource proofs; renderer modules retain only live persistent-scene and font/configuration infrastructure. The targets share the explicitly named `targets/shared/direct-wasm.ts` dependency adapter only after target selection. The public missing-sibling loader Worker is conformance because it proves authenticated Worker bytes and loader fallback behavior; it is not a rendering product target. Boundary tests reject workload imports back into renderer implementation, reject renderer imports of executable targets, authenticate literal selected-target imports, preserve selected-technique asset chunks, and reject direct font-baker or Wasm URL imports outside the shared adapter, preventing raw tooling from leaking into the normal Presentation module graph. The external raster product proof renders a competing transparent cover and public `Text` under different parent Groups on -WebGPU and WebGL2. Framebuffer differences prove that the composite Text and neutral plugin batch preserve the caller-owned -primary group order through actual Three.js sorting. +WebGPU and WebGL2. The external package's static policy and font binding run through the Rust command-buffer path; its +renderer factory receives only plan-selected buffers. Framebuffer differences prove caller-owned Group order through +actual Three.js sorting, while the baseline toggles public `Text.visible` to prove one indexed instance can disappear +without splitting the shared draw or rerunning layout. Two samples per backend reproduce the same RGBA SHA-256 +`817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`. Timed playback compares each frame with the latest requested location rather than the last committed scene, so an in-flight preload receives exactly one request and cannot be superseded by a duplicate transition that skips workload-default initialization. Presentation captures Space at the window capture boundary to start or stop timed playback even while a button, switch, slider, select, or combobox owns focus; matching key-up activation is suppressed, while inputs, textareas, and editable text retain ordinary space entry. Arrow navigation remains disabled on interactive controls. diff --git a/docs/packages/glyph-example-raster.md b/docs/packages/glyph-example-raster.md index 8475c8e7..5346dcf5 100644 --- a/docs/packages/glyph-example-raster.md +++ b/docs/packages/glyph-example-raster.md @@ -5,7 +5,7 @@ description: Proves the published raster and baker extension boundary with a pri resource: ../../packages/glyph-example-raster workspace_package: '@pmndrs/text-glyph-example-raster' documentation_type: reference -source_digest: 'sha256:f668c47d4500e4fe1d98d84b73dbf95f8d842d04c1fed4b144ad293b2c3c5610' +source_digest: 'sha256:0a0b7bd0a74ec262d5b4722cc43e5a1f8da837e9fa72b9c1d9112f119f1bb445' tags: [package, raster, extension-proof, threejs, tsl] sources: - id: manifest @@ -28,7 +28,7 @@ sources: title: Dual-backend product rendering probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-04T18:59:39Z' + at: '2026-08-09T06:36:53Z' --- # Package reference: `@pmndrs/text-glyph-example-raster` @@ -48,20 +48,23 @@ diagnostic rather than a text-quality recommendation. The baker accepts both emb The external lane authenticates the companion GLB and its separate record payload through the public raster and resource resolvers; the embedded lane proves recursive `BufferView` rebasing through the public Node composition host. -The package now supplies both halves of the target-v1 boundary separately. `glyphExample` is a portable -`defineRasterTechnique` that decodes, selects one shared resource, and packs canonical positive-down instance storage while -importing no renderer; `@pmndrs/text-glyph-example-raster/three` registers the Three program for it through the public -`registerThreeRasterProgram` registry, so nothing in `@pmndrs/text` names this package. Instance capacity and dirty ranges -are now core's, not the plugin's: the program reads `PreparedGlyphBatch.capacity` and `.dirtyRanges` and retains its meshes, -geometry, and buffers while both hold, which deleted this package's own slack planner and bucket coalescer. Focused tests -cover deterministic bytes, public Node bake, standalone companion validation, external resource resolution, -abort-before-decode, selection, range writes, binding identity, and paint admission. +The package now supplies both halves of the Rust render-plan boundary separately. `glyphExample` is a portable +`defineRasterTechnique` that decodes and selects one shared resource while importing no renderer. +`@pmndrs/text-glyph-example-raster/three` registers a static policy program through public +`registerThreeRasterPlanProgram`, so nothing in `@pmndrs/text` names this package. The policy describes the exact Rust +inputs, buffers, scalar operations, and storage/draw keys. A cold compiler lowers validated glyph colors and inset data +into one font binding; a renderer factory consumes the resulting buffers to construct the TSL material. The package no +longer owns a `ParagraphBatchTarget`, target revision, slack planner, dirty-range upload loop, or mesh transaction. +Focused tests cover deterministic bytes, public Node bake, standalone companion validation, external resource +resolution, abort-before-decode, selection and binding identity, plus a compiled-Wasm public `Text` lifecycle that +observes Rust-packed buffers and retained draw/geometry identity. The hardware-browser target uses the public source-font fallback, package runtime baker, the target-v1 `FontLoader`, public `Text` and `TextGroup`, warm matrix-lifecycle publication, TSL compilation, draw, asynchronous render-target readback, and complete disposal. WebGPU and forced WebGL2 each produced two deterministic samples with visible glyph frames, one draw, -retained mesh and geometry identity, and the same RGBA SHA-256 -`0e0ec025a2121ec3b29317276c12978e7a7a062197b0a9ad448a6b37c270b368`. +retained mesh and geometry identity, individual `Text.visible` behavior inside an indexed shared draw, caller-owned +Group ordering, and the same RGBA SHA-256 +`817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`. When the benchmark route supplies an exclusive execution context, the target borrows that renderer, restores render target, clear, viewport, scissor, and scissor-test state, and never creates or disposes a parallel renderer. Run the focused lane with `pnpm scripts run benchmark:external-raster`. diff --git a/docs/packages/text.md b/docs/packages/text.md index 98e93de4..b3794d8d 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:c5708dd665b5ede78cde985af8dea48c4c5474fd745104310822f133b7313eb4' +source_digest: 'sha256:36708c3ebe0e3ee71edc3df4355bd7e68986f64597152fabd7ce196c0b5ac1c7' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -190,7 +190,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-08T23:04:50Z' + at: '2026-08-09T06:36:53Z' --- # Package reference: `@pmndrs/text` @@ -745,8 +745,10 @@ changes while emitting the draw partitions its backend requires. Production font bitmap strikes, MTSDF glyph records, and Slug bands directly into one field-major request allocation. Exact integration tests compare every emitted lane with the established real-font renderer-parity tables. Public string technique and resource IDs lower through deterministic UTF-8 FNV-1a into the wire's nonzero `u32` namespace, and one runtime-scoped -registry rejects any collision before Rust registration. The policy and binding bytes are still package-internal; -public third-party policy authoring and Three render-plan consumption remain open. +registry rejects any collision before Rust registration. First-party policy and binding compilation share their exact +wire compiler with the public Three plan-program registry. A third-party program supplies static validated policy data, +one cold font-binding compiler, and one renderer material factory; it cannot insert a hot JavaScript callback into Rust +planning. Three render-plan consumption is the public imperative and R3F rendering path. Frame-request serialization is also production code rather than a benchmark helper. One final `Uint8Array` carries text replacements; root and span style mutations; language and OpenType feature payloads; material, paint, decoration, @@ -925,7 +927,16 @@ executor retains only renderer resources needed to apply later deltas. It does n Three's old layout, glyph-snapshot, and glyph-origin override surface is removed. Any later interaction or measurement API must query retained Rust layout state separately and on demand. Focused integration covers mixed-font spans, custom material factories, two-child indexed batching, renderer-local transform updates, reparenting, and disposal. R3F, -TypeGPU, and the portable legacy core still own the remaining cutover and deletion work. +which constructs the same imperative objects, is cut over as well. TypeGPU and the portable legacy core still own the +remaining cutover and deletion work. + +Third-party Three techniques use the same Rust planner instead of reviving the removed target transaction. Public +`registerThreeRasterPlanProgram` accepts a static policy descriptor, a cold compiler that lowers the package's validated +font data into one binding, and a material factory that receives exact policy buffers and transform authority. The +glyph-example package proves this contract without importing text internals or duplicating instance packing. Its +compiled-Wasm lifecycle retains mesh and geometry identity over a text mutation. Its live WebGPU and forced-WebGL2 +frames are deterministic and byte-identical across backends. Individual visibility does not split an indexed draw or +cross into Wasm: the renderer zeros only that text's matrix-sidecar slot; direct-policy draws mirror object visibility. Public font stacks no longer repeat a raster technique or require every fallback font to share one. Their generic type is the union of the concrete loaded-font techniques, while runtime construction still requires one text-runtime domain, diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index d11031fc..1c7166c8 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -308,6 +308,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-224 | The Three and R3F command-buffer surfaces complete D-167's naming cutover: `material` is the only authored renderer customization property from group/text/span input through numeric Rust `materialId` and renderer factory realization. Their `ThreeRenderVariant` generic, `renderVariant` properties, setters, comparison logic, and no-op binding hook are deleted. The legacy portable core and TypeGPU variant state remains only until those implementations move to the Rust plan; it is not re-exported through the cut-over renderer APIs. | Accepted | +| D-225 | A third-party Three raster integrates with the Rust command buffer through one declarative `registerThreeRasterPlanProgram` registration. Its static policy descriptor is compiled and validated before the engine session exists; it contains input, physical-buffer, scalar-operation, and batching-key data but no hot JavaScript callback. Its cold font compiler lowers validated package-owned raster fields and resource references into one Rust binding, while its renderer factory realizes a material from exact plan buffers only when a draw requires it. The external glyph-example package no longer owns a `ParagraphBatchTarget`, candidate revision, slack planner, dirty-range copier, mesh transaction, or renderer-side layout loop. Compiled-Wasm lifecycle coverage proves Rust-packed buffers and retained mesh/geometry identity. A hardware-browser proof produces two deterministic samples on both WebGPU and forced WebGL2 with the same RGBA SHA-256 `817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`. Indexed visibility remains renderer-local: hiding one public `Text` zeros only its matrix-sidecar slot and preserves the shared draw; direct-policy draws mirror that object's mesh visibility. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/packages/glyph-example-raster/src/three.ts b/packages/glyph-example-raster/src/three.ts index 53c5512e..7fa9c552 100644 --- a/packages/glyph-example-raster/src/three.ts +++ b/packages/glyph-example-raster/src/three.ts @@ -1,323 +1,168 @@ -import type { - GlyphBatchKey, - ParagraphBatchTarget, - ParagraphBatchTargetUpdate, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '@pmndrs/text'; -import { registerThreeRasterProgram, type ThreeRasterTargetOwner } from '@pmndrs/text/three'; -import { add, instanceIndex, min, mul, positionLocal, step, storage, sub, uniform, uv, vec3 } from 'three/tsl'; +import { registerThreeRasterPlanProgram, threePolicyAbi, type ThreePlanProgramBuffer } from '@pmndrs/text/three'; +import { add, min, mul, positionLocal, step, storage, sub, uv, vec3 } from 'three/tsl'; import * as THREE from 'three/webgpu'; -import { glyphExample } from './raster.js'; - -/** - * A third-party Three program for a third-party technique. It is registered by technique identifier, so nothing in - * `@pmndrs/text` needs to know this package exists: core packs the canonical storage and this program owns every - * engine resource — geometry, attributes, material, and the meshes it publishes into the caller's scene. - */ -registerThreeRasterProgram(glyphExample, (owner) => new ThreeGlyphExampleTarget(owner)); - -interface GlyphExampleResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -interface RunIdentity { - readonly paragraph: ParagraphId; - readonly batch: GlyphBatchKey; -} - -/** - * One committed generation of draws. Retention is the point of this class: while core reports the same batch capacities - * and the same run topology, a warm revision transfers its meshes and buffers to its successor instead of rebuilding - * them, which is what keeps object and geometry identity stable across text updates. - */ -class ThreeGlyphExampleRevision { - readonly sourceRevision: number; - readonly draws: readonly THREE.Mesh[]; - readonly #resources: ReadonlyMap; - readonly #runIdentities: readonly RunIdentity[]; - #transferred = false; - #disposed = false; - - constructor( - sourceRevision: number, - draws: readonly THREE.Mesh[], - resources: ReadonlyMap, - runIdentities: readonly RunIdentity[], - ) { - this.sourceRevision = sourceRevision; - this.draws = draws; - this.#resources = resources; - this.#runIdentities = runIdentities; - } - - get gpuBytes(): number { - if (this.#disposed || this.#transferred) return 0; - let bytes = 0; - for (const resource of this.#resources.values()) bytes += resource.gpuBytes; - return bytes; - } - - setRenderOrderBase(base: number): void { - for (let index = 0; index < this.draws.length; index += 1) this.draws[index]!.renderOrder = base + index; - } - - canReuse(next: PreparedParagraphBatchRevision): boolean { - if (this.#disposed || this.#transferred || next.glyphBatches.length !== this.#resources.size) return false; - for (const batch of next.glyphBatches) { - const resource = this.#resources.get(batch.key); - if (resource === undefined || resource.capacity !== batch.capacity) return false; - } - if (next.glyphRuns.length !== this.#runIdentities.length) return false; - return next.glyphRuns.every((run, index) => { - const identity = this.#runIdentities[index]; - return identity?.paragraph === run.paragraph && identity.batch === run.batch; +import { glyphExample, type GlyphExampleData } from './raster.js'; + +const opcodes = threePolicyAbi.opcodes; +const scalar = threePolicyAbi.scalarTypes; +const semantic = threePolicyAbi.semanticF32Fields; +const semanticU32 = threePolicyAbi.semanticU32Fields; +const batch = threePolicyAbi.batchFields; + +registerThreeRasterPlanProgram({ + technique: glyphExample, + policy: { + f32InputCount: 12, + u32InputCount: 1, + inputs: [ + { scope: 'semantic', field: semantic.inlineStart }, + { scope: 'semantic', field: semantic.blockStart }, + { scope: 'semantic', field: semantic.fontSize }, + { scope: 'semantic', field: semantic.foregroundRed }, + { scope: 'semantic', field: semantic.foregroundGreen }, + { scope: 'semantic', field: semantic.foregroundBlue }, + { scope: 'semantic', field: semantic.foregroundAlpha }, + ...Array.from({ length: 5 }, (_, field) => ({ scope: 'glyph' as const, field })), + { scope: 'semantic', field: semanticU32.transformIndex }, + ], + buffers: [ + { id: 1, scalar: scalar.f32, vectorWidth: 2 }, + { id: 2, scalar: scalar.f32, vectorWidth: 2 }, + { id: 3, scalar: scalar.f32, vectorWidth: 4 }, + { id: threePolicyAbi.transformBufferId, scalar: scalar.u32, vectorWidth: 1 }, + ], + operations: glyphExampleOperations(), + storageKeyMask: batch.technique | batch.program | batch.resource, + drawKeyMask: + batch.technique | batch.program | batch.resource | batch.material | batch.clip | batch.depth | batch.order, + }, + compileFont(compiler) { + const data = compiler.font.data as GlyphExampleData; + const { resources } = compiler.resources([data.resource]); + compiler.retain(data.resource, data); + compiler.compile({ + techniqueId: compiler.techniqueId, + programVariant: 0, + glyphCount: compiler.font.font.glyphCount, + strikes: [0], + resources, + resourceIndex: () => 0, + glyphF32: { + rows: data.glyphCount, + fields: [ + () => data.binding.inset, + (row) => data.colors[row * 4]! / 255, + (row) => data.colors[row * 4 + 1]! / 255, + (row) => data.colors[row * 4 + 2]! / 255, + (row) => data.colors[row * 4 + 3]! / 255, + ], + }, + glyphU32: compiler.emptyTable(data.glyphCount), + strikeF32: compiler.emptyTable(data.glyphCount), + strikeU32: compiler.emptyTable(data.glyphCount), + resourceF32: compiler.emptyTable(resources.length), + resourceU32: compiler.emptyTable(resources.length), }); - } - - transfer( - next: PreparedParagraphBatchRevision, - renderOrderBase: number, - ): ThreeGlyphExampleRevision { - if (!this.canReuse(next)) throw new Error('glyph-example revision is not compatible for reuse'); - for (const batch of next.glyphBatches) this.#resources.get(batch.key)!.update(batch); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const draw = this.draws[index]!; - draw.userData.pmndrsTextRunStart = run.start; - (draw.geometry as THREE.InstancedBufferGeometry).instanceCount = run.count; - draw.renderOrder = renderOrderBase + index; - } - this.#transferred = true; - return new ThreeGlyphExampleRevision(next.revision, this.draws, this.#resources, this.#runIdentities); - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - if (this.#transferred) return; - for (const draw of this.draws) { - draw.removeFromParent(); - draw.geometry.dispose(); - } - for (const resource of this.#resources.values()) resource.dispose(); - } -} - -class ThreeGlyphExampleTarget implements ParagraphBatchTarget< - typeof glyphExample, - Variant, - ThreeGlyphExampleRevision -> { - readonly technique: typeof glyphExample = glyphExample; - readonly #owner: ThreeRasterTargetOwner; - #committed: ThreeGlyphExampleRevision | undefined; - #disposed = false; - - constructor(owner: ThreeRasterTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#committed?.gpuBytes ?? 0; - } - - stage( - previous: ThreeGlyphExampleRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('glyph-example Three target has been disposed'); - if (previous?.canReuse(next) === true) return this.#warmStage(previous, next); - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) resources.set(batch.key, createResource(batch)); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const resource = resources.get(run.batch); - if (resource === undefined) throw new Error('glyph-example run references an unknown physical batch'); - const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material); - mesh.name = 'pmndrs.text.glyph-example'; - mesh.userData.pmndrsTextRunStart = run.start; - mesh.frustumCulled = false; - mesh.renderOrder = this.#owner.renderOrderBase + index; - draws.push(mesh); - } - } catch (error) { - discard(draws, resources.values()); - throw error; + }, + createMaterial(context) { + if (context.materialId !== 0) { + throw new TypeError('glyph-example does not implement custom text materials'); } - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('glyph-example stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) { - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - } - this.#committed = new ThreeGlyphExampleRevision(next.revision, draws, resources, runIdentities(next)); - return this.#committed; - }, - abort: () => { - if (finished) return; - finished = true; - discard(draws, resources.values()); - }, - }, - }; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#committed = undefined; + const origins = floatBuffer(context.buffers, 1, 2); + const sizes = floatBuffer(context.buffers, 2, 2); + const colors = floatBuffer(context.buffers, 3, 4); + const origin = storage(origins.attribute, 'vec2', origins.attribute.count).setPBO(true).element(context.instance); + const size = storage(sizes.attribute, 'vec2', sizes.attribute.count).setPBO(true).element(context.instance); + const color = storage(colors.attribute, 'vec4', colors.attribute.count).setPBO(true).element(context.instance); + const unit = uv(); + const edgeDistance = min(min(unit.x, sub(1, unit.x)), min(unit.y, sub(1, unit.y))); + const material = new THREE.MeshBasicNodeMaterial({ + depthTest: false, + depthWrite: false, + side: THREE.DoubleSide, + transparent: true, + }); + material.positionNode = context.transformPosition( + vec3(add(origin.x, mul(positionLocal.x, size.x)), add(origin.y, mul(positionLocal.y, size.y)).negate(), 0), + ); + material.colorNode = color.rgb; + material.opacityNode = mul(color.a, sub(1, step(0.08, edgeDistance))); + return material; + }, +}); + +function glyphExampleOperations() { + const operations: Array<{ + opcode: number; + target?: number; + operand0?: number; + operand1?: number; + immediate0?: number; + }> = []; + for (let field = 0; field < 12; field += 1) { + operations.push({ opcode: opcodes.loadF32, target: field, operand0: field }); } - - #warmStage( - previous: ThreeGlyphExampleRevision, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('glyph-example stage is no longer active'); - finished = true; - this.#committed = previous.transfer(next, this.#owner.renderOrderBase); - return this.#committed; - }, - abort: () => { - finished = true; - }, - }, - }; + operations.push({ opcode: opcodes.loadU32, target: 31, operand0: 0 }); + operations.push({ opcode: opcodes.multiplyF32, target: 12, operand0: 7, operand1: 2 }); + operations.push({ opcode: opcodes.addF32, target: 13, operand0: 0, operand1: 12 }); + operations.push({ opcode: opcodes.constantF32, target: 14, immediate0: f32Bits(0.8) }); + operations.push({ opcode: opcodes.multiplyF32, target: 15, operand0: 2, operand1: 14 }); + operations.push({ opcode: opcodes.subtractF32, target: 16, operand0: 1, operand1: 15 }); + operations.push({ opcode: opcodes.addF32, target: 17, operand0: 16, operand1: 12 }); + operations.push({ opcode: opcodes.constantF32, target: 18, immediate0: f32Bits(0.05) }); + operations.push({ opcode: opcodes.multiplyF32, target: 19, operand0: 2, operand1: 18 }); + operations.push({ opcode: opcodes.constantF32, target: 20, immediate0: f32Bits(0.65) }); + operations.push({ opcode: opcodes.multiplyF32, target: 21, operand0: 2, operand1: 20 }); + operations.push({ opcode: opcodes.constantF32, target: 22, immediate0: f32Bits(2) }); + operations.push({ opcode: opcodes.multiplyF32, target: 23, operand0: 12, operand1: 22 }); + operations.push({ opcode: opcodes.subtractF32, target: 24, operand0: 21, operand1: 23 }); + operations.push({ opcode: opcodes.lessThanF32, target: 25, operand0: 24, operand1: 19 }); + operations.push({ opcode: opcodes.selectF32, target: 26, operand0: 25, operand1: 19, immediate0: 24 }); + operations.push({ opcode: opcodes.subtractF32, target: 27, operand0: 2, operand1: 23 }); + operations.push({ opcode: opcodes.lessThanF32, target: 28, operand0: 27, operand1: 19 }); + operations.push({ opcode: opcodes.selectF32, target: 29, operand0: 28, operand1: 19, immediate0: 27 }); + storeF32(operations, 1, 0, 13); + storeF32(operations, 1, 1, 17); + storeF32(operations, 2, 0, 26); + storeF32(operations, 2, 1, 29); + for (let channel = 0; channel < 4; channel += 1) { + operations.push({ opcode: opcodes.multiplyF32, target: 12, operand0: 3 + channel, operand1: 8 + channel }); + storeF32(operations, 3, channel, 12); } + operations.push({ + opcode: opcodes.storeU32, + operand0: 31, + operand1: 0, + immediate0: threePolicyAbi.transformBufferId, + }); + return operations; } -function createResource(batch: PreparedGlyphBatch): GlyphExampleResource { - const origins = instanceAttribute(batch.storage.origins, 2); - const sizes = instanceAttribute(batch.storage.sizes, 2); - const colors = instanceAttribute(batch.storage.colors, 4); - const material = createMaterial(origins, sizes, colors); - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: origins.array.byteLength + sizes.array.byteLength + colors.array.byteLength, - material, - update(next) { - upload(origins, next.storage.origins, 2, next.dirtyRanges); - upload(sizes, next.storage.sizes, 2, next.dirtyRanges); - upload(colors, next.storage.colors, 4, next.dirtyRanges); - }, - geometry(count) { - const geometry = unitQuad(); - geometry.instanceCount = count; - geometry.setAttribute('_glyphExampleOrigins', origins); - geometry.setAttribute('_glyphExampleSizes', sizes); - geometry.setAttribute('_glyphExampleColors', colors); - return geometry; - }, - dispose() { - material.dispose(); - }, - }; -} - -function upload( - target: THREE.StorageInstancedBufferAttribute, - source: Float32Array, - itemSize: number, - ranges: readonly { readonly start: number; readonly count: number }[], +function storeF32( + operations: Array<{ opcode: number; operand0?: number; operand1?: number; immediate0?: number }>, + buffer: number, + lane: number, + register: number, ): void { - if (ranges.length === 0) return; - const values = target.array as Float32Array; - target.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * itemSize; - const count = range.count * itemSize; - values.set(source.subarray(start, start + count), start); - target.addUpdateRange(start, count); - } - target.needsUpdate = true; - const pbo = (target as THREE.StorageInstancedBufferAttribute & { pbo?: THREE.DataTexture }).pbo; - if (pbo !== undefined) pbo.needsUpdate = true; -} - -function instanceAttribute(source: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute { - const value = new THREE.StorageInstancedBufferAttribute(new Float32Array(source), itemSize); - value.setUsage(THREE.DynamicDrawUsage); - value.needsUpdate = true; - return value; + operations.push({ opcode: opcodes.storeF32, operand0: register, operand1: lane, immediate0: buffer }); } -function runIdentities( - revision: PreparedParagraphBatchRevision, -): readonly RunIdentity[] { - return revision.glyphRuns.map((run) => ({ paragraph: run.paragraph, batch: run.batch })); -} - -function discard(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) { - draw.removeFromParent(); - draw.geometry.dispose(); +function floatBuffer( + buffers: ReadonlyMap, + id: number, + vectorWidth: number, +): ThreePlanProgramBuffer { + const buffer = buffers.get(id); + if (buffer === undefined || buffer.scalarType !== scalar.f32 || buffer.vectorWidth !== vectorWidth) { + throw new TypeError(`glyph-example draw requires f32x${vectorWidth} policy buffer ${id}`); } - for (const resource of resources) resource.dispose(); -} - -/** - * Core packs one physical batch that several runs may share, so each mesh reads its own slice through the run-start - * uniform rather than assuming its run begins at instance zero. - */ -function createMaterial( - origins: THREE.StorageInstancedBufferAttribute, - sizes: THREE.StorageInstancedBufferAttribute, - colors: THREE.StorageInstancedBufferAttribute, -): THREE.MeshBasicNodeMaterial { - const material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - const runStart = uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = instanceIndex.add(runStart); - const origin = storage(origins, 'vec2', origins.count).setPBO(true).element(instance); - const size = storage(sizes, 'vec2', sizes.count).setPBO(true).element(instance); - const color = storage(colors, 'vec4', colors.count).setPBO(true).element(instance); - const unit = uv(); - const edgeDistance = min(min(unit.x, sub(1, unit.x)), min(unit.y, sub(1, unit.y))); - const frame = sub(1, step(0.08, edgeDistance)); - // Canonical storage is positive-down, so the program negates Y to reach Three's Y-up paragraph space. The technique - // never states a renderer convention; converting it is exactly the program's job. - material.positionNode = vec3( - add(origin.x, mul(positionLocal.x, size.x)), - add(origin.y, mul(positionLocal.y, size.y)).negate(), - 0, - ); - material.colorNode = color.rgb; - material.opacityNode = mul(color.a, frame); - return material; + return buffer; } -function unitQuad(): THREE.InstancedBufferGeometry { - const geometry = new THREE.InstancedBufferGeometry(); - geometry.setAttribute('position', new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 1, 0], 3)); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 0, 1, 1, 1], 2)); - geometry.setIndex([0, 1, 2, 2, 1, 3]); - return geometry; +function f32Bits(value: number): number { + const bytes = new ArrayBuffer(4); + const view = new DataView(bytes); + view.setFloat32(0, value, true); + return view.getUint32(0, true); } diff --git a/packages/glyph-example-raster/tests/glyph-example.test.ts b/packages/glyph-example-raster/tests/glyph-example.test.ts index 6e8926ef..0d9bdda6 100644 --- a/packages/glyph-example-raster/tests/glyph-example.test.ts +++ b/packages/glyph-example-raster/tests/glyph-example.test.ts @@ -5,6 +5,8 @@ import { join } from 'node:path'; import { FontRegistry, + createRuntimeShaper, + createTextRuntime, rasterBake, type GlyphPaint, type RasterGlyphInput, @@ -15,10 +17,13 @@ import { type Sha256Hex, } from '@pmndrs/text'; import { bakeFont } from '@pmndrs/text/bake'; +import { Text, TextGroup } from '@pmndrs/text/three'; +import * as THREE from 'three/webgpu'; import { afterEach, describe, expect, test, vi } from 'vitest'; import glyphExampleBaker from '../src/baker.js'; import { GLYPH_EXAMPLE_KIND, glyphExample, glyphExampleDescriptor, type GlyphExampleData } from '../src/index.js'; +import '../src/three.js'; const source = new URL('../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url); const temporaryDirectories: string[] = []; @@ -151,6 +156,49 @@ describe('public external raster proof', () => { ); font.dispose(); }); + + test('publishes and retains external draws through the Rust command buffer', async () => { + const baked = await bakeFixture({ artifact: 'embedded', pages: 'embedded' }); + const core = baked.execution.outputs.find(({ role }) => role === 'font'); + assert.ok(core); + const registry = new FontRegistry(); + const shaper = await createRuntimeShaper({ + registry, + wasm: await readFile(new URL('../../text/dist/text_shaper.wasm', import.meta.url)), + }); + const runtime = await createTextRuntime({ registry, shaper }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(core.file)) }, + raster: { technique: glyphExample, options: { paletteSeed: 7 } }, + }); + const text = new Text({ font, text: 'PUBLIC RASTER', style: { fontSize: 48 } }); + const group = new TextGroup({ renderOrder: 200 }); + group.add(text); + const scene = new THREE.Scene(); + scene.add(group); + scene.updateMatrixWorld(); + + expect(group.error).toBeUndefined(); + const draw = group.children.find((child): child is THREE.Mesh => child instanceof THREE.Mesh); + expect(draw).toBeDefined(); + expect(draw?.renderOrder).toBe(200); + const geometry = draw?.geometry as THREE.InstancedBufferGeometry; + expect(geometry.getAttribute('_pmndrsText_1')).toBeDefined(); + expect(geometry.getAttribute('_pmndrsText_2')).toBeDefined(); + expect(geometry.getAttribute('_pmndrsText_3')).toBeDefined(); + expect(geometry.getAttribute('_pmndrsText_15')).toBeDefined(); + + text.text = 'PLUGIN UPDATE'; + scene.updateMatrixWorld(); + expect(group.error).toBeUndefined(); + expect(group.children.find((child) => child instanceof THREE.Mesh)).toBe(draw); + expect(draw?.geometry).toBe(geometry); + + group.dispose(); + text.dispose(); + font.dispose(); + runtime.dispose(); + }); }); async function loadEmbedded(): Promise<{ readonly font: RegisteredFont; readonly data: GlyphExampleData }> { @@ -199,3 +247,7 @@ function glyphColor(data: GlyphExampleData, glyphId: number): readonly number[] function paint(): GlyphPaint { return { palette: [{ color: [1, 1, 1, 1] }], paintIndices: Uint16Array.of(0) }; } + +function dataUrl(bytes: Uint8Array): string { + return `data:model/gltf-binary;base64,${Buffer.from(bytes).toString('base64')}`; +} diff --git a/packages/text/src/internal/font-binding-wire.ts b/packages/text/src/internal/font-binding-wire.ts index e4911b7f..64ad07e0 100644 --- a/packages/text/src/internal/font-binding-wire.ts +++ b/packages/text/src/internal/font-binding-wire.ts @@ -10,7 +10,7 @@ const MAX_U32 = 0xffff_ffff; const ABSENT_PAGE = 0xffff; const MISSING_RESOURCE = 0xffff_ffff; -interface BindingResource { +export interface BindingResource { readonly key: RasterResourceId; readonly id: number; readonly generation: number; @@ -18,24 +18,24 @@ interface BindingResource { readonly reference: number; } -interface FieldTable { +export interface FontBindingFieldTable { readonly rows: number; readonly fields: readonly ((row: number) => number)[]; } -interface FontBindingDescriptor { +export interface FontBindingDescriptor { readonly techniqueId: number; readonly programVariant: number; readonly glyphCount: number; readonly strikes: readonly number[]; readonly resources: readonly BindingResource[]; readonly resourceIndex: (row: number) => number; - readonly glyphF32: FieldTable; - readonly glyphU32: FieldTable; - readonly strikeF32: FieldTable; - readonly strikeU32: FieldTable; - readonly resourceF32: FieldTable; - readonly resourceU32: FieldTable; + readonly glyphF32: FontBindingFieldTable; + readonly glyphU32: FontBindingFieldTable; + readonly strikeF32: FontBindingFieldTable; + readonly strikeU32: FontBindingFieldTable; + readonly resourceF32: FontBindingFieldTable; + readonly resourceU32: FontBindingFieldTable; } /** Compile one first-party loaded font into the Rust engine's field-major immutable binding. */ @@ -106,7 +106,7 @@ function compileBitmap( identities: RenderWireIdentityRegistry, ): Uint8Array { const entries = data.strikes.flatMap((strike) => strike.pages.map((page) => page.resource)); - const { resources, indexFor } = bindingResources(entries, identities); + const { resources, indexFor } = fontBindingResources(entries, identities); const views = data.strikes.map((strike) => recordView(strike.records)); const rows = checkedProduct(glyphCount, data.strikes.length, 'bitmap strike rows'); const strikeRecord = (row: number): { readonly view: DataView; readonly record: number; readonly strike: number } => { @@ -128,7 +128,7 @@ function compileBitmap( : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / data.strikes[strike]!.pages[page]![dimension]; }; - return compileBinding({ + return compileFontBinding({ techniqueId, programVariant: 0, glyphCount, @@ -139,8 +139,8 @@ function compileBitmap( const page = view.getUint16(record + 16, true); return page === ABSENT_PAGE ? MISSING_RESOURCE : indexFor(data.strikes[strike]!.pages[page]!.resource); }, - glyphF32: emptyTable(glyphCount), - glyphU32: emptyTable(glyphCount), + glyphF32: emptyFontBindingTable(glyphCount), + glyphU32: emptyFontBindingTable(glyphCount), strikeF32: { rows, fields: [ @@ -170,9 +170,9 @@ function compileBitmap( (row) => span(row, 10, 14, 'height'), ], }, - strikeU32: emptyTable(rows), - resourceF32: emptyTable(resources.length), - resourceU32: emptyTable(resources.length), + strikeU32: emptyFontBindingTable(rows), + resourceF32: emptyFontBindingTable(resources.length), + resourceU32: emptyFontBindingTable(resources.length), }); } @@ -182,7 +182,7 @@ function compileMsdf( techniqueId: number, identities: RenderWireIdentityRegistry, ): Uint8Array { - const { resources, indexFor } = bindingResources([data.resource], identities); + const { resources, indexFor } = fontBindingResources([data.resource], identities); const view = recordView(data.records); const rowRecord = (row: number): number => row * 20; const pageAt = (row: number): number => view.getUint16(rowRecord(row) + 16, true); @@ -197,7 +197,7 @@ function compileMsdf( ? 0 : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / data.pages[page]![dimension]; }; - return compileBinding({ + return compileFontBinding({ techniqueId, programVariant: 0, glyphCount, @@ -223,10 +223,10 @@ function compileMsdf( ], }, glyphU32: { rows: glyphCount, fields: [(row) => pageAt(row)] }, - strikeF32: emptyTable(glyphCount), - strikeU32: emptyTable(glyphCount), - resourceF32: emptyTable(resources.length), - resourceU32: emptyTable(resources.length), + strikeF32: emptyFontBindingTable(glyphCount), + strikeU32: emptyFontBindingTable(glyphCount), + resourceF32: emptyFontBindingTable(resources.length), + resourceU32: emptyFontBindingTable(resources.length), }); } @@ -236,7 +236,7 @@ function compileSlug( techniqueId: number, identities: RenderWireIdentityRegistry, ): Uint8Array { - const { resources, indexFor } = bindingResources( + const { resources, indexFor } = fontBindingResources( data.pages.map((page) => page.resource), identities, ); @@ -251,7 +251,7 @@ function compileSlug( const verticalBands = (row: number): number => view.getUint16(record(row) + 12, true); const bandScaleX = (row: number): number => (width(row) === 0 ? 0 : verticalBands(row) / width(row)); const bandScaleY = (row: number): number => (height(row) === 0 ? 0 : horizontalBands(row) / height(row)); - return compileBinding({ + return compileFontBinding({ techniqueId, programVariant: 0, glyphCount, @@ -285,14 +285,14 @@ function compileSlug( (row) => verticalBands(row), ], }, - strikeF32: emptyTable(glyphCount), - strikeU32: emptyTable(glyphCount), - resourceF32: emptyTable(resources.length), - resourceU32: emptyTable(resources.length), + strikeF32: emptyFontBindingTable(glyphCount), + strikeU32: emptyFontBindingTable(glyphCount), + resourceF32: emptyFontBindingTable(resources.length), + resourceU32: emptyFontBindingTable(resources.length), }); } -function bindingResources( +export function fontBindingResources( keys: readonly RasterResourceId[], identities: RenderWireIdentityRegistry, ): { @@ -323,7 +323,7 @@ function bindingResources( }; } -function compileBinding(descriptor: FontBindingDescriptor): Uint8Array { +export function compileFontBinding(descriptor: FontBindingDescriptor): Uint8Array { const request = textShaperAbi.layouts.fontBindingRequest; const strike = textShaperAbi.layouts.fontBindingStrike; const resource = textShaperAbi.layouts.fontBindingResource; @@ -399,7 +399,7 @@ function compileBinding(descriptor: FontBindingDescriptor): Uint8Array { return bytes; } -function emptyTable(rows: number): FieldTable { +export function emptyFontBindingTable(rows: number): FontBindingFieldTable { return { rows, fields: [] }; } diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index 6bc87106..c56c0092 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -4,14 +4,14 @@ const MAX_U32 = 0xffff_ffff; const encoder = new TextEncoder(); export const FIRST_PARTY_TRANSFORM_BUFFER_ID = 15; -type PolicyInputScope = keyof typeof textShaperAbi.policy.inputScopes; +export type PolicyInputScope = keyof typeof textShaperAbi.policy.inputScopes; -interface PolicyInput { +export interface PolicyInput { readonly scope: PolicyInputScope; readonly field: number; } -interface PolicyBuffer { +export interface PolicyBuffer { readonly id: number; readonly scalar: number; readonly vectorWidth: number; @@ -21,7 +21,7 @@ interface PolicyBuffer { readonly capacityClass?: number; } -interface PolicyOperation { +export interface PolicyOperation { readonly opcode: number; readonly target?: number; readonly operand0?: number; @@ -31,7 +31,7 @@ interface PolicyOperation { readonly immediate2?: number; } -interface PolicyProgram { +export interface PolicyProgram { readonly techniqueId: number; readonly programId: number; readonly capabilitySetId?: number; @@ -50,7 +50,7 @@ interface PolicyProgram { readonly operations: readonly PolicyOperation[]; } -interface PolicyCapabilitySet { +export interface PolicyCapabilitySet { readonly id: number; readonly flags: number; readonly maxBufferBytes: number; @@ -64,7 +64,7 @@ interface PolicyCapabilitySet { readonly wholeBufferThresholdBasisPoints: number; } -interface PolicyDescriptor { +export interface PolicyDescriptor { readonly capabilitySets: readonly PolicyCapabilitySet[]; readonly programs: readonly PolicyProgram[]; } @@ -117,6 +117,7 @@ export const firstPartyTechniqueWireIds: FirstPartyTechniqueWireIds = Object.fre export function firstPartyThreeRenderPolicyBytes( identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), transformMode: ThreeTransformMode | ThreeTechniqueTransformModes = 'indexed', + additionalPrograms: readonly PolicyProgram[] = [], ): Uint8Array { const bitmap = identities.resolve('pmndrs.bitmap'); const msdf = identities.resolve('pmndrs.msdf'); @@ -125,15 +126,16 @@ export function firstPartyThreeRenderPolicyBytes( typeof transformMode === 'string' ? { bitmap: transformMode, msdf: transformMode, slug: transformMode } : transformMode; - const programs = [ + const programs: PolicyProgram[] = [ bitmapProgram(bitmap, 1, modes.bitmap), msdfProgram(msdf, 2, modes.msdf), slugProgram(slug, 3, modes.slug), + ...additionalPrograms, ]; if (new Set(programs.map((program) => program.techniqueId)).size !== programs.length) { throw new TypeError('first-party raster technique wire identities collide'); } - return compilePolicy({ capabilitySets: [threeCapabilitySet()], programs }); + return compileRenderPolicy({ capabilitySets: [threeCapabilitySet()], programs }); } function threeCapabilitySet(): PolicyCapabilitySet { @@ -399,7 +401,7 @@ function transformIndexBuffer(): PolicyBuffer { }; } -function compilePolicy(descriptor: PolicyDescriptor): Uint8Array { +export function compileRenderPolicy(descriptor: PolicyDescriptor): Uint8Array { const request = textShaperAbi.layouts.policyRequest; const capability = textShaperAbi.layouts.policyCapabilitySet; const programLayout = textShaperAbi.layouts.policyProgram; diff --git a/packages/text/src/loaded-font.ts b/packages/text/src/loaded-font.ts index fe539f24..70ce73ca 100644 --- a/packages/text/src/loaded-font.ts +++ b/packages/text/src/loaded-font.ts @@ -43,10 +43,7 @@ const loadedFontState = new WeakMap, LoadedFontSt export function createFontStack< Primary extends AnyRasterTechnique, const Fallback extends readonly LoadedFont[], ->( - primary: LoadedFont, - ...fallback: Fallback -): FontStack> { +>(primary: LoadedFont, ...fallback: Fallback): FontStack> { type Technique = Primary | TechniqueOfLoadedFont; const fonts = [primary, ...fallback] as unknown as [LoadedFont, ...LoadedFont[]]; assertLoadedFont(primary); diff --git a/packages/text/src/r3f.ts b/packages/text/src/r3f.ts index 760c58d2..44e2ef88 100644 --- a/packages/text/src/r3f.ts +++ b/packages/text/src/r3f.ts @@ -83,9 +83,7 @@ const fontPromises = new Map>>(); const techniqueIds = new WeakMap(); let nextTechniqueId = 1; -export function Text( - input: R3fTextProps, -): ReactElement | null { +export function Text(input: R3fTextProps): ReactElement | null { const { ref: forwardedRef, ...properties } = input; const flattened = useMemo(() => flattenText(properties.children), [properties.children]); const desired = textProperties(properties, flattened); @@ -168,7 +166,7 @@ export function TextGroup(input: R3fTextGroupProps): ReactElement | null { if (object === undefined) return; if (properties.capacity !== undefined && !sameCapacity(properties.capacity, object)) object.setCapacity(properties.capacity); - object.material = properties.material; + object.setMaterial(properties.material); invalidate(); }, [invalidate, object, properties.capacity, properties.material]); diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index d7d61809..3011cb77 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -20,6 +20,13 @@ export type { export { FontLoader } from './three/font-loader.js'; export { defineTextMaterial } from './three/material.js'; export type { ThreeTextMaterial, ThreeTextMaterialContext } from './three/material.js'; +export { registerThreeRasterPlanProgram, threePolicyAbi } from './three/plan-program-registry.js'; +export type { + ThreePlanProgramBuffer, + ThreePlanProgramFontCompiler, + ThreePlanProgramMaterialContext, + ThreeRasterPlanProgram, +} from './three/plan-program-registry.js'; export { msdfShader } from './three/msdf-shader.js'; export type { ThreeMsdfInstanceNodes, ThreeMsdfShaderOutput, ThreeMsdfShaderResources } from './three/msdf-shader.js'; export { registerThreeRasterProgram } from './three/program-registry.js'; @@ -41,10 +48,4 @@ export type { ThreeSlugShaderResources, } from './three/slug-shader.js'; export { Text, TextGroup } from './three/text.js'; -export type { - StandaloneTextProperties, - TextGroupOptions, - TextProperties, - TextSpan, - TextUpdate, -} from './three/text.js'; +export type { StandaloneTextProperties, TextGroupOptions, TextProperties, TextSpan, TextUpdate } from './three/text.js'; diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index a0edf07f..3e81eea2 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -14,6 +14,7 @@ import { msdfShader } from './msdf-shader.js'; import { invalidatePboTexture } from './retained-target.js'; import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; import type { ThreeTextMaterialContext } from './material.js'; +import type { ThreePlanProgramBuffer } from './plan-program-registry.js'; type ScalarArray = Float32Array | Uint32Array | Uint16Array; @@ -144,8 +145,14 @@ export class ThreeTextRenderPlanExecutor { const object = this.#owner.objectForTransform(index); object.updateWorldMatrix(true, false); this.#relativeTransform.multiplyMatrices(this.#rootInverse, object.matrixWorld); - if (matrixEquals(target, index * 16, this.#relativeTransform.elements)) continue; - target.set(this.#relativeTransform.elements, index * 16); + const offset = index * 16; + if (object.visible) { + if (matrixEquals(target, offset, this.#relativeTransform.elements)) continue; + target.set(this.#relativeTransform.elements, offset); + } else { + if (zeroMatrixEquals(target, offset)) continue; + target.fill(0, offset, offset + 16); + } this.#transformAttribute.addUpdateRange(index * 16, 16); changed += 1; indexedChanged += 1; @@ -154,12 +161,19 @@ export class ThreeTextRenderPlanExecutor { const transformId = directTransformId(draw); if (transformId === 0) continue; const object = this.#owner.objectForTransform(transformId); + let drawChanged = false; + if (draw.visible !== object.visible) { + draw.visible = object.visible; + drawChanged = true; + } object.updateWorldMatrix(true, false); this.#relativeTransform.multiplyMatrices(this.#rootInverse, object.matrixWorld); - if (draw.matrix.equals(this.#relativeTransform)) continue; - draw.matrix.copy(this.#relativeTransform); - draw.matrixWorldNeedsUpdate = true; - changed += 1; + if (!draw.matrix.equals(this.#relativeTransform)) { + draw.matrix.copy(this.#relativeTransform); + draw.matrixWorldNeedsUpdate = true; + drawChanged = true; + } + if (drawChanged) changed += 1; } if (changed === 0) return 0; if (indexedChanged !== 0) { @@ -518,9 +532,59 @@ export class ThreeTextRenderPlanExecutor { if (resolved.technique === bitmap.id) return this.#bitmapMaterial(resource, buffers, materialId, transform); if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId, transform); if (resolved.technique === slug.id) return this.#slugMaterial(resource, buffers, materialId, transform); + if ('program' in resolved) return this.#planProgramMaterial(resource, resolved, buffers, materialId, transform); throw new Error('this Three plan target does not recognize the draw technique'); } + #planProgramMaterial( + resource: RetainedResource, + resolved: Extract, + buffers: ReadonlyMap, + materialId: number, + transform: TransformRealization, + ): THREE.NodeMaterial { + const required = [...buffers.values()]; + const key = `external:${resource.id}:${resource.generation}:${materialId}:${required + .map((buffer) => `${buffer.policyBufferId}:${buffer.id}:${buffer.generation}`) + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}`; + const cached = this.#materials.get(key); + if (cached !== undefined) return cached.material; + const runStart = TSL.uniform(0, 'uint').onObjectUpdate( + ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, + ); + const instance = TSL.instanceIndex.add(runStart); + const publicBuffers = new Map( + [...buffers].map(([id, buffer]) => [ + id, + { + scalarType: buffer.scalarType, + vectorWidth: buffer.vectorWidth, + attribute: buffer.attribute, + }, + ]), + ); + const material = this.#ownMaterial( + resolved.program.createMaterial({ + resource: resolved.resource, + buffers: publicBuffers, + instance, + materialId, + material: this.#coordinator.resolveMaterial(materialId), + transformPosition: (position) => + transform.kind === 'indexed' + ? indexedTransformPosition(position, transform.indices.attribute, this.#transformAttribute, instance) + : position, + }), + ); + this.#retainMaterial( + key, + material, + resource, + transform.kind === 'indexed' ? [...required, transform.indices] : required, + ); + return material; + } + #msdfMaterial( resource: RetainedResource, buffers: ReadonlyMap, @@ -743,7 +807,10 @@ export class ThreeTextRenderPlanExecutor { #createMaterial(materialId: number, context: ThreeTextMaterialContext): THREE.NodeMaterial { const definition = this.#coordinator.resolveMaterial(materialId); - const material = definition?.create(context) ?? context.createDefaultMaterial(); + return this.#ownMaterial(definition?.create(context) ?? context.createDefaultMaterial()); + } + + #ownMaterial(material: THREE.NodeMaterial): THREE.NodeMaterial { if (material?.isNodeMaterial !== true) throw new TypeError('text material factory must return a Three NodeMaterial'); if (this.#ownedMaterials.has(material)) { @@ -946,6 +1013,13 @@ function matrixEquals(target: Float32Array, offset: number, matrix: readonly num return true; } +function zeroMatrixEquals(target: Float32Array, offset: number): boolean { + for (let index = 0; index < 16; index += 1) { + if (target[offset + index] !== 0) return false; + } + return true; +} + function indexedTransformPosition( position: THREE.Node<'vec3'>, indexAttribute: THREE.StorageInstancedBufferAttribute, diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts index d220857b..ba23ad96 100644 --- a/packages/text/src/three/engine-runtime.ts +++ b/packages/text/src/three/engine-runtime.ts @@ -8,6 +8,7 @@ import { firstPartyFontBindingBytes } from '../internal/font-binding-wire.js'; import { firstPartyThreeRenderPolicyBytes, type ThreeTransformMode } from '../internal/render-policy-wire.js'; import { TextEngineHost, type TextEngineSession, type TextEngineSessionOptions } from '../internal/text-engine-host.js'; import type { ThreeTextMaterial } from './material.js'; +import { compiledThreeRasterPlanPrograms, type CompiledThreeRasterPlanProgram } from './plan-program-registry.js'; const POLICY_HANDLE = 1; const MAX_U32 = 0xffff_ffff; @@ -37,7 +38,8 @@ interface RetainedMaterial { export type ThreeTextEngineResource = | Readonly<{ technique: typeof bitmap.id; page: BitmapPageData }> | Readonly<{ technique: typeof msdf.id; data: MsdfData }> - | Readonly<{ technique: typeof slug.id; page: SlugPageData }>; + | Readonly<{ technique: typeof slug.id; page: SlugPageData }> + | Readonly<{ technique: string; resource: unknown; program: CompiledThreeRasterPlanProgram }>; export interface ThreeTextEngineCoordinatorOptions { /** Renderer-policy choice; indexed is the first-party high-throughput default. */ @@ -52,6 +54,7 @@ export class ThreeTextEngineCoordinator { readonly #stacks = new Map(); readonly #materialHandles = new WeakMap(); readonly #materials = new Map(); + readonly #planPrograms: ReadonlyMap; #nextBindingHandle = 1; #nextStackHandle = 1; #nextSessionHandle = 1; @@ -60,9 +63,15 @@ export class ThreeTextEngineCoordinator { constructor(runtime: Pick, options: ThreeTextEngineCoordinatorOptions = {}) { this.host = new TextEngineHost(runtime.shaper); + const planPrograms = compiledThreeRasterPlanPrograms(this.host.wireIdentities); + this.#planPrograms = new Map(planPrograms.map((program) => [program.technique.id, program])); this.host.registerPolicy( POLICY_HANDLE, - firstPartyThreeRenderPolicyBytes(this.host.wireIdentities, options.transformMode), + firstPartyThreeRenderPolicyBytes( + this.host.wireIdentities, + options.transformMode, + planPrograms.map((program) => program.policy), + ), ); } @@ -149,8 +158,21 @@ export class ThreeTextEngineCoordinator { const existing = this.#bindingHandles.get(font); if (existing !== undefined) return existing; const handle = this.#allocateBindingHandle(); - this.#registerResources(font); - this.host.registerFontBinding(handle, font.font.handle, firstPartyFontBindingBytes(font, this.host.wireIdentities)); + const program = this.#planPrograms.get(font.technique.id); + if (program === undefined) { + this.#registerResources(font); + this.host.registerFontBinding( + handle, + font.font.handle, + firstPartyFontBindingBytes(font, this.host.wireIdentities), + ); + } else { + const compiled = program.compileFont(font, this.host.wireIdentities); + for (const [key, resource] of compiled.resources) { + this.#retainResource(key, { technique: font.technique.id, resource, program }); + } + this.host.registerFontBinding(handle, font.font.handle, compiled.binding); + } this.#bindingHandles.set(font, handle); return handle; } diff --git a/packages/text/src/three/plan-program-registry.ts b/packages/text/src/three/plan-program-registry.ts new file mode 100644 index 00000000..cca244c8 --- /dev/null +++ b/packages/text/src/three/plan-program-registry.ts @@ -0,0 +1,145 @@ +import type { Node, NodeMaterial, StorageInstancedBufferAttribute } from 'three/webgpu'; + +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { FontBindingDescriptor, FontBindingFieldTable } from '../internal/font-binding-wire.js'; +import { compileFontBinding, emptyFontBindingTable, fontBindingResources } from '../internal/font-binding-wire.js'; +import type { PolicyProgram } from '../internal/render-policy-wire.js'; +import { RenderWireIdentityRegistry } from '../internal/render-policy-wire.js'; +import type { LoadedFont } from '../loaded-font.js'; +import type { AnyRasterTechnique, RasterResourceId } from '../raster-technique.js'; +import type { ThreeTextMaterial } from './material.js'; + +export interface ThreePlanProgramBuffer { + readonly scalarType: number; + readonly vectorWidth: number; + readonly attribute: StorageInstancedBufferAttribute; +} + +export interface ThreePlanProgramMaterialContext { + readonly resource: Resource; + readonly buffers: ReadonlyMap; + readonly instance: Node<'uint'>; + readonly materialId: number; + readonly material: ThreeTextMaterial | undefined; + transformPosition(position: Node<'vec3'>): Node<'vec3'>; +} + +export interface ThreePlanProgramFontCompiler { + readonly font: LoadedFont; + readonly techniqueId: number; + readonly identities: RenderWireIdentityRegistry; + readonly emptyTable: (rows: number) => FontBindingFieldTable; + resources(keys: readonly RasterResourceId[]): ReturnType; + compile(descriptor: FontBindingDescriptor): Uint8Array; + retain(key: RasterResourceId, resource: Resource): void; +} + +export interface ThreeRasterPlanProgram { + readonly technique: Technique; + /** Static validated policy bytecode descriptor. IDs are supplied by the renderer registry. */ + readonly policy: Omit; + /** Cold font registration; never runs during frame shaping, layout, packing, or draw submission. */ + compileFont(compiler: ThreePlanProgramFontCompiler): void; + /** Renderer realization invoked only when a compatible retained material is absent. */ + createMaterial(context: ThreePlanProgramMaterialContext): NodeMaterial; +} + +export interface CompiledThreeRasterPlanProgram { + readonly technique: AnyRasterTechnique; + readonly techniqueId: number; + readonly programId: number; + readonly policy: PolicyProgram; + compileFont( + font: LoadedFont, + identities: RenderWireIdentityRegistry, + ): Readonly<{ binding: Uint8Array; resources: ReadonlyMap }>; + createMaterial(context: ThreePlanProgramMaterialContext): NodeMaterial; +} + +const programs = new Map>(); + +/** + * Register declarative Rust packing policy plus cold font/resource and renderer realization code. + * Registration must happen before the runtime-scoped Three coordinator is first created. + */ +export function registerThreeRasterPlanProgram( + program: ThreeRasterPlanProgram, +): void { + const erased = program as unknown as ThreeRasterPlanProgram; + const existing = programs.get(program.technique.id); + if (existing !== undefined && existing !== erased) { + throw new TypeError(`a different Three raster plan program is already registered for "${program.technique.id}"`); + } + programs.set(program.technique.id, erased); +} + +/** @internal Compile the cold registry snapshot into one Rust policy and exact font compilers. */ +export function compiledThreeRasterPlanPrograms( + identities: RenderWireIdentityRegistry, +): readonly CompiledThreeRasterPlanProgram[] { + return [...programs.values()] + .sort((left, right) => left.technique.id.localeCompare(right.technique.id)) + .map((program) => compileProgram(program, identities)); +} + +export interface ThreePolicyAbi { + readonly opcodes: typeof textShaperAbi.policy.opcodes; + readonly scalarTypes: typeof textShaperAbi.policy.scalarTypes; + readonly bufferUsage: typeof textShaperAbi.policy.bufferUsage; + readonly allocationStrategies: typeof textShaperAbi.policy.allocationStrategies; + readonly batchFields: typeof textShaperAbi.policy.batchFields; + readonly semanticF32Fields: typeof textShaperAbi.engine.semanticF32Fields; + readonly semanticU32Fields: typeof textShaperAbi.engine.semanticU32Fields; + readonly transformBufferId: 15; +} + +export const threePolicyAbi: ThreePolicyAbi = Object.freeze({ + opcodes: textShaperAbi.policy.opcodes, + scalarTypes: textShaperAbi.policy.scalarTypes, + bufferUsage: textShaperAbi.policy.bufferUsage, + allocationStrategies: textShaperAbi.policy.allocationStrategies, + batchFields: textShaperAbi.policy.batchFields, + semanticF32Fields: textShaperAbi.engine.semanticF32Fields, + semanticU32Fields: textShaperAbi.engine.semanticU32Fields, + transformBufferId: 15, +}); + +function compileProgram( + program: ThreeRasterPlanProgram, + identities: RenderWireIdentityRegistry, +): CompiledThreeRasterPlanProgram { + const techniqueId = identities.resolve(program.technique.id); + const programId = identities.resolve(`${program.technique.id}/three-plan-program`); + return { + technique: program.technique, + techniqueId, + programId, + policy: { ...program.policy, techniqueId, programId }, + compileFont(font, bindingIdentities) { + if (font.technique.id !== program.technique.id) { + throw new TypeError('Three raster plan program received an incompatible loaded font'); + } + let binding: Uint8Array | undefined; + const resources = new Map(); + program.compileFont({ + font, + techniqueId, + identities: bindingIdentities, + emptyTable: emptyFontBindingTable, + resources: (keys) => fontBindingResources(keys, bindingIdentities), + compile(descriptor) { + if (binding !== undefined) throw new Error('Three raster plan font compiler produced more than one binding'); + binding = compileFontBinding(descriptor); + return binding; + }, + retain(key, resource) { + if (resources.has(key)) throw new TypeError(`Three raster plan font retained duplicate resource "${key}"`); + resources.set(key, resource); + }, + }); + if (binding === undefined) throw new Error('Three raster plan font compiler produced no binding'); + return { binding, resources }; + }, + createMaterial: (context) => program.createMaterial(context), + }; +} diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index d89a45c4..2d91a3a3 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -314,14 +314,16 @@ export class TextGroup extends THREE.Object3D { return this.#material; } set material(value: ThreeTextMaterial | undefined) { + this.setMaterial(value); + } + setMaterial(value: ThreeTextMaterial | undefined): void { this.#material = value; this.#binding?.invalidateMaterial(); } override add(...children: THREE.Object3D[]): this { this.#assertActive(); - for (const child of children) - if (child instanceof Text) validateText(child as Text); + for (const child of children) if (child instanceof Text) validateText(child as Text); return super.add(...children); } setCapacity(capacity: GlyphBufferCapacity): void { @@ -415,11 +417,7 @@ class ThreeTextBatchBinding { #materialInvalidated = false; #disposed = false; - constructor( - runtime: TextRuntime, - capacity: GlyphBufferCapacity, - group: TextGroup | undefined, - ) { + constructor(runtime: TextRuntime, capacity: GlyphBufferCapacity, group: TextGroup | undefined) { this.#runtime = runtime; this.#group = group; this.#coordinator = threeTextEngineCoordinator(runtime); @@ -625,10 +623,7 @@ class ThreeTextBatchBinding { this.#textsByParagraph.clear(); this.#removed.length = 0; } - #ensureText( - text: Text, - group: TextGroup | undefined, - ): void { + #ensureText(text: Text, group: TextGroup | undefined): void { validateBinding(this.#runtime, text); let paragraph = this.#paragraphs.get(text); if (paragraph === undefined) { @@ -693,11 +688,7 @@ function compileEngineStyles( ]; for (const [index, span] of (properties.spans ?? []).entries()) { const fontStackHandle = span.font === undefined ? undefined : acquireEngineStack(coordinator, span.font, leases); - const materialId = acquireEngineMaterial( - coordinator, - (span as TextSpan).material, - materialLeases, - ); + const materialId = acquireEngineMaterial(coordinator, (span as TextSpan).material, materialLeases); styles.push({ opcode: 'upsert', paragraphId, @@ -891,9 +882,7 @@ function ownPublication(publication: TextEnginePublication): TextEnginePublicati * against unrelated text, so an update that replaces text without stating spans * clears the ones it replaced. */ -function replacedContent( - update: TextUpdate, -): TextUpdate { +function replacedContent(update: TextUpdate): TextUpdate { if (!('text' in update) || 'spans' in update) return update; return { ...update, spans: [] } as TextUpdate; } @@ -906,9 +895,7 @@ function normalizeDesired( return Object.freeze({ font: properties.font, text: formatted?.text ?? (properties.text as string), - spans: Object.freeze([ - ...((formatted?.spans as readonly TextSpan[]) ?? properties.spans ?? []), - ]), + spans: Object.freeze([...((formatted?.spans as readonly TextSpan[]) ?? properties.spans ?? [])]), contentBox: Object.freeze({ ...(properties.contentBox ?? {}) }), style: Object.freeze({ ...(properties.style ?? {}) }), paint: Object.freeze({ ...(properties.paint ?? {}) }), @@ -957,9 +944,7 @@ function nearestTextGroup(object: THREE.Object3D): TextGroup | undefined { } return undefined; } -function eraseTextTechnique( - text: Text, -): Text { +function eraseTextTechnique(text: Text): Text { return text as unknown as Text; } function collectTextDescendants(group: TextGroup): Text[] { diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 140eb560..c9cdc007 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -382,6 +382,16 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.equal(target.syncTransforms(), 1); assert.equal(transformTableAttribute.version, unchangedTransformVersion + 1); assert.equal(transformTableAttribute.array[1 * 16 + 12], 4); + paragraphObjects.get(1).visible = false; + assert.equal(target.syncTransforms(), 1); + assert.deepEqual( + Array.from(transformTableAttribute.array.subarray(16, 32)), + Array(16).fill(0), + 'indexed draws suppress only the hidden Text instances without splitting the shared draw', + ); + paragraphObjects.get(1).visible = true; + assert.equal(target.syncTransforms(), 1); + assert.equal(transformTableAttribute.array[1 * 16 + 12], 4); const reorderedPublication = session.update( compileTextEngineFrameUpdate({ @@ -653,6 +663,12 @@ test('Three coordinator shares shaping data across technique bindings and refere paragraphObjects.get(2).position.x = 9; assert.equal(directTarget.syncTransforms(), 1); assert.equal(directTarget.draws[1].matrix.elements[12], 9); + paragraphObjects.get(2).visible = false; + assert.equal(directTarget.syncTransforms(), 1); + assert.equal(directTarget.draws[1].visible, false); + paragraphObjects.get(2).visible = true; + assert.equal(directTarget.syncTransforms(), 1); + assert.equal(directTarget.draws[1].visible, true); directTarget.dispose(); directSession.dispose(); diff --git a/packages/text/tests/types/r3f-v1-api.test.ts b/packages/text/tests/types/r3f-v1-api.test.ts index 23aaf4a2..889cd05f 100644 --- a/packages/text/tests/types/r3f-v1-api.test.ts +++ b/packages/text/tests/types/r3f-v1-api.test.ts @@ -26,8 +26,5 @@ function FontConsumer(): null { // @ts-expect-error The selected font technique must match the Text technique. createElement(Text, { font: mtsdfFont }, 'wrong technique'); -// @ts-expect-error Material replaced the obsolete renderer-variant surface. -createElement(Text, { font: bitmapFont, renderVariant: 'old' }, 'old API'); - void labels; void FontConsumer; From ccf8958d79efbbd70b3b810bc253afd6d4fdcade Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 03:30:16 -0400 Subject: [PATCH 078/128] feat(text): query Rust layout measurements --- docs/log.md | 7 + docs/packages/text.md | 9 +- docs/planning/decision-register.md | 2 + docs/planning/rust-layout-engine.md | 13 +- packages/text/rust/shaper/src/abi_contract.rs | 41 +-- packages/text/rust/shaper/src/engine/frame.rs | 3 + .../text/rust/shaper/src/engine/frame_wire.rs | 26 +- .../rust/shaper/src/engine/layout_query.rs | 268 ++++++++++++++++++ packages/text/rust/shaper/src/engine/mod.rs | 2 + .../rust/shaper/src/engine/ordered_plan.rs | 14 +- .../rust/shaper/src/engine/render_plan.rs | 27 -- .../shaper/src/engine/render_plan_wire.rs | 51 +++- .../rust/shaper/src/engine/semantic_view.rs | 32 +++ .../rust/shaper/src/engine/stable_plan.rs | 12 +- packages/text/rust/shaper/src/engine/state.rs | 61 ++++ .../text/rust/shaper/src/engine/transport.rs | 16 +- packages/text/rust/shaper/src/wasm.rs | 21 +- .../text/src/generated/text-shaper-abi.ts | 14 +- .../text/src/internal/layout-query-view.ts | 121 ++++++++ .../text/src/internal/render-plan-view.ts | 15 +- .../text/src/internal/text-engine-host.ts | 2 + packages/text/src/three/text.ts | 40 +++ .../text/tests/integration/three-v1.test.mjs | 11 + .../text/tests/types/three-v1-api.test.ts | 2 + 24 files changed, 716 insertions(+), 94 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/layout_query.rs create mode 100644 packages/text/rust/shaper/src/engine/semantic_view.rs create mode 100644 packages/text/src/internal/layout-query-view.ts diff --git a/docs/log.md b/docs/log.md index 764927c9..cc756c3d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-09 +- **Separated semantic measurement from the render plan** — Activated the existing `semanticViewMask` for an explicit + retained-Rust measurement query while ordinary rendering continues to request zero semantic records. The first view + publishes one paragraph summary plus its line records in the immutable A/B sidecar; Three's command-buffer executor + ignores it. Public `Text.measureLayout()` caches the frozen result until a committed semantic update. Rust exact and + at-most/overflow tests plus a compiled-Wasm Three lifecycle prove the query retains the existing mesh and does not + restore the removed `Text.layout` arrays. + - **Proved external Rust plan programs on both Three backends** — Replaced the glyph-example package's renderer-side `ParagraphBatchTarget`, revision transfer, packing, dirty upload, and mesh transaction with a static policy program, cold font-binding compiler, and plan-buffer material factory. A compiled-Wasm lifecycle proves Rust-packed buffers diff --git a/docs/packages/text.md b/docs/packages/text.md index b3794d8d..21b0d486 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:36708c3ebe0e3ee71edc3df4355bd7e68986f64597152fabd7ce196c0b5ac1c7' +source_digest: 'sha256:aa033b7909cfedec42bced6bd5e9b6b69d4c7fc7490a1f85e4277858498bee23' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -938,6 +938,13 @@ compiled-Wasm lifecycle retains mesh and geometry identity over a text mutation. frames are deterministic and byte-identical across backends. Individual visibility does not split an indexed draw or cross into Wasm: the renderer zeros only that text's matrix-sidecar slot; direct-policy draws mirror object visibility. +Semantic measurement is now the first explicit query over that retained Rust state. `Text.measureLayout()` requests one +paragraph summary and its line records through the existing frame ABI only when the committed result is absent from its +cache. A normal rendering update requests no semantic records, and the command-buffer executor never reads the query +sidecar. A semantic edit clears the cache; repeated measurement of the same committed layout returns the same frozen +object without another crossing. Rust tests cover exact and at-most box resolution plus overflow, and the compiled-Wasm +Three fixture proves measurement retains the existing mesh while the removed `Text.layout` arrays remain absent. + Public font stacks no longer repeat a raster technique or require every fallback font to share one. Their generic type is the union of the concrete loaded-font techniques, while runtime construction still requires one text-runtime domain, unique font identities, and live font leases. Public Three `TextGroup` likewise has no authored technique: its retained diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 1c7166c8..86b3af14 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -310,6 +310,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-225 | A third-party Three raster integrates with the Rust command buffer through one declarative `registerThreeRasterPlanProgram` registration. Its static policy descriptor is compiled and validated before the engine session exists; it contains input, physical-buffer, scalar-operation, and batching-key data but no hot JavaScript callback. Its cold font compiler lowers validated package-owned raster fields and resource references into one Rust binding, while its renderer factory realizes a material from exact plan buffers only when a draw requires it. The external glyph-example package no longer owns a `ParagraphBatchTarget`, candidate revision, slack planner, dirty-range copier, mesh transaction, or renderer-side layout loop. Compiled-Wasm lifecycle coverage proves Rust-packed buffers and retained mesh/geometry identity. A hardware-browser proof produces two deterministic samples on both WebGPU and forced WebGL2 with the same RGBA SHA-256 `817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`. Indexed visibility remains renderer-local: hiding one public `Text` zeros only its matrix-sidecar slot and preserves the shared draw; direct-policy draws mirror that object's mesh visibility. | Accepted | +| D-226 | Semantic layout inspection is an explicit demand-shaped Rust query, not renderer input and not a reason to retain the legacy TypeScript layout path. The query uses the existing `semanticViewMask` on `text_update`; zero remains the ordinary rendering request. Its records may share the immutable A/B publication lifetime, but `ThreeTextRenderPlanExecutor` ignores them. The first public `Text.measureLayout()` view emits one paragraph summary plus its line records, performs one extra crossing only on an uncached explicit request, and invalidates on a committed semantic update. Record-level Rust tests cover exact/at-most sizing and overflow, while a real compiled-Wasm Three fixture proves the query leaves `Text.layout` absent and retains the existing mesh. Per-glyph inspection, caret, selection, hit testing, accessibility, and diagnostics remain separate masks and do not enter the render plan by default. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 4e59aa18..ef4410b2 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -141,8 +141,9 @@ renderer. hidden typography crossings. - Per-line widths are computed inside Rust from declarative flow regions, columns, exclusions, and inline objects. An arbitrary host callback per line is incompatible with both a single crossing and native reuse. -- The engine output contains semantic layout state and a portable render plan. Canonical GPU-ready records are requested - views within that plan, not the only representation of the result. +- The engine retains semantic layout state and publishes a portable render plan. Rendering receives only policy-packed + physical records and commands. Measurement, caret, selection, hit testing, accessibility, and diagnostics are + separate demand-shaped views requested explicitly from the retained Rust state. - Render implementations register a versioned render-plan policy describing formats, batching compatibility, capabilities, patch preferences, and permitted augmentations. Stable policies are referenced by ID on updates rather than serialized every frame. @@ -739,11 +740,10 @@ The render plan is a revisioned display-list and resource transaction, following renderers such as WebRender: rendering intent and resource changes are portable; backend command encoding is not. [^webrender] -It contains: +The render plan contains: - **identity:** ABI, engine revision, plan revision, required base revision, policy/capability hashes, and output generation; -- **semantic tables:** optional line, fragment, run, cluster, logical/visual, caret, selection, and inserted-glyph tables; - **resources:** stable IDs, generations, bounds, creation/update/retirement intent, and technique-specific references; - **buffers:** stable buffer IDs, schemas, live lengths, capacities, and allocation generations; - **patches:** allocate/resize, write range, fill, copy/relocate, and retire operations referencing exact payload spans in @@ -754,6 +754,11 @@ It contains: argument records; and - **retirement:** the earliest generation after which resources, slots, and output bytes may be reused. +An explicitly requested semantic query may share the immutable A/B publication and its lifetime, but it is a sidecar, +not a render-plan table and not an input to the renderer executor. The first admitted view contains one paragraph +measurement record plus its line records; its mask is zero on ordinary rendering updates. Glyph inspection, caret, +selection, hit testing, accessibility, and diagnostics must each prove a bounded record shape before admission. + The initial adapter lowers this IR to Three attributes, TSL storage nodes, and draws. The same graph runs through Three's WebGPU backend and forced WebGL2 backend. In Three 0.185.1, WebGL PBO setup replaces the supplied typed array with a power-of-two-padded retained array and a `DataTexture`; the adapter therefore performs one explicit copy into that diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 01050681..3be3c391 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -17,13 +17,12 @@ use crate::engine::frame::{ SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, - SEMANTIC_U32_TRANSFORM_INDEX, - SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, - STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, - STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, - STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, - STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, - STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_MASK, SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, + SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, + STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, + STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, + STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, + STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; @@ -44,8 +43,10 @@ use crate::engine::render_plan::{ PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_BUFFER, RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, ResourceRecord, RetirementRecord, +}; +use crate::engine::semantic_view::{ SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_INSERTED_GLYPH, SEMANTIC_LINE, - SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, + SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, }; pub const ABI_VERSION: u32 = 0; @@ -415,8 +416,8 @@ struct EngineResultHeader { capability_set: u32, policy_fingerprint_low: u32, policy_fingerprint_high: u32, - semantics_offset: u32, - semantics_count: u32, + semantic_views_offset: u32, + semantic_view_count: u32, resources_offset: u32, resource_count: u32, buffers_offset: u32, @@ -1701,12 +1702,12 @@ field_offset!( field_offset!( ENGINE_RESULT_SEMANTICS_OFFSET, EngineResultHeader, - semantics_offset + semantic_views_offset ); field_offset!( ENGINE_RESULT_SEMANTICS_COUNT, EngineResultHeader, - semantics_count + semantic_view_count ); field_offset!( ENGINE_RESULT_RESOURCES_OFFSET, @@ -2354,8 +2355,8 @@ pub fn json() -> String { "capabilitySet": ENGINE_RESULT_CAPABILITY_SET, "policyFingerprintLow": ENGINE_RESULT_POLICY_FINGERPRINT_LOW, "policyFingerprintHigh": ENGINE_RESULT_POLICY_FINGERPRINT_HIGH, - "semanticsOffset": ENGINE_RESULT_SEMANTICS_OFFSET, - "semanticsCount": ENGINE_RESULT_SEMANTICS_COUNT, + "semanticViewsOffset": ENGINE_RESULT_SEMANTICS_OFFSET, + "semanticViewCount": ENGINE_RESULT_SEMANTICS_COUNT, "resourcesOffset": ENGINE_RESULT_RESOURCES_OFFSET, "resourceCount": ENGINE_RESULT_RESOURCE_COUNT, "buffersOffset": ENGINE_RESULT_BUFFERS_OFFSET, @@ -2371,7 +2372,7 @@ pub fn json() -> String { "diagnosticsOffset": ENGINE_RESULT_DIAGNOSTICS_OFFSET, "diagnosticCount": ENGINE_RESULT_DIAGNOSTIC_COUNT }, - "engineSemantic": { + "engineSemanticView": { "size": SEMANTIC_RECORD_SIZE, "alignment": SEMANTIC_RECORD_ALIGNMENT, "id": SEMANTIC_ID, @@ -2752,6 +2753,13 @@ pub fn json() -> String { "resultFlags": { "checkpoint": RESULT_FLAG_CHECKPOINT }, + "semanticViewMasks": { + "all": SEMANTIC_VIEW_MASK, + "measurement": SEMANTIC_VIEW_MEASUREMENT + }, + "measurementFlags": { + "overflowed": crate::engine::layout_query::MEASUREMENT_FLAG_OVERFLOWED + }, "semanticKinds": { "line": SEMANTIC_LINE, "fragment": SEMANTIC_FRAGMENT, @@ -2759,7 +2767,8 @@ pub fn json() -> String { "cluster": SEMANTIC_CLUSTER, "caret": SEMANTIC_CARET, "selection": SEMANTIC_SELECTION, - "insertedGlyph": SEMANTIC_INSERTED_GLYPH + "insertedGlyph": SEMANTIC_INSERTED_GLYPH, + "paragraphMeasurement": SEMANTIC_PARAGRAPH_MEASUREMENT }, "resourceActions": { "create": RESOURCE_ACTION_CREATE, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index 8bd9cb83..10670a6b 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -1,4 +1,6 @@ pub(crate) const RESULT_FLAG_CHECKPOINT: u32 = 1; +pub(crate) const SEMANTIC_VIEW_MEASUREMENT: u32 = 1 << 0; +pub(crate) const SEMANTIC_VIEW_MASK: u32 = SEMANTIC_VIEW_MEASUREMENT; pub(crate) const TEXT_MUTATION_REPLACE_UTF16: u8 = 1; pub(crate) const TEXT_ENCODING_UTF16_LE: u8 = 1; @@ -90,6 +92,7 @@ pub(crate) struct UpdateRequest<'a> { pub acknowledged_publication_generation: u32, pub policy_handle: u32, pub capability_set: u32, + pub semantic_view_mask: u32, pub limits: UpdateLimits, pub paragraph_mutations: super::semantic_wire::ParagraphMutationBatch<'a>, pub text_mutations: super::semantic_wire::TextMutationBatch<'a>, diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 626bb486..0edb1edb 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -44,10 +44,13 @@ pub(crate) fn parse_update_request( || read_u32(bytes, ENGINE_UPDATE_BYTE_LENGTH)? != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? || read_u32(bytes, ENGINE_UPDATE_FLAGS)? != 0 - || read_u32(bytes, ENGINE_UPDATE_SEMANTIC_VIEW_MASK)? != 0 { return Err(STATUS_INVALID_REQUEST); } + let semantic_view_mask = read_u32(bytes, ENGINE_UPDATE_SEMANTIC_VIEW_MASK)?; + if semantic_view_mask & !super::frame::SEMANTIC_VIEW_MASK != 0 { + return Err(STATUS_INVALID_REQUEST); + } for (offset, count) in [( ENGINE_UPDATE_POLICY_PARAMETERS_OFFSET, @@ -140,6 +143,7 @@ pub(crate) fn parse_update_request( )?, policy_handle: read_u32(bytes, ENGINE_UPDATE_POLICY_HANDLE)?, capability_set: positive(bytes, ENGINE_UPDATE_CAPABILITY_SET)?, + semantic_view_mask, limits, paragraph_mutations, text_mutations, @@ -170,6 +174,26 @@ mod tests { assert_eq!(parsed.session_id, 4); assert_eq!(parsed.policy_handle, 9); + let mut measurement = bytes.clone(); + write_u32( + &mut measurement, + ENGINE_UPDATE_SEMANTIC_VIEW_MASK, + super::super::frame::SEMANTIC_VIEW_MEASUREMENT, + ); + assert_eq!( + parse_update_request(&measurement, 4) + .unwrap() + .semantic_view_mask, + super::super::frame::SEMANTIC_VIEW_MEASUREMENT + ); + + let mut unknown_view = bytes.clone(); + write_u32(&mut unknown_view, ENGINE_UPDATE_SEMANTIC_VIEW_MASK, 1 << 31); + assert_eq!( + parse_update_request(&unknown_view, 4), + Err(STATUS_INVALID_REQUEST) + ); + let mut trailing = bytes.clone(); trailing.push(0); let trailing_length = u32::try_from(trailing.len()).unwrap(); diff --git a/packages/text/rust/shaper/src/engine/layout_query.rs b/packages/text/rust/shaper/src/engine/layout_query.rs new file mode 100644 index 00000000..a424d041 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/layout_query.rs @@ -0,0 +1,268 @@ +//! Demand-shaped semantic layout views. +//! +//! These records share the immutable frame publication for lifetime and transport only. They are +//! not render-plan commands and render executors must not consume them. + +use alloc::vec::Vec; + +use super::{ + EngineError, + flow_composition::{FlowFragment, FlowLayoutArena, FlowLine}, + flow_geometry::FlowGeometryArena, + frame::{AXIS_AT_MOST, AXIS_EXACT, AXIS_UNCONSTRAINED}, + semantic_view::{SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SemanticRecord}, +}; + +pub(crate) const MEASUREMENT_FLAG_OVERFLOWED: u16 = 1; + +pub(crate) fn append_measurement( + target: &mut Vec, + paragraph_id: u32, + text_length: usize, + cluster_count: usize, + geometry: &FlowGeometryArena, + flow: &FlowLayoutArena, +) -> Result<(), EngineError> { + let constraint = geometry + .constraints + .first() + .ok_or(EngineError::InvalidRequest)?; + if constraint.paragraph_id != paragraph_id { + return Err(EngineError::InvalidRequest); + } + target + .try_reserve(flow.lines.len().saturating_add(1)) + .map_err(|_| EngineError::ResultTooLarge)?; + + let summary_index = target.len(); + let line_start = + u32::try_from(summary_index.saturating_add(1)).map_err(|_| EngineError::ResultTooLarge)?; + target.push(SemanticRecord::default()); + let mut content_width = 0.0_f64; + let mut content_height = 0.0_f64; + let mut consumed_clusters = + usize::try_from(constraint.resume_cluster).map_err(|_| EngineError::InvalidRequest)?; + + for (index, line) in flow.lines.iter().copied().enumerate() { + if line.flow_thread_id != constraint.flow_thread_id { + continue; + } + let fragments = line_fragments(flow, line)?; + let Some(first) = fragments.first() else { + continue; + }; + let Some(last) = fragments.last() else { + continue; + }; + let inline_start = fragments + .iter() + .map(|fragment| fragment.slot_start) + .fold(f64::INFINITY, f64::min); + let inline_end = fragments + .iter() + .map(|fragment| fragment.slot_start + fragment.line.advance) + .fold(f64::NEG_INFINITY, f64::max); + let advance = (inline_end - inline_start).max(0.0); + content_width = content_width.max(advance); + content_height = content_height.max(line.block_start + line.height); + consumed_clusters = consumed_clusters + .max(usize::try_from(last.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?); + target.push(SemanticRecord { + id: u32::try_from(index.saturating_add(1)).map_err(|_| EngineError::ResultTooLarge)?, + kind: SEMANTIC_LINE, + parent_id: paragraph_id, + text_start: first.line.text_start, + text_end: last.line.text_end, + block_start: finite_f32(line.block_start + line.baseline)?, + inline_extent: finite_nonnegative_f32(advance)?, + block_extent: finite_nonnegative_f32(line.height)?, + ..SemanticRecord::default() + }); + } + + let width = resolve_axis(constraint.width_mode, constraint.width, content_width)?; + let height = resolve_axis(constraint.height_mode, constraint.height, content_height)?; + let overflowed = consumed_clusters < cluster_count + || content_width > f64::from(width) + || content_height > f64::from(height); + let line_count = target.len().saturating_sub(summary_index + 1); + target[summary_index] = SemanticRecord { + id: paragraph_id, + kind: SEMANTIC_PARAGRAPH_MEASUREMENT, + flags: if overflowed { + MEASUREMENT_FLAG_OVERFLOWED + } else { + 0 + }, + text_end: u32::try_from(text_length).map_err(|_| EngineError::ResultTooLarge)?, + item_start: line_start, + item_count: u32::try_from(line_count).map_err(|_| EngineError::ResultTooLarge)?, + inline_start: width, + block_start: height, + inline_extent: finite_nonnegative_f32(content_width)?, + block_extent: finite_nonnegative_f32(content_height)?, + ..SemanticRecord::default() + }; + Ok(()) +} + +fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { + let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + flow.fragments + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + +fn resolve_axis(mode: u8, requested: f32, content: f64) -> Result { + match mode { + AXIS_UNCONSTRAINED => finite_nonnegative_f32(content), + AXIS_AT_MOST => finite_nonnegative_f32(content.min(f64::from(requested))), + AXIS_EXACT => Ok(requested), + _ => Err(EngineError::InvalidRequest), + } +} + +fn finite_f32(value: f64) -> Result { + let narrowed = value as f32; + if narrowed.is_finite() { + Ok(narrowed) + } else { + Err(EngineError::ResultTooLarge) + } +} + +fn finite_nonnegative_f32(value: f64) -> Result { + if !value.is_finite() || value < 0.0 { + return Err(EngineError::InvalidRequest); + } + finite_f32(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::{ + flow_composition::{FlowFragment, FlowLine}, + line_composition::ComposedLine, + semantic_wire::FlowConstraint, + }; + + #[test] + fn measurement_is_demand_shaped_from_retained_lines_without_glyph_arrays() { + let geometry = FlowGeometryArena { + constraints: vec![constraint(AXIS_EXACT, 20.0, AXIS_EXACT, 10.0)], + ..FlowGeometryArena::default() + }; + let flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 11, + region_id: 3, + transform_index: 1, + clip_id: 0, + fragment_start: 0, + fragment_count: 1, + align: 1, + block_start: 0.0, + baseline: 4.0, + height: 5.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 2, + text_start: 0, + text_end: 2, + advance: 7.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 20.0, + }], + }; + let mut records = vec![]; + append_measurement(&mut records, 7, 2, 2, &geometry, &flow).unwrap(); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].kind, SEMANTIC_PARAGRAPH_MEASUREMENT); + assert_eq!(records[0].id, 7); + assert_eq!(records[0].flags, 0); + assert_eq!(records[0].item_start, 1); + assert_eq!(records[0].item_count, 1); + assert_eq!(records[0].inline_start, 20.0); + assert_eq!(records[0].block_start, 10.0); + assert_eq!(records[0].inline_extent, 7.0); + assert_eq!(records[0].block_extent, 5.0); + assert_eq!(records[1].kind, SEMANTIC_LINE); + assert_eq!(records[1].parent_id, 7); + assert_eq!(records[1].block_start, 4.0); + assert_eq!(records[1].inline_extent, 7.0); + } + + #[test] + fn constrained_measurement_reports_content_overflow_independently_of_box_size() { + let geometry = FlowGeometryArena { + constraints: vec![constraint(AXIS_AT_MOST, 6.0, AXIS_AT_MOST, 4.0)], + ..FlowGeometryArena::default() + }; + let flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 11, + region_id: 3, + transform_index: 1, + clip_id: 0, + fragment_start: 0, + fragment_count: 1, + align: 1, + block_start: 0.0, + baseline: 4.0, + height: 5.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 1, + text_start: 0, + text_end: 1, + advance: 7.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 6.0, + }], + }; + let mut records = vec![]; + append_measurement(&mut records, 7, 2, 2, &geometry, &flow).unwrap(); + + assert_eq!(records[0].flags, MEASUREMENT_FLAG_OVERFLOWED); + assert_eq!(records[0].inline_start, 6.0); + assert_eq!(records[0].block_start, 4.0); + assert_eq!(records[0].inline_extent, 7.0); + assert_eq!(records[0].block_extent, 5.0); + } + + fn constraint(width_mode: u8, width: f32, height_mode: u8, height: f32) -> FlowConstraint { + FlowConstraint { + paragraph_id: 7, + flow_thread_id: 11, + width, + height, + viewport_block_start: 0.0, + viewport_block_end: height, + resume_block_offset: 0.0, + max_lines: 8, + region_start: 0, + resume_cluster: 0, + region_count: 1, + resume_region: 0, + width_mode, + height_mode, + wrap: 2, + align: 1, + overflow: 1, + block_align: 1, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index cc902cf7..eca8014f 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -19,6 +19,7 @@ mod identity_index; #[cfg(feature = "kernel-lab")] #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] pub(crate) mod kernel_lab; +pub(crate) mod layout_query; #[cfg_attr(not(test), allow(dead_code))] mod line_composition; #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] @@ -35,6 +36,7 @@ mod positioning; pub mod render_plan; pub mod render_plan_compiler; pub(crate) mod render_plan_wire; +pub(crate) mod semantic_view; mod semantic_wire; mod shaping_state; #[cfg_attr(not(test), allow(dead_code))] diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index 30a247b7..dba8b3fc 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -1035,7 +1035,11 @@ impl OrderedPlanCompiler { material_id: if split_material { first.material_id } else { 0 }, clip_id: first.clip_id, depth_key: first.depth_key, - transform_id: if split_transform { first.transform_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, primitive_start: u32::try_from(primitive_start) .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, primitive_count: 1, @@ -1482,7 +1486,13 @@ mod tests { .collect::>(), expected ); - assert_eq!(plan.primitives.iter().map(|item| item.record_count).sum::(), 2); + assert_eq!( + plan.primitives + .iter() + .map(|item| item.record_count) + .sum::(), + 2 + ); } } diff --git a/packages/text/rust/shaper/src/engine/render_plan.rs b/packages/text/rust/shaper/src/engine/render_plan.rs index 4f258d0d..12a52c3e 100644 --- a/packages/text/rust/shaper/src/engine/render_plan.rs +++ b/packages/text/rust/shaper/src/engine/render_plan.rs @@ -3,14 +3,6 @@ //! These types describe rendering intent and revision-relative resource changes. They deliberately //! contain no backend command, JavaScript callback, GPU object, or host pointer. -pub const SEMANTIC_LINE: u16 = 1; -pub const SEMANTIC_FRAGMENT: u16 = 2; -pub const SEMANTIC_RUN: u16 = 3; -pub const SEMANTIC_CLUSTER: u16 = 4; -pub const SEMANTIC_CARET: u16 = 5; -pub const SEMANTIC_SELECTION: u16 = 6; -pub const SEMANTIC_INSERTED_GLYPH: u16 = 7; - pub const RESOURCE_ACTION_CREATE: u16 = 1; pub const RESOURCE_ACTION_UPDATE: u16 = 2; pub const RESOURCE_ACTION_RETAIN: u16 = 3; @@ -37,23 +29,6 @@ pub const RETIRE_BUFFER: u16 = 2; pub const RETIRE_SLOT_RANGE: u16 = 3; pub const RETIRE_OUTPUT_BYTES: u16 = 4; -#[repr(C)] -#[derive(Clone, Copy, Debug, Default, PartialEq)] -pub struct SemanticRecord { - pub id: u32, - pub kind: u16, - pub flags: u16, - pub parent_id: u32, - pub text_start: u32, - pub text_end: u32, - pub item_start: u32, - pub item_count: u32, - pub inline_start: f32, - pub block_start: f32, - pub inline_extent: f32, - pub block_extent: f32, -} - #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct ResourceRecord { @@ -183,7 +158,6 @@ pub struct RenderPlanView<'a> { pub policy_handle: u32, pub capability_set: u32, pub policy_fingerprint: u64, - pub semantics: &'a [SemanticRecord], pub resources: &'a [ResourceRecord], pub buffers: &'a [BufferRecord], pub patches: &'a [PatchRecord], @@ -194,7 +168,6 @@ pub struct RenderPlanView<'a> { pub payload: &'a [u8], } -const _: () = assert!(core::mem::size_of::() == 44); const _: () = assert!(core::mem::size_of::() == 40); const _: () = assert!(core::mem::size_of::() == 36); const _: () = assert!(core::mem::size_of::() == 36); diff --git a/packages/text/rust/shaper/src/engine/render_plan_wire.rs b/packages/text/rust/shaper/src/engine/render_plan_wire.rs index 9d160815..ddf6f097 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_wire.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_wire.rs @@ -9,8 +9,12 @@ use crate::{ PRIMITIVE_GLYPH, PRIMITIVE_INLINE_OBJECT, PRIMITIVE_POLICY, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_BUFFER, RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, - RetirementRecord, SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, - SEMANTIC_INSERTED_GLYPH, SEMANTIC_LINE, SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, + RetirementRecord, + }, + engine::semantic_view::{ + SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_INSERTED_GLYPH, + SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, SEMANTIC_SELECTION, + SemanticRecord, }, }; @@ -26,7 +30,7 @@ pub(crate) struct TableSpan { pub(crate) struct EncodedPlanLayout { pub byte_length: u32, pub payload_offset: u32, - pub semantics: TableSpan, + pub semantic_views: TableSpan, pub resources: TableSpan, pub buffers: TableSpan, pub patches: TableSpan, @@ -36,11 +40,20 @@ pub(crate) struct EncodedPlanLayout { pub diagnostics: TableSpan, } +#[cfg(test)] pub(crate) fn encode_plan( plan: RenderPlanView<'_>, output: &mut [u8], ) -> Result { - let layout = plan_layout(plan)?; + encode_publication(plan, &[], output) +} + +pub(crate) fn encode_publication( + plan: RenderPlanView<'_>, + semantic_views: &[SemanticRecord], + output: &mut [u8], +) -> Result { + let layout = publication_layout(plan, semantic_views)?; let byte_length = usize::try_from(layout.byte_length).map_err(|_| STATUS_RESULT_TOO_LARGE)?; let bytes = output .get_mut(..byte_length) @@ -52,9 +65,9 @@ pub(crate) fn encode_plan( } write_records( bytes, - layout.semantics, + layout.semantic_views, SEMANTIC_RECORD_SIZE, - plan.semantics, + semantic_views, write_semantic, ); write_records( @@ -103,8 +116,16 @@ pub(crate) fn encode_plan( Ok(layout) } +#[cfg(test)] pub(crate) fn plan_layout(plan: RenderPlanView<'_>) -> Result { - validate_plan(plan)?; + publication_layout(plan, &[]) +} + +pub(crate) fn publication_layout( + plan: RenderPlanView<'_>, + semantic_views: &[SemanticRecord], +) -> Result { + validate_plan(plan, semantic_views)?; let mut cursor = ENGINE_RESULT_HEADER_SIZE; let payload_offset = if plan.payload.is_empty() { 0 @@ -114,9 +135,9 @@ pub(crate) fn plan_layout(plan: RenderPlanView<'_>) -> Result) -> Result) -> Result) -> Result<(), u32> { +fn validate_plan(plan: RenderPlanView<'_>, semantic_views: &[SemanticRecord]) -> Result<(), u32> { if plan.policy_handle == 0 { return Err(STATUS_INVALID_REQUEST); } - for record in plan.semantics { + for record in semantic_views { if record.id == 0 || !matches!( record.kind, @@ -191,6 +212,7 @@ fn validate_plan(plan: RenderPlanView<'_>) -> Result<(), u32> { | SEMANTIC_CARET | SEMANTIC_SELECTION | SEMANTIC_INSERTED_GLYPH + | SEMANTIC_PARAGRAPH_MEASUREMENT ) || record.text_start > record.text_end || !finite4( @@ -647,7 +669,6 @@ mod tests { policy_handle: 15, capability_set: 16, policy_fingerprint: 17, - semantics: &semantic, resources: &resource, buffers: &buffer, patches: &patch, @@ -657,9 +678,9 @@ mod tests { diagnostics: &diagnostic, payload: &[0xaa, 0xbb, 0xcc, 0xdd, 0xee], }; - let expected = plan_layout(plan).unwrap(); + let expected = publication_layout(plan, &semantic).unwrap(); let mut bytes = vec![0x7f; expected.byte_length as usize + 16]; - let layout = encode_plan(plan, &mut bytes).unwrap(); + let layout = encode_publication(plan, &semantic, &mut bytes).unwrap(); assert_eq!(layout, expected); assert_eq!(layout.payload_offset % PAYLOAD_ALIGNMENT, 0); assert_eq!( diff --git a/packages/text/rust/shaper/src/engine/semantic_view.rs b/packages/text/rust/shaper/src/engine/semantic_view.rs new file mode 100644 index 00000000..f2eadc99 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/semantic_view.rs @@ -0,0 +1,32 @@ +//! Demand-shaped semantic records returned only when explicitly requested. +//! +//! Semantic views can share a publication with a render plan, but they are not commands and are +//! never part of `RenderPlanView`. + +pub const SEMANTIC_LINE: u16 = 1; +pub const SEMANTIC_FRAGMENT: u16 = 2; +pub const SEMANTIC_RUN: u16 = 3; +pub const SEMANTIC_CLUSTER: u16 = 4; +pub const SEMANTIC_CARET: u16 = 5; +pub const SEMANTIC_SELECTION: u16 = 6; +pub const SEMANTIC_INSERTED_GLYPH: u16 = 7; +pub const SEMANTIC_PARAGRAPH_MEASUREMENT: u16 = 8; + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SemanticRecord { + pub id: u32, + pub kind: u16, + pub flags: u16, + pub parent_id: u32, + pub text_start: u32, + pub text_end: u32, + pub item_start: u32, + pub item_count: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, +} + +const _: () = assert!(core::mem::size_of::() == 44); diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 1acdb9ba..ea4c0433 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -16,9 +16,9 @@ use super::{ record_alignment, take_allocation, }, policy::{ - ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BUFFER_USAGE_COPY_DST, BUFFER_USAGE_STORAGE, - BufferId, BufferSchema, CapabilitySetId, PolicyExecutionError, ScalarType, TechniqueId, - ValidatedPolicy, + ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BUFFER_USAGE_COPY_DST, + BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CapabilitySetId, PolicyExecutionError, + ScalarType, TechniqueId, ValidatedPolicy, }, render_plan::{ BUFFER_STABLE_INDIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, @@ -1362,7 +1362,11 @@ impl StablePlanCompiler { material_id: if split_material { first.material_id } else { 0 }, clip_id: first.clip_id, depth_key: first.depth_key, - transform_id: if split_transform { first.transform_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, primitive_start: u32::try_from(primitive_start) .map_err(|_| StablePlanError::ArithmeticOverflow)?, primitive_count: 1, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index df64edbb..20b12297 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -84,6 +84,7 @@ struct EngineSession { acknowledged_publication_generation: u32, policy_binding: Option, plan: RenderPlanCompiler, + semantic_records: Vec, next_glyph_id: u32, pending_next_glyph_id: u32, next_content_revision: u32, @@ -534,6 +535,7 @@ impl TextEngine { None }; let preparation = (|| { + session.semantic_records.clear(); session.prepare_lifecycle( request.paragraph_mutations, implicit_paragraph, @@ -659,6 +661,49 @@ impl TextEngine { ) .map_err(plan_error)?; } + if request.semantic_view_mask & super::frame::SEMANTIC_VIEW_MEASUREMENT != 0 { + let mut records = core::mem::take(&mut session.semantic_records); + let query = (|| { + for order_index in 0..session.active_order().len() { + let paragraph_id = session.active_order()[order_index].id; + let paragraph = session + .paragraph(paragraph_id) + .ok_or(EngineError::InvalidRequest)?; + let state = ¶graph.state; + let text = if state.text_prepared { + &state.pending_text + } else { + &state.text + }; + let clusters = if state.clusters_prepared { + &state.pending_clusters + } else { + &state.clusters + }; + let geometry = if state.geometry_prepared { + &state.pending_geometry + } else { + &state.geometry + }; + let flow = if state.flow_layout_prepared { + &state.pending_flow_layout + } else { + &state.flow_layout + }; + super::layout_query::append_measurement( + &mut records, + paragraph_id, + text.len(), + clusters.starts.len(), + geometry, + flow, + )?; + } + Ok(()) + })(); + session.semantic_records = records; + query?; + } session.pending_next_glyph_id = next_glyph_id; session.pending_next_content_revision = next_content_revision; Ok(()) @@ -700,6 +745,20 @@ impl TextEngine { .map_err(plan_error) } + pub(crate) fn prepared_semantic_views( + &self, + prepared: PreparedUpdate, + ) -> Result<&[super::semantic_view::SemanticRecord], EngineError> { + let session = self + .sessions + .get(&prepared.session_id) + .ok_or(EngineError::SessionMissing)?; + if session.revision != prepared.previous { + return Err(EngineError::RevisionConflict); + } + Ok(&session.semantic_records) + } + pub(crate) fn abort_update(&mut self, prepared: PreparedUpdate) -> Result<(), EngineError> { let session = self .sessions @@ -913,6 +972,7 @@ impl EngineSession { fn abort_pending(&mut self) { self.plan.abort(); + self.semantic_records.clear(); for paragraph in &mut self.paragraphs { paragraph.state.abort_all(); paragraph.positioned_changed = false; @@ -2954,6 +3014,7 @@ mod tests { acknowledged_publication_generation, policy_handle: 9, capability_set: 1, + semantic_view_mask: 0, limits: super::super::frame::UpdateLimits { max_paragraphs: 1, max_clusters: 1, diff --git a/packages/text/rust/shaper/src/engine/transport.rs b/packages/text/rust/shaper/src/engine/transport.rs index 61116a1f..429c2c2d 100644 --- a/packages/text/rust/shaper/src/engine/transport.rs +++ b/packages/text/rust/shaper/src/engine/transport.rs @@ -24,7 +24,8 @@ use crate::{ engine::{ frame::{CommittedUpdate, RESULT_FLAG_CHECKPOINT, SessionRevision}, render_plan::RenderPlanView, - render_plan_wire::{EncodedPlanLayout, encode_plan}, + render_plan_wire::{EncodedPlanLayout, encode_publication}, + semantic_view::SemanticRecord, }, wire::write_u32, }; @@ -108,9 +109,18 @@ impl FrameTransport { .ok_or(STATUS_RESULT_TOO_LARGE) } + #[cfg(test)] pub fn stage_plan(&mut self, plan: RenderPlanView<'_>) -> Result { + self.stage_publication(plan, &[]) + } + + pub fn stage_publication( + &mut self, + plan: RenderPlanView<'_>, + semantic_views: &[SemanticRecord], + ) -> Result { let slot = self.inactive_slot(); - let layout = encode_plan(plan, self.outputs[slot].bytes_mut())?; + let layout = encode_publication(plan, semantic_views, self.outputs[slot].bytes_mut())?; Ok(StagedPlan { slot, policy_handle: plan.policy_handle, @@ -236,7 +246,7 @@ impl FrameTransport { bytes, ENGINE_RESULT_SEMANTICS_OFFSET, ENGINE_RESULT_SEMANTICS_COUNT, - values.layout.semantics, + values.layout.semantic_views, ); write_span( bytes, diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 258f4818..696ed914 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -8,8 +8,8 @@ use crate::{ bidi, engine::{ EngineError, TextEngine, font_binding_wire::parse_font_binding, frame::SessionRevision, - frame_wire::parse_update_request, render_plan_wire::plan_layout, transport::FrameTransport, - wire::parse_policy, + frame_wire::parse_update_request, render_plan_wire::publication_layout, + transport::FrameTransport, wire::parse_policy, }, wire::{ pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, @@ -546,7 +546,20 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( ); } }; - let required_output = match plan_layout(plan) { + let semantic_views = match state.engine.prepared_semantic_views(prepared) { + Ok(views) => views, + Err(error) => { + return publish_prepared_failure( + state, + prepared, + revision, + engine_status(error), + 0, + 0, + ); + } + }; + let required_output = match publication_layout(plan, semantic_views) { Ok(layout) => layout.byte_length, Err(status) => { return publish_prepared_failure(state, prepared, revision, status, 0, 0); @@ -572,7 +585,7 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( let staged = match state .frames .get_mut(&session_id) - .and_then(|transport| transport.stage_plan(plan).ok()) + .and_then(|transport| transport.stage_publication(plan, semantic_views).ok()) { Some(staged) => staged, None => { diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 76d080bc..c5b50d31 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -89,6 +89,9 @@ export const textShaperAbi = { "internalBufferBindings": { "order": 65535 }, + "measurementFlags": { + "overflowed": 1 + }, "overflowModes": { "clip": 2, "ellipsis": 3, @@ -145,6 +148,7 @@ export const textShaperAbi = { "fragment": 2, "insertedGlyph": 7, "line": 1, + "paragraphMeasurement": 8, "run": 3, "selection": 6 }, @@ -155,6 +159,10 @@ export const textShaperAbi = { "regionId": 2, "transformIndex": 4 }, + "semanticViewMasks": { + "all": 1, + "measurement": 1 + }, "styleFields": { "all": 8191, "baselineShift": 256, @@ -485,8 +493,8 @@ export const textShaperAbi = { "resultCapacity": 48, "retirementCount": 124, "retirementsOffset": 120, - "semanticsCount": 76, - "semanticsOffset": 72, + "semanticViewCount": 76, + "semanticViewsOffset": 72, "sessionId": 16, "size": 144, "status": 8 @@ -502,7 +510,7 @@ export const textShaperAbi = { "kind": 0, "size": 24 }, - "engineSemantic": { + "engineSemanticView": { "alignment": 4, "blockExtent": 40, "blockStart": 32, diff --git a/packages/text/src/internal/layout-query-view.ts b/packages/text/src/internal/layout-query-view.ts new file mode 100644 index 00000000..459a8f1b --- /dev/null +++ b/packages/text/src/internal/layout-query-view.ts @@ -0,0 +1,121 @@ +import { textShaperAbi } from '../generated/text-shaper-abi.js'; +import type { ParagraphMeasurement } from '../layout.js'; +import type { TextEnginePublication } from './text-engine-host.js'; + +/** Reads an explicitly requested semantic sidecar. Rendering never calls this reader. */ +export function readTextEngineMeasurements( + publication: TextEnginePublication, +): ReadonlyMap { + const view = new SemanticViewReader(publication); + const table = view.table(); + const recordLayout = textShaperAbi.layouts.engineSemanticView; + const kinds = textShaperAbi.engine.semanticKinds; + const measurements = new Map(); + for (let index = 0; index < table.count; index += 1) { + const record = view.record(table, index); + if (view.u16(record + recordLayout.kind) !== kinds.paragraphMeasurement) continue; + const paragraphId = view.u32(record + recordLayout.id); + const lineStart = view.u32(record + recordLayout.itemStart); + const lineCount = view.u32(record + recordLayout.itemCount); + if (lineStart + lineCount > table.count) + throw new RangeError('paragraph measurement line span is outside the query'); + let firstBaseline = 0; + let lastBaseline = 0; + for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) { + const line = view.record(table, lineStart + lineIndex); + if (view.u16(line + recordLayout.kind) !== kinds.line || view.u32(line + recordLayout.parentId) !== paragraphId) { + throw new TypeError('paragraph measurement references a foreign semantic line'); + } + const baseline = view.f32(line + recordLayout.blockStart); + if (lineIndex === 0) firstBaseline = baseline; + lastBaseline = baseline; + } + if (measurements.has(paragraphId)) throw new TypeError('text engine returned duplicate paragraph measurements'); + measurements.set( + paragraphId, + Object.freeze({ + width: view.f32(record + recordLayout.inlineStart), + height: view.f32(record + recordLayout.blockStart), + contentWidth: view.f32(record + recordLayout.inlineExtent), + contentHeight: view.f32(record + recordLayout.blockExtent), + firstBaseline, + lastBaseline, + overflowed: (view.u16(record + recordLayout.flags) & textShaperAbi.engine.measurementFlags.overflowed) !== 0, + }), + ); + } + return measurements; +} + +interface SemanticViewTable { + readonly offset: number; + readonly count: number; + readonly stride: number; +} + +class SemanticViewReader { + readonly #publication: TextEnginePublication; + readonly #view: DataView; + + constructor(publication: TextEnginePublication) { + if (publication.bytes.buffer !== publication.memoryBuffer) { + throw new TypeError('text-engine query bytes do not belong to the reported Wasm memory'); + } + this.#publication = publication; + this.#view = new DataView(publication.memoryBuffer); + } + + table(): SemanticViewTable { + const result = textShaperAbi.layouts.engineResult; + const record = textShaperAbi.layouts.engineSemanticView; + const offset = this.u32(result.semanticViewsOffset); + const count = this.u32(result.semanticViewCount); + if (count !== this.#publication.semanticViewCount) { + throw new TypeError('text-engine query metadata disagrees with its publication'); + } + if (count === 0) { + if (offset !== 0) throw new RangeError('empty text-engine semantic view has a nonzero offset'); + return { offset: 0, count: 0, stride: record.size }; + } + const byteLength = count * record.size; + if (!Number.isSafeInteger(byteLength) || offset % record.alignment !== 0 || offset < result.size) { + throw new RangeError('text-engine semantic view has an invalid span'); + } + this.#assertRange(offset, byteLength); + return { offset, count, stride: record.size }; + } + + record(table: SemanticViewTable, index: number): number { + if (!Number.isSafeInteger(index) || index < 0 || index >= table.count) { + throw new RangeError('text-engine semantic-view record index is outside its table'); + } + return table.offset + index * table.stride; + } + + u16(offset: number): number { + this.#assertRange(offset, 2); + return this.#view.getUint16(this.#publication.bytes.byteOffset + offset, true); + } + + u32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view.getUint32(this.#publication.bytes.byteOffset + offset, true); + } + + f32(offset: number): number { + this.#assertRange(offset, 4); + return this.#view.getFloat32(this.#publication.bytes.byteOffset + offset, true); + } + + #assertRange(offset: number, byteLength: number): void { + if ( + !Number.isSafeInteger(offset) || + !Number.isSafeInteger(byteLength) || + offset < 0 || + byteLength < 0 || + offset + byteLength > this.#publication.bytes.byteLength + ) { + throw new RangeError('text-engine semantic-view read is outside the publication'); + } + } +} diff --git a/packages/text/src/internal/render-plan-view.ts b/packages/text/src/internal/render-plan-view.ts index dc972756..9347cb4b 100644 --- a/packages/text/src/internal/render-plan-view.ts +++ b/packages/text/src/internal/render-plan-view.ts @@ -7,23 +7,10 @@ export interface RenderPlanTable { readonly stride: number; } -type TableName = - | 'semantics' - | 'resources' - | 'buffers' - | 'patches' - | 'primitives' - | 'draws' - | 'retirements' - | 'diagnostics'; +type TableName = 'resources' | 'buffers' | 'patches' | 'primitives' | 'draws' | 'retirements' | 'diagnostics'; const resultLayout = textShaperAbi.layouts.engineResult; const tableLayouts = { - semantics: { - offset: resultLayout.semanticsOffset, - count: resultLayout.semanticsCount, - record: textShaperAbi.layouts.engineSemantic, - }, resources: { offset: resultLayout.resourcesOffset, count: resultLayout.resourceCount, diff --git a/packages/text/src/internal/text-engine-host.ts b/packages/text/src/internal/text-engine-host.ts index 3424e6e1..5755ae4b 100644 --- a/packages/text/src/internal/text-engine-host.ts +++ b/packages/text/src/internal/text-engine-host.ts @@ -27,6 +27,7 @@ export interface TextEnginePublication { readonly flags: number; readonly policyHandle: number; readonly capabilitySet: number; + readonly semanticViewCount: number; readonly primitiveCount: number; readonly patchCount: number; readonly drawCount: number; @@ -246,6 +247,7 @@ export class TextEngineSession { flags: header.getUint32(layout.flags, true), policyHandle: header.getUint32(layout.policyHandle, true), capabilitySet: header.getUint32(layout.capabilitySet, true), + semanticViewCount: header.getUint32(layout.semanticViewCount, true), primitiveCount: header.getUint32(layout.primitiveCount, true), patchCount: header.getUint32(layout.patchCount, true), drawCount: header.getUint32(layout.drawCount, true), diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 2d91a3a3..ba0163d7 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -28,6 +28,9 @@ import { type TextEngineTextMutation, } from '../internal/engine-frame-wire.js'; import type { TextEnginePublication, TextEngineSession } from '../internal/text-engine-host.js'; +import { readTextEngineMeasurements } from '../internal/layout-query-view.js'; +import type { ParagraphMeasurement } from '../layout.js'; +import { textShaperAbi } from '../generated/text-shaper-abi.js'; import { ThreeTextRenderPlanExecutor } from './engine-plan-target.js'; import { threeTextEngineCoordinator, @@ -203,6 +206,11 @@ export class Text extends THREE.Object3D { this.#assertActive(); this.#binding?.retry(); } + /** Performs one explicit Rust query when this committed layout has not already been measured. */ + measureLayout(): ParagraphMeasurement | undefined { + this.#assertActive(); + return this.#binding?.measurement(eraseTextTechnique(this)); + } override updateMatrixWorld(force?: boolean): void { if (this.#disposed) { @@ -406,6 +414,7 @@ class ThreeTextBatchBinding { readonly #paragraphs = new Map, RetainedEngineParagraph>(); readonly #textsByParagraph = new Map>(); readonly #removed: RetainedEngineParagraph[] = []; + readonly #measurements = new Map, ParagraphMeasurement>(); #nextParagraphId = 1; #engineRevision = 0; #planRevision = 0; @@ -456,6 +465,35 @@ class ThreeTextBatchBinding { get renderOrderBase(): number { return this.#group?.renderOrder ?? 0; } + measurement(text: Text): ParagraphMeasurement | undefined { + if (!this.#paragraphs.has(text)) return undefined; + this.synchronize(); + const cached = this.#measurements.get(text); + if (cached !== undefined) return cached; + const totalTextLength = [...this.#paragraphs.keys()].reduce((total, entry) => total + entry.text.length, 0); + const publication = this.#session.update( + compileTextEngineFrameUpdate({ + sessionId: this.#session.handle, + policyHandle: this.#coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: this.#engineRevision, + consumedPlanRevision: this.#planRevision, + acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + semanticViewMask: textShaperAbi.engine.semanticViewMasks.measurement, + limits: engineLimits(this.#paragraphs.size, totalTextLength, this.#paragraphs.size, this.#resultCapacity), + }), + ); + this.#engineRevision = publication.engineRevision; + this.#planRevision = publication.planRevision; + const measurements = readTextEngineMeasurements(publication); + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + for (const [paragraphId, measurement] of measurements) { + const measuredText = this.#textsByParagraph.get(paragraphId); + if (measuredText === undefined) throw new Error(`text engine measured unknown paragraph ${paragraphId}`); + this.#measurements.set(measuredText, measurement); + } + return this.#measurements.get(text); + } reconcile(texts: readonly Text[]): void { const desired = new Set(texts); for (const text of [...this.#paragraphs.keys()]) if (!desired.has(text)) this.removeText(text); @@ -568,6 +606,7 @@ class ThreeTextBatchBinding { paragraph.order = order; } this.#materialInvalidated = false; + this.#measurements.clear(); committed = true; try { this.#target.apply(publication); @@ -621,6 +660,7 @@ class ThreeTextBatchBinding { for (const paragraph of this.#removed) releaseMaterialLeases(paragraph.materialLeases); this.#paragraphs.clear(); this.#textsByParagraph.clear(); + this.#measurements.clear(); this.#removed.length = 0; } #ensureText(text: Text, group: TextGroup | undefined): void { diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 95cf318d..caf2f6a1 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -40,6 +40,16 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.ok(firstDraws.length > 0); assert.equal(firstDraws[0].geometry.instanceCount, 10, 'the GPU plan omits the non-rendering space glyph'); assert.equal(firstDraws[0].renderOrder, 12); + const measurement = label.measureLayout(); + assert.ok(measurement, 'layout measurement must be available through an explicit Rust query'); + assert.equal(measurement.width, measurement.contentWidth); + assert.equal(measurement.height, measurement.contentHeight); + assert.ok(measurement.firstBaseline > 0); + assert.equal(measurement.firstBaseline, measurement.lastBaseline); + assert.equal(measurement.overflowed, false); + assert.equal(label.measureLayout(), measurement, 'an unchanged committed layout must reuse its queried measurement'); + assert.equal(label.layout, undefined, 'query data must not restore layout arrays to rendering'); + assert.equal(group.children.filter((child) => child.isMesh)[0], firstDraws[0]); group.renderOrder = 20; scene.updateMatrixWorld(); @@ -61,6 +71,7 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr 7, 'compatible revisions must retain draws and resize live counts', ); + assert.notEqual(label.measureLayout(), measurement, 'a semantic update must invalidate the measurement cache'); scene.add(label); scene.updateMatrixWorld(); diff --git a/packages/text/tests/types/three-v1-api.test.ts b/packages/text/tests/types/three-v1-api.test.ts index 692bcc66..7c3bb069 100644 --- a/packages/text/tests/types/three-v1-api.test.ts +++ b/packages/text/tests/types/three-v1-api.test.ts @@ -12,6 +12,8 @@ const labels = new TextGroup(); labels.add(label); label.text = 'Updated'; label.setCapacity({ size: 64, policy: 'grow' }); +const measurement = label.measureLayout(); +void measurement?.contentWidth; labels.setCapacity({ size: 4_096, policy: 'chunk' }); labels.add(new Text({ font: mtsdfFont, text: 'Mixed technique' })); From 80ee3bc8698206fe8cfe3da9412fc3841b5a79e8 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 04:20:22 -0400 Subject: [PATCH 079/128] feat(text): direct layout inspection through Rust plans --- .../targets/conformance/advanced-shaping.ts | 2 +- .../conformance/raster/mtsdf-capture.ts | 2 +- .../conformance/raster/slug-capture.ts | 2 +- .../targets/conformance/rich-text-spans.ts | 4 +- .../benchmark/targets/product/react-text.ts | 7 +- .../benchmark/targets/product/slug-text.ts | 2 +- .../src/generated/package-sizes.json | 78 +++++----- .../benchmark/scenes/comparison-workload.ts | 18 ++- .../src/techniques/bitmap/conformance-line.ts | 2 +- .../src/techniques/bitmap/persistent-scene.ts | 14 +- .../src/techniques/mtsdf/persistent-scene.ts | 12 +- .../shared/glyph-origin-transition.ts | 57 +++---- .../src/techniques/slug/persistent-scene.ts | 12 +- apps/benchmarks/src/v1-bitmap-proof.ts | 2 +- apps/benchmarks/src/v1-compose-proof.ts | 2 +- apps/benchmarks/src/v1-mtsdf-proof.ts | 2 +- apps/benchmarks/src/v1-slug-proof.ts | 2 +- .../src/workloads/dynamic-layout/scene.ts | 4 +- .../src/workloads/icon-grid/scene.ts | 4 +- .../src/workloads/off-axis-3d/scene.ts | 4 +- .../src/workloads/paint-effects/scene.ts | 4 +- .../src/workloads/paragraph-stress/scene.ts | 6 +- .../src/workloads/rich-text/scene.ts | 4 +- .../src/workloads/shared/scene-entry.ts | 16 +- .../src/workloads/text-ladder/scene.ts | 6 +- .../src/workloads/zoom-text/scene.ts | 4 +- docs/log.md | 9 ++ docs/packages/benchmarks.md | 14 +- docs/packages/text.md | 25 ++- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 14 +- packages/text/rust/shaper/src/abi_contract.rs | 27 ++-- packages/text/rust/shaper/src/engine/frame.rs | 5 +- .../rust/shaper/src/engine/layout_query.rs | 145 +++++++++++++++++- .../rust/shaper/src/engine/positioning.rs | 97 ++++++++++-- .../shaper/src/engine/render_plan_wire.rs | 10 +- .../rust/shaper/src/engine/semantic_view.rs | 1 + packages/text/rust/shaper/src/engine/state.rs | 19 ++- .../text/src/generated/text-shaper-abi.ts | 5 +- packages/text/src/index.ts | 8 +- .../text/src/internal/layout-query-view.ts | 127 ++++++++++++++- .../text/src/internal/render-policy-wire.ts | 33 +++- packages/text/src/layout.ts | 17 ++ packages/text/src/three.ts | 10 +- packages/text/src/three/engine-plan-target.ts | 138 ++++++++++++++++- packages/text/src/three/text.ts | 115 +++++++++++--- .../render-plan-frame-abi.test.mjs | 1 + .../text/tests/integration/three-v1.test.mjs | 48 ++++++ 48 files changed, 925 insertions(+), 216 deletions(-) diff --git a/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts b/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts index 75ff8241..63f6e444 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/advanced-shaping.ts @@ -138,7 +138,7 @@ export function createAdvancedShapingConformanceTarget(): BenchmarkTarget { cause: text.error, }); } - const layout = text.layout; + const layout = text.inspectLayout(); if (layout === undefined) throw new Error(`${definition.id}:${frame.tick} has no layout`); const rendered = renderedGlyphs(text); const draws = bitmapDraws(text); diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts index 85a2a2b8..6d6b8de0 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/mtsdf-capture.ts @@ -302,7 +302,7 @@ async function disposeFlatMtsdfConformanceResources(resources: FlatMtsdfConforma } function committedLayout(line: Text): ParagraphLayout { - const layout = line.layout; + const layout = line.inspectLayout(); if (layout === undefined) throw new Error('MTSDF conformance Text lost its committed layout'); return layout; } diff --git a/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts b/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts index c7b3518e..2bab1819 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/raster/slug-capture.ts @@ -913,7 +913,7 @@ function pixelHasInk(bytes: Uint8Array, pixelIndex: number): boolean { function committedLayout(line: Text): ParagraphLayout { const error = line.error; if (error !== undefined) throw error; - const layout = line.layout; + const layout = line.inspectLayout(); if (layout === undefined) throw new Error('Slug conformance Text lost its committed layout'); return layout; } diff --git a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts index 090c3720..5819cfb5 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts @@ -1,4 +1,4 @@ -import type { AnyRasterTechnique, LoadedFont, LoadedFontRequest, ParagraphLayout } from '@pmndrs/text'; +import type { LoadedFont, LoadedFontRequest, ParagraphLayout } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; import { FontLoader, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; @@ -296,7 +296,7 @@ function measureCase( if (failure !== undefined) { throw new Error(`${caseId} failed to publish: ${String(failure)}`, { cause: failure }); } - const layout = text.layout; + const layout = text.inspectLayout(); if (layout === undefined) throw new Error(`${caseId} has no layout`); return readEvidence(text, layout); } finally { diff --git a/apps/benchmarks/src/benchmark/targets/product/react-text.ts b/apps/benchmarks/src/benchmark/targets/product/react-text.ts index f1458c67..c69746ec 100644 --- a/apps/benchmarks/src/benchmark/targets/product/react-text.ts +++ b/apps/benchmarks/src/benchmark/targets/product/react-text.ts @@ -291,9 +291,10 @@ function requiredCoreText(reference: React.RefObject): return reference.current; } -function requiredLayout(core: BitmapTextObject): NonNullable { - if (core.layout === undefined) throw new Error('React Text layout is unavailable'); - return core.layout; +function requiredLayout(core: BitmapTextObject): ParagraphLayout { + const layout = core.inspectLayout(); + if (layout === undefined) throw new Error('React Text layout inspection is unavailable'); + return layout; } function countDraws(object: BitmapTextObject): number { diff --git a/apps/benchmarks/src/benchmark/targets/product/slug-text.ts b/apps/benchmarks/src/benchmark/targets/product/slug-text.ts index 4e4e8cb8..c3edc0c4 100644 --- a/apps/benchmarks/src/benchmark/targets/product/slug-text.ts +++ b/apps/benchmarks/src/benchmark/targets/product/slug-text.ts @@ -175,7 +175,7 @@ async function createResources(backend: RendererBackend, dpr: number): Promise): void { const error = line.error; if (error !== undefined) throw error; - if (line.layout === undefined) throw new Error('Slug product Text did not commit a layout'); + if (line.measureLayout() === undefined) throw new Error('Slug product Text did not commit layout metrics'); } async function renderSlugText(resources: SlugProductTargetResources): Promise { diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index b00452be..198538e4 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,11 +10,11 @@ "label": "Browser core", "status": "measured", "format": "javascript", - "sha256": "8af458da131f8017cae1e9e5e8984051ab4fc57ed2a043f8cdc74e0216a0dbc5", - "rawBytes": 384896, - "minifiedBytes": 282563, - "gzipBytes": 81948, - "brotliBytes": 63199 + "sha256": "d2b56a78ed8eae0c69cd536dcbdcfaae5d90702b526ef1864dd585be265c5378", + "rawBytes": 400792, + "minifiedBytes": 296674, + "gzipBytes": 85908, + "brotliBytes": 66586 }, { "id": "font-validator-js", @@ -44,7 +44,7 @@ "status": "measured", "format": "javascript", "sha256": "31dc8dd13f3eefcbb497ac666911a8a402de2a32dde89544b91b288b0037110a", - "rawBytes": 12822, + "rawBytes": 12908, "minifiedBytes": 8936, "gzipBytes": 3003, "brotliBytes": 2671 @@ -54,66 +54,66 @@ "label": "Text shaper JS", "status": "measured", "format": "javascript", - "sha256": "1160e9b39b6ef5bcea15d475f57823fec80ae5b60fc103f58482555262f64b2a", - "rawBytes": 54460, - "minifiedBytes": 38027, - "gzipBytes": 10262, - "brotliBytes": 9134 + "sha256": "a50116f8dbb58e21a1bb408c653e5e6cfb183a0b2ab74110e09dc6eccc84722f", + "rawBytes": 70570, + "minifiedBytes": 52350, + "gzipBytes": 14242, + "brotliBytes": 12531 }, { "id": "text-shaper-wasm", "label": "Text shaper Wasm", "status": "measured", "format": "wasm", - "sha256": "e41e32f585ad99f91c82bff8bf9b01011fd01dc07d16d19e97eeec77a8d50c86", - "rawBytes": 680312, - "minifiedBytes": 680312, - "gzipBytes": 253568, - "brotliBytes": 199365 + "sha256": "f1cebddb33064296f7b3fc2e457869b44e5c11512bc40f9ff1578a3003ace433", + "rawBytes": 1089889, + "minifiedBytes": 1089889, + "gzipBytes": 414204, + "brotliBytes": 325805 }, { "id": "bitmap-runtime-js", "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "9af4ebed9b0712bedbc887529ea2753a49d2f52214742f376ba2184bbec94675", - "rawBytes": 89604, - "minifiedBytes": 60671, - "gzipBytes": 16081, - "brotliBytes": 14229 + "sha256": "9fe7b5c0870b8ded2d7c0ecef42de8bbb5944cbda1f82429c87619bfaa751854", + "rawBytes": 322956, + "minifiedBytes": 213729, + "gzipBytes": 54264, + "brotliBytes": 45666 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "c194d5f368e7224b83a351098a7ebaa2e5daac9d6b4e853e01b490cf9b580ea2", - "rawBytes": 94082, - "minifiedBytes": 63216, - "gzipBytes": 16774, - "brotliBytes": 14807 + "sha256": "5595d030e06fbd5e082176d2a0397970aca88519165d643625078953f835b6bc", + "rawBytes": 322952, + "minifiedBytes": 213794, + "gzipBytes": 54249, + "brotliBytes": 45633 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "99e19d86de2756ae1a1ef67c03ccf065a11708c455d6be070341d4d1f0b15a53", - "rawBytes": 79169, - "minifiedBytes": 53547, - "gzipBytes": 14214, - "brotliBytes": 12612 + "sha256": "175e819b7840b23cffeb52958cefb71d73a8003aee8d31d90819e33797de75ec", + "rawBytes": 322954, + "minifiedBytes": 213721, + "gzipBytes": 54223, + "brotliBytes": 45660 }, { "id": "bitmap-baker-wasm", "label": "Bitmap fixed baker Wasm", "status": "measured", "format": "wasm", - "sha256": "144c2aa99644999531a332722d0c4eccc2d2a5cb429a3b8639659f8695cb5c7f", + "sha256": "bdcf6215905e47be09d8a1a8e5122e95c07c4e4b93509bf8f4a74d043e0438a8", "rawBytes": 626940, "minifiedBytes": 626940, "gzipBytes": 234735, - "brotliBytes": 180503 + "brotliBytes": 180620 }, { "id": "bitmap-baker-js", @@ -175,11 +175,11 @@ "label": "Slug fixed baker Wasm", "status": "measured", "format": "wasm", - "sha256": "594f1f2994780e59a37b873c403fd2f003ce9dac42fd48252f7f09f71c0cd204", - "rawBytes": 465046, - "minifiedBytes": 465046, - "gzipBytes": 186683, - "brotliBytes": 146720 + "sha256": "38d461e1ccfd9be05cccff46c6ee4993c785602ba3cafe170abf4eefdecd8f94", + "rawBytes": 465031, + "minifiedBytes": 465031, + "gzipBytes": 186665, + "brotliBytes": 146606 }, { "id": "slug-baker-js", @@ -198,7 +198,7 @@ "status": "measured", "format": "javascript", "sha256": "077bf3546c43678e8b01512302c80b511bd11bd6ee2913ff9fdfc3d4a38a0055", - "rawBytes": 8926, + "rawBytes": 9012, "minifiedBytes": 6077, "gzipBytes": 2175, "brotliBytes": 1937 diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index c7d0cfb5..f5221f68 100644 --- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -1,4 +1,4 @@ -import { FontRegistry, type AnyRasterTechnique, type ParagraphLayout, type RegisteredFont } from '@pmndrs/text'; +import { FontRegistry, type ParagraphLayoutSummary, type RegisteredFont } from '@pmndrs/text'; import { TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; import { selectBitmapStrikePpem } from '@pmndrs/text/three/bitmap'; @@ -33,7 +33,7 @@ import type { ComparisonWorkloadId, } from '../../../workloads/comparison/contracts'; import { - committedTextLayout, + committedTextMetrics, exactWidth, publishWorkloadTexts, type ComparisonWorkloadEntry, @@ -1552,8 +1552,8 @@ function measureVisibleEntries( if (!entry.node.visible) continue; geometries.clear(); measureVisibleObject(entry.node, metrics, geometries); - measureVisibleLayout(committedTextLayout(entry.text), zoomScale, metrics); - if (entry.labelText !== undefined) measureVisibleLayout(committedTextLayout(entry.labelText), zoomScale, metrics); + measureVisibleLayout(committedTextMetrics(entry.text), zoomScale, metrics); + if (entry.labelText !== undefined) measureVisibleLayout(committedTextMetrics(entry.labelText), zoomScale, metrics); metrics.sourceTextLength += entry.sourceText.length; } } @@ -1574,11 +1574,15 @@ function measureVisibleObject( for (const child of object.children) measureVisibleObject(child, metrics, geometries); } -function measureVisibleLayout(layout: ParagraphLayout, scale: number, metrics: MutableVisibleEntryMetrics): void { +function measureVisibleLayout( + layout: ParagraphLayoutSummary, + scale: number, + metrics: MutableVisibleEntryMetrics, +): void { metrics.layoutWidth = Math.max(metrics.layoutWidth, layout.width * scale); metrics.layoutHeight += layout.height * scale; - metrics.lineCount += layout.lineGlyphCounts.length; - for (const glyphId of layout.glyphIds) if (glyphId === 0) metrics.missingGlyphCount += 1; + metrics.lineCount += layout.lineCount; + metrics.missingGlyphCount += layout.missingGlyphCount; } function positive(value: number, label: string): number { diff --git a/apps/benchmarks/src/techniques/bitmap/conformance-line.ts b/apps/benchmarks/src/techniques/bitmap/conformance-line.ts index 1c2d4d63..9f6beb64 100644 --- a/apps/benchmarks/src/techniques/bitmap/conformance-line.ts +++ b/apps/benchmarks/src/techniques/bitmap/conformance-line.ts @@ -50,7 +50,7 @@ export function createBitmapConformanceLine( try { object.updateMatrixWorld(true); if (object.error !== undefined) throw object.error; - const layout = object.layout; + const layout = object.inspectLayout(); if (layout === undefined) throw new Error('target-v1 Text did not commit a bitmap layout'); const missingGlyphCount = layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0); if (missingGlyphCount !== 0) throw new Error(`benchmark specimen contains ${missingGlyphCount} missing glyphs`); diff --git a/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts b/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts index 41814350..52662997 100644 --- a/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts +++ b/apps/benchmarks/src/techniques/bitmap/persistent-scene.ts @@ -3,7 +3,7 @@ import { type FontFeature, type LoadedFont, type ParagraphContentBox, - type ParagraphLayout, + type ParagraphLayoutSummary, type ParagraphStyle, type RegisteredFont, } from '@pmndrs/text'; @@ -242,8 +242,8 @@ function updateBitmapDrawVisibility(object: THREE.Object3D): void { object.visible = glyphCount > 0; } -function countMissingGlyphs(layout: ParagraphLayout): number { - return layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0); +function countMissingGlyphs(layout: ParagraphLayoutSummary): number { + return layout.missingGlyphCount; } function bitmapContentBox(width: number, textAlign: 'start' | 'center'): ParagraphContentBox { @@ -432,8 +432,8 @@ async function activateBitmapTextPersistentScene( activeText.updateMatrixWorld(true); if (activeText.error !== undefined) throw activeText.error; const readyAt = performance.now(); - const committedLayout = (): ParagraphLayout => { - const layout = activeText.layout; + const committedLayout = (): ParagraphLayoutSummary => { + const layout = activeText.measureLayout(); if (layout === undefined) throw new Error('live bitmap Text lost its committed layout'); return layout; }; @@ -523,7 +523,7 @@ async function activateBitmapTextPersistentScene( revision: presentation.revision, presentationProgress: presentation.kind === 'settled' ? 1 : presentation.progress, glyphCount: countRenderedGlyphs(activeText), - lineCount: layout.lineGlyphCounts.length, + lineCount: layout.lineCount, layoutWidth: layout.width, layoutHeight: layout.height, }; @@ -724,7 +724,7 @@ async function activateBitmapTextPersistentScene( drawCount: countDraws(activeText), layoutWidth: layout.width, layoutHeight: layout.height, - lineCount: layout.lineGlyphCounts.length, + lineCount: layout.lineCount, strikePpem, cssFontSize: currentFontSize, renderedPpem: currentFontSize * viewport.dpr, diff --git a/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts b/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts index 6bdc5958..7df29fc8 100644 --- a/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts +++ b/apps/benchmarks/src/techniques/mtsdf/persistent-scene.ts @@ -3,7 +3,7 @@ import { type FontFeature, type LoadedFont, type ParagraphContentBox, - type ParagraphLayout, + type ParagraphLayoutSummary, type ParagraphStyle, type RegisteredFont, } from '@pmndrs/text'; @@ -462,7 +462,7 @@ export function createMtsdfTextPersistentScene(options: MtsdfTextPersistentScene drawCount: drawCount(resources.line), layoutWidth: layout.width, layoutHeight: layout.height, - lineCount: layout.lineGlyphCounts.length, + lineCount: layout.lineCount, atlasGpuBytes, framebufferGpuBytes, totalGpuBytes: atlasGpuBytes + framebufferGpuBytes, @@ -675,14 +675,14 @@ function positionLiveLine( line.position.set(x, y, 0); } -function committedLayout(line: Text): ParagraphLayout { - const layout = line.layout; +function committedLayout(line: Text): ParagraphLayoutSummary { + const layout = line.measureLayout(); if (layout === undefined) throw new Error('live MSDF Text lost its committed layout'); return layout; } -function missingGlyphCount(layout: ParagraphLayout): number { - return layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0); +function missingGlyphCount(layout: ParagraphLayoutSummary): number { + return layout.missingGlyphCount; } function positiveViewportSize(value: number, name: string): number { diff --git a/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts b/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts index e5223c9a..82d6a1d1 100644 --- a/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts +++ b/apps/benchmarks/src/techniques/shared/glyph-origin-transition.ts @@ -1,4 +1,5 @@ -import type { FontFeature, GlyphOriginUpdate, GlyphSnapshot, ParagraphLayout } from '@pmndrs/text'; +import type { FontFeature, ParagraphLayoutInspection } from '@pmndrs/text'; +import type { TextGlyphOriginSnapshot, TextGlyphOriginUpdate } from '@pmndrs/text/three'; /** * The part of a committed target-v1 `Text` this helper needs. Core owns glyph snapshots and topology-guarded @@ -6,9 +7,9 @@ import type { FontFeature, GlyphOriginUpdate, GlyphSnapshot, ParagraphLayout } f * and interpolation live here — in the application — and every live technique scene shares this one implementation. */ export interface TransitionableText { - readonly layout: ParagraphLayout | undefined; - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; + inspectLayout(): ParagraphLayoutInspection | undefined; + snapshotGlyphOrigins(): TextGlyphOriginSnapshot | undefined; + setGlyphOrigins(update: TextGlyphOriginUpdate): void; clearGlyphOriginOverrides(): void; } @@ -57,7 +58,7 @@ export interface GlyphOriginPresentation { */ export function snapGlyphOrigins(text: TransitionableText): GlyphOriginPresentation { text.clearGlyphOriginOverrides(); - return { transitioned: false, matchedGlyphs: 0, targetGlyphs: text.layout?.glyphIds.length ?? 0 }; + return { transitioned: false, matchedGlyphs: 0, targetGlyphs: text.inspectLayout()?.glyphIds.length ?? 0 }; } /** Displayed glyph origins copied out of one committed paragraph. It retains no renderer or core resources. */ @@ -85,10 +86,12 @@ const EMPTY_SNAPSHOT: GlyphOriginSnapshot = { glyphCount: 0, origins: new Map() * which is a normal first-frame state rather than a failure, so it yields an empty snapshot that matches nothing. */ export function captureGlyphOrigins(text: TransitionableText): GlyphOriginSnapshot { - const layout = text.layout; + const layout = text.inspectLayout(); if (layout === undefined) return EMPTY_SNAPSHOT; - const glyphs = text.snapshotGlyphs(); - const identities = glyphIdentityKeys(layout, glyphs); + const glyphs = text.snapshotGlyphOrigins(); + if (glyphs === undefined || glyphs.layout !== layout) + throw new TypeError('glyph origin snapshot lost its inspection'); + const identities = glyphIdentityKeys(layout); const origins = new Map(); for (let index = 0; index < identities.length; index += 1) { origins.set(identities[index]!, [glyphs.displayedX[index]!, glyphs.displayedY[index]!]); @@ -105,10 +108,12 @@ export function createGlyphOriginTransition( text: TransitionableText, from: GlyphOriginSnapshot, ): GlyphOriginTransition { - const layout = text.layout; + const layout = text.inspectLayout(); if (layout === undefined) throw new TypeError('glyph-origin transition requires a committed paragraph'); - const glyphs = text.snapshotGlyphs(); - const identities = glyphIdentityKeys(layout, glyphs); + const glyphs = text.snapshotGlyphOrigins(); + if (glyphs === undefined || glyphs.layout !== layout) + throw new TypeError('glyph origin snapshot lost its inspection'); + const identities = glyphIdentityKeys(layout); const targetGlyphs = identities.length; const fromX = glyphs.shapedX.slice(); const fromY = glyphs.shapedY.slice(); @@ -120,7 +125,6 @@ export function createGlyphOriginTransition( fromY[index] = origin[1]; matchedGlyphs += 1; } - const topology = glyphs.topology; const targetX = glyphs.shapedX; const targetY = glyphs.shapedY; const displayedX = new Float32Array(targetGlyphs); @@ -133,7 +137,7 @@ export function createGlyphOriginTransition( } // A reflow publishes a new layout object and a new topology together, which is exactly when interpolating between // the old and new glyph arrays would be meaningless. Report it as staleness, the way a superseded update reads. - if (disposed || text.layout !== layout) { + if (disposed || text.inspectLayout() !== layout) { throw new DOMException('The glyph-origin transition is stale', 'AbortError'); } for (let index = 0; index < targetGlyphs; index += 1) { @@ -142,7 +146,7 @@ export function createGlyphOriginTransition( displayedX[index] = startX + (targetX[index]! - startX) * nextProgress; displayedY[index] = startY + (targetY[index]! - startY) * nextProgress; } - text.setGlyphOrigins({ topology, x: displayedX, y: displayedY }); + text.setGlyphOrigins({ layout, x: displayedX, y: displayedY }); progress = nextProgress; }; return { @@ -233,18 +237,18 @@ function sameFontFeatures(previous: readonly FontFeature[], next: readonly FontF * Reproduces the identity merged-v0 matched on: font handle, glyph id, cluster, exact font size, and the occurrence * index that separates otherwise identical glyphs within one paragraph. */ -function glyphIdentityKeys(layout: ParagraphLayout, glyphs: GlyphSnapshot): readonly string[] { - assertParallelGlyphIdentity(layout, glyphs); +function glyphIdentityKeys(layout: ParagraphLayoutInspection): readonly string[] { + assertParallelGlyphIdentity(layout); const counts = new Map(); const keys: string[] = []; - for (let index = 0; index < glyphs.glyphIds.length; index += 1) { - const fontHandle = layout.fontHandles[glyphs.fontSlots[index]!]; + for (let index = 0; index < layout.glyphIds.length; index += 1) { + const fontHandle = layout.fontHandles[layout.glyphFontSlots[index]!]; if (fontHandle === undefined) throw new TypeError('paragraph layout references a missing font slot'); // Font size is deliberately absent. The merged renderer keyed on it, which made a glyph fail to match itself across // the one change the transition exists to animate, so a size change reported every glyph as new and interpolated // nothing. Font handle, glyph id, cluster, and occurrence still identify a glyph, and a uniform scale preserves // visual order, so matching across a resize recovers exactly the glyph that moved. - const baseKey = `${fontHandle}:${glyphs.glyphIds[index]!}:${glyphs.clusters[index]!}`; + const baseKey = `${fontHandle}:${layout.glyphIds[index]!}:${layout.clusters[index]!}`; const occurrence = counts.get(baseKey) ?? 0; counts.set(baseKey, occurrence + 1); keys.push(`${baseKey}:${occurrence}`); @@ -252,15 +256,14 @@ function glyphIdentityKeys(layout: ParagraphLayout, glyphs: GlyphSnapshot): read return keys; } -function assertParallelGlyphIdentity(layout: ParagraphLayout, glyphs: GlyphSnapshot): void { - const glyphCount = glyphs.glyphIds.length; +function assertParallelGlyphIdentity(layout: ParagraphLayoutInspection): void { + const glyphCount = layout.glyphIds.length; for (const values of [ - glyphs.clusters, - glyphs.fontSlots, - glyphs.shapedX, - glyphs.shapedY, - glyphs.displayedX, - glyphs.displayedY, + layout.glyphStableIds, + layout.clusters, + layout.glyphFontSlots, + layout.x, + layout.y, layout.glyphFontSizes, ]) { if (values.length !== glyphCount) throw new TypeError('paragraph glyph identity arrays are not parallel'); diff --git a/apps/benchmarks/src/techniques/slug/persistent-scene.ts b/apps/benchmarks/src/techniques/slug/persistent-scene.ts index e6852a22..3c6dc8ec 100644 --- a/apps/benchmarks/src/techniques/slug/persistent-scene.ts +++ b/apps/benchmarks/src/techniques/slug/persistent-scene.ts @@ -4,7 +4,7 @@ import { type FontFeature, type LoadedFont, type ParagraphContentBox, - type ParagraphLayout, + type ParagraphLayoutSummary, type ParagraphStyle, type RegisteredFont, } from '@pmndrs/text'; @@ -485,7 +485,7 @@ export function createSlugTextPersistentScene(options: SlugTextPersistentSceneOp drawCount: drawCount(line), layoutWidth: layout.width, layoutHeight: layout.height, - lineCount: layout.lineGlyphCounts.length, + lineCount: layout.lineCount, slugPageCount: currentFontFixture.rasterConfiguration.pageCount, slugCurveTexelCount: currentFontFixture.rasterConfiguration.curveTexelCount, slugCurveGpuBytes: currentFontFixture.rasterConfiguration.curveBytes, @@ -681,14 +681,14 @@ function positionLiveLine( line.position.set(x, y, 0); } -function committedLayout(line: Text): ParagraphLayout { - const layout = line.layout; +function committedLayout(line: Text): ParagraphLayoutSummary { + const layout = line.measureLayout(); if (layout === undefined) throw new Error('live Slug Text lost its committed layout'); return layout; } -function missingGlyphCount(layout: ParagraphLayout): number { - return layout.glyphIds.reduce((count, glyphId) => count + (glyphId === 0 ? 1 : 0), 0); +function missingGlyphCount(layout: ParagraphLayoutSummary): number { + return layout.missingGlyphCount; } function positiveViewportSize(value: number, name: string): number { diff --git a/apps/benchmarks/src/v1-bitmap-proof.ts b/apps/benchmarks/src/v1-bitmap-proof.ts index 72edafc6..3709a70e 100644 --- a/apps/benchmarks/src/v1-bitmap-proof.ts +++ b/apps/benchmarks/src/v1-bitmap-proof.ts @@ -64,7 +64,7 @@ async function render(): Promise { return { backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu', drawCount: text.children.filter((child) => child instanceof THREE.Mesh).length, - glyphCount: text.layout?.glyphIds.length ?? 0, + glyphCount: text.measureLayout()?.glyphCount ?? 0, litPixels, retainedDraw: retainedDraw === firstDraw, retainedStorage: retainedDraw?.geometry.getAttribute('_pmndrsTextOrigins') === firstStorage, diff --git a/apps/benchmarks/src/v1-compose-proof.ts b/apps/benchmarks/src/v1-compose-proof.ts index c618ec4a..54474daf 100644 --- a/apps/benchmarks/src/v1-compose-proof.ts +++ b/apps/benchmarks/src/v1-compose-proof.ts @@ -110,7 +110,7 @@ async function render(): Promise { return { backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu', drawCount: composedText.children.filter((child) => child instanceof THREE.Mesh).length, - glyphCount: composedText.layout?.glyphIds.length ?? 0, + glyphCount: composedText.measureLayout()?.glyphCount ?? 0, litPixels: composed.lit, redPixels: composed.red, greenPixels: composed.green, diff --git a/apps/benchmarks/src/v1-mtsdf-proof.ts b/apps/benchmarks/src/v1-mtsdf-proof.ts index d8735358..08908ee9 100644 --- a/apps/benchmarks/src/v1-mtsdf-proof.ts +++ b/apps/benchmarks/src/v1-mtsdf-proof.ts @@ -71,7 +71,7 @@ async function render(): Promise { return { backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu', drawCount: text.children.filter((child) => child instanceof THREE.Mesh).length, - glyphCount: text.layout?.glyphIds.length ?? 0, + glyphCount: text.measureLayout()?.glyphCount ?? 0, litPixels, retainedDraw: retainedDraw === firstDraw, retainedStorage: retainedDraw?.geometry.getAttribute('_pmndrsText_geometry') === firstStorage, diff --git a/apps/benchmarks/src/v1-slug-proof.ts b/apps/benchmarks/src/v1-slug-proof.ts index a32400f1..f97e2dda 100644 --- a/apps/benchmarks/src/v1-slug-proof.ts +++ b/apps/benchmarks/src/v1-slug-proof.ts @@ -71,7 +71,7 @@ async function render(): Promise { return { backend: renderer.backend instanceof THREE.WebGLBackend ? 'webgl2' : 'webgpu', drawCount: text.children.filter((child) => child instanceof THREE.Mesh).length, - glyphCount: text.layout?.glyphIds.length ?? 0, + glyphCount: text.measureLayout()?.glyphCount ?? 0, litPixels, retainedDraw: retainedDraw === firstDraw, retainedStorage: retainedDraw?.geometry.getAttribute('_pmndrsText_geometry') === firstStorage, diff --git a/apps/benchmarks/src/workloads/dynamic-layout/scene.ts b/apps/benchmarks/src/workloads/dynamic-layout/scene.ts index 63e139b6..1b33ec0a 100644 --- a/apps/benchmarks/src/workloads/dynamic-layout/scene.ts +++ b/apps/benchmarks/src/workloads/dynamic-layout/scene.ts @@ -5,7 +5,7 @@ import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } fr import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, exactWidth, paintColor, publishWorkloadTexts, @@ -167,7 +167,7 @@ export function layoutDynamicLayoutEntries( const inset = 20; const laneHeight = viewportHeight / Math.max(1, entries.length); for (const [index, entry] of entries.entries()) { - const layout = committedTextLayout(entry.text); + const layout = committedTextMetrics(entry.text); const x = entry.alignment === 'end' ? viewportWidth - inset - layout.width diff --git a/apps/benchmarks/src/workloads/icon-grid/scene.ts b/apps/benchmarks/src/workloads/icon-grid/scene.ts index 5e70dbec..f6072693 100644 --- a/apps/benchmarks/src/workloads/icon-grid/scene.ts +++ b/apps/benchmarks/src/workloads/icon-grid/scene.ts @@ -5,7 +5,7 @@ import fontAwesomeIcons from '../../../fixtures/fonts/font-awesome-free-6.7.2/ic import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, exactWidth, paintColor, publishWorkloadTexts, @@ -160,7 +160,7 @@ export function positionIconGridEntry( row: number, iconSize: number, ): void { - const iconLayout = committedTextLayout(entry.text); + const iconLayout = committedTextMetrics(entry.text); entry.node.position.set( layout.inset + column * (layout.cellWidth + layout.gap), -(layout.inset + row * (layout.cellHeight + layout.gap)), diff --git a/apps/benchmarks/src/workloads/off-axis-3d/scene.ts b/apps/benchmarks/src/workloads/off-axis-3d/scene.ts index 12fe8d2d..04e8afd1 100644 --- a/apps/benchmarks/src/workloads/off-axis-3d/scene.ts +++ b/apps/benchmarks/src/workloads/off-axis-3d/scene.ts @@ -6,7 +6,7 @@ import { createOklabColorCycle } from '../shared/oklab-color-cycle'; import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, exactWidth, paintColor, type ComparisonWorkloadEntry, @@ -102,7 +102,7 @@ export function layoutOffAxis3dEntries( ): void { const entry = entries[0]; if (entry === undefined) return; - const layout = committedTextLayout(entry.text); + const layout = committedTextMetrics(entry.text); entry.text.position.set(-layout.width / 2, layout.height / 2, 0); entry.node.position.set(viewportWidth * (0.5 + OFF_AXIS_HORIZONTAL_BIAS_RATIO), -viewportHeight / 2, 0); } diff --git a/apps/benchmarks/src/workloads/paint-effects/scene.ts b/apps/benchmarks/src/workloads/paint-effects/scene.ts index 76a44c31..9f045b40 100644 --- a/apps/benchmarks/src/workloads/paint-effects/scene.ts +++ b/apps/benchmarks/src/workloads/paint-effects/scene.ts @@ -4,7 +4,7 @@ import type { RasterTechnique } from '../../benchmark/url-state'; import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; import { benchmarkContentWidth, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, exactWidth, paintColor, type ComparisonWorkloadEntry, @@ -135,7 +135,7 @@ export function layoutPaintEffectsEntries( ): void { const entry = entries[0]; if (entry === undefined) return; - const layout = committedTextLayout(entry.text); + const layout = committedTextMetrics(entry.text); entry.text.position.set( Math.max(12, (viewportWidth - layout.width) / 2), -Math.max(18, (viewportHeight - layout.height) / 2), diff --git a/apps/benchmarks/src/workloads/paragraph-stress/scene.ts b/apps/benchmarks/src/workloads/paragraph-stress/scene.ts index faec8db7..fe964db2 100644 --- a/apps/benchmarks/src/workloads/paragraph-stress/scene.ts +++ b/apps/benchmarks/src/workloads/paragraph-stress/scene.ts @@ -6,7 +6,7 @@ import { paragraphStressScrollProgress } from '../../benchmark/paragraph-stress- import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; import { benchmarkContentWidth, LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, exactWidth, paintColor, type ComparisonWorkloadEntry, @@ -73,7 +73,7 @@ export function layoutParagraphStressEntries( ): void { const entry = entries[0]; if (entry === undefined) return; - const layout = committedTextLayout(entry.text); + const layout = committedTextMetrics(entry.text); entry.text.position.set( Math.max(12, (viewportWidth - layout.width) / 2), -Math.max(12, (viewportHeight - layout.height) / 2), @@ -90,7 +90,7 @@ export function animateParagraphStressScene( ): void { const entry = entries[0]; if (entry === undefined) return; - const layout = committedTextLayout(entry.text); + const layout = committedTextMetrics(entry.text); const scrollProgress = paragraphStressScrollProgress(elapsedMs, configuration.animationSpeed); const maximumScrollY = Math.max(0, layout.height - viewportHeight + 24); scene.position.y = maximumScrollY * scrollProgress; diff --git a/apps/benchmarks/src/workloads/rich-text/scene.ts b/apps/benchmarks/src/workloads/rich-text/scene.ts index 634a2a30..7a7af2f5 100644 --- a/apps/benchmarks/src/workloads/rich-text/scene.ts +++ b/apps/benchmarks/src/workloads/rich-text/scene.ts @@ -5,7 +5,7 @@ import type { RasterTechnique } from '../../benchmark/url-state'; import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; import { benchmarkContentWidth, LIVE_TEXT_COLOR_CSS, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, exactWidth, type ComparisonWorkloadEntry, type WorkloadFont, @@ -311,7 +311,7 @@ export function layoutRichTextEntries( viewportWidth: number, viewportHeight: number, ): void { - const layouts = entries.map(({ text }) => committedTextLayout(text)); + const layouts = entries.map(({ text }) => committedTextMetrics(text)); if (layouts.length === 0) return; const stackHeight = layouts.reduce((total, layout) => total + layout.height, 0) + RICH_TEXT_PARAGRAPH_GAP * (layouts.length - 1); diff --git a/apps/benchmarks/src/workloads/shared/scene-entry.ts b/apps/benchmarks/src/workloads/shared/scene-entry.ts index 4c3c7e12..3176f70d 100644 --- a/apps/benchmarks/src/workloads/shared/scene-entry.ts +++ b/apps/benchmarks/src/workloads/shared/scene-entry.ts @@ -1,4 +1,4 @@ -import type { AnyRasterTechnique, LoadedFont, ParagraphLayout } from '@pmndrs/text'; +import type { AnyRasterTechnique, LoadedFont, ParagraphLayoutSummary } from '@pmndrs/text'; import { TextGroup, type Text } from '@pmndrs/text/three'; import type * as THREE from 'three/webgpu'; @@ -71,8 +71,8 @@ export interface WorkloadTextFactoryContext { * Commits every pending Text edit beneath `root` and reports the first failure. * * Target-v1 has no per-Text readiness promise. A `TextGroup` — and a standalone `Text` that has a parent — reconciles, - * shapes, lays out, and packs synchronously inside `updateMatrixWorld`, so this call is exactly the point at which - * `layout` becomes readable and `error` becomes meaningful. + * shapes, lays out, and packs synchronously inside `updateMatrixWorld`, so this call is exactly the point at which an + * explicit retained-Rust measurement can observe the committed revision and `error` becomes meaningful. */ export function publishWorkloadTexts(root: THREE.Object3D, entries: readonly ComparisonWorkloadEntry[]): void { root.updateMatrixWorld(true); @@ -83,11 +83,11 @@ export function publishWorkloadTexts(root: THREE.Object3D, entries: readonly Com } } -/** Returns the layout committed by the Text lifecycle before a workload positions its scene. */ -export function committedTextLayout(text: WorkloadText): ParagraphLayout { - const layout = text.layout; - if (layout === undefined) throw new Error('workload Text lost its committed layout'); - return layout; +/** Explicitly queries the aggregate metrics committed by the Rust Text lifecycle before scene positioning. */ +export function committedTextMetrics(text: WorkloadText): ParagraphLayoutSummary { + const metrics = text.measureLayout(); + if (metrics === undefined) throw new Error('workload Text lost its committed layout metrics'); + return metrics; } /** Target-v1 paint takes CSS colors, while the comparison palettes stay authored as 24-bit hex. */ diff --git a/apps/benchmarks/src/workloads/text-ladder/scene.ts b/apps/benchmarks/src/workloads/text-ladder/scene.ts index f147ee5c..46fb111f 100644 --- a/apps/benchmarks/src/workloads/text-ladder/scene.ts +++ b/apps/benchmarks/src/workloads/text-ladder/scene.ts @@ -5,7 +5,7 @@ import type { RasterConformanceSpecimen } from '../../benchmark/font-fixtures'; import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, paintColor, type ComparisonWorkloadEntry, type WorkloadTextFactoryContext, @@ -154,7 +154,7 @@ export function setTextLadderScenePosition( } export function layoutTextLadderEntries(entries: readonly ComparisonWorkloadEntry[], viewportWidth: number): void { - const layouts = entries.map(({ text }) => committedTextLayout(text)); + const layouts = entries.map(({ text }) => committedTextMetrics(text)); const widestLine = layouts.reduce((maximum, layout) => Math.max(maximum, layout.width), 0); const centeredColumnWidth = Math.min(widestLine, viewportWidth * 0.94); const x = Math.max(LADDER_INSET_CSS_PX, (viewportWidth - centeredColumnWidth) / 2); @@ -177,7 +177,7 @@ export function animateTextLadderScene( ): void { const finalEntry = entries[entries.length - 1]; if (finalEntry === undefined) return; - const layout = committedTextLayout(finalEntry.text); + const layout = committedTextMetrics(finalEntry.text); setTextLadderScenePosition(positionScratch, { animationSpeed: configuration.animationSpeed, elapsedMs, diff --git a/apps/benchmarks/src/workloads/zoom-text/scene.ts b/apps/benchmarks/src/workloads/zoom-text/scene.ts index d40ae150..63ee4f20 100644 --- a/apps/benchmarks/src/workloads/zoom-text/scene.ts +++ b/apps/benchmarks/src/workloads/zoom-text/scene.ts @@ -4,7 +4,7 @@ import * as THREE from 'three/webgpu'; import type { ComparisonWorkloadConfiguration, ComparisonWorkloadDefinition } from '../comparison/contracts'; import { LIVE_TEXT_COLOR, LIVE_TEXT_LINE_HEIGHT } from '../shared/text-style'; import { - committedTextLayout, + committedTextMetrics, paintColor, type ComparisonWorkloadEntry, type WorkloadTextFactoryContext, @@ -197,7 +197,7 @@ export function animateZoomTextEntries( } function layoutZoomTextEntry(entry: ComparisonWorkloadEntry, viewportWidth: number, viewportHeight: number): void { - const layout = committedTextLayout(entry.text); + const layout = committedTextMetrics(entry.text); entry.text.position.set(-layout.width / 2, layout.height / 2, 0); entry.node.position.set(viewportWidth / 2, -viewportHeight / 2, 0); entry.node.scale.setScalar(1); diff --git a/docs/log.md b/docs/log.md index cc756c3d..d175d188 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-09 +- **Kept layout inspection and presentation outside rendering authority** — Added an explicit Rust semantic-glyph + inspection mask alongside measurement; ordinary rendering still publishes no layout arrays. First-party policy + programs now carry one stable glyph ID per renderable instance so Three can direct optional Bitmap/MTSDF/Slug origin + presentation without reconstructing glyph topology. The executor restores authoritative origins before every later + command-buffer update and retains only resource tables plus reversible overrides, eliminating any need to revive the + candidate/current target state machine. Compiled-Wasm fixtures cover semantic spaces, shared two-paragraph batching, + isolated overrides, transform-only retention, and semantic-update retirement. The refreshed canonical checkpoint is + 1,089,889 raw / 414,204 gzip / 325,805 Brotli shaper bytes; legacy-path deletion and a Rust size pass remain open. + - **Separated semantic measurement from the render plan** — Activated the existing `semanticViewMask` for an explicit retained-Rust measurement query while ordinary rendering continues to request zero semantic records. The first view publishes one paragraph summary plus its line records in the immutable A/B sidecar; Three's command-buffer executor diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index cf9ce347..b4a8fd9f 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:9856ec47cea3a348fd12bb1678d3c51bde7982569a2d443eedacb1a36ffd00b9' +source_digest: 'sha256:7781a56401ad8744d121c3c5635163d37e25a575bf4422c47d75f06ca549a673' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -232,8 +232,8 @@ bounds, so the oracle changed renderer without changing what counts as correct. The three live technique scenes moved to target-v1 next. `techniques/{bitmap,mtsdf,slug}/persistent-scene.ts` now build a standalone `Text` — an implicit batch of one, deliberately left off `TextGroup` so the single-paragraph adapter path stays exercised and their `drawCount` stays directly comparable with merged v0 — from the `LoadedFont` that -`workloads/font-assets` already produced, commit it by parenting and forcing `updateMatrixWorld`, and read `error` and -`layout` instead of awaiting readiness. Flat merged-v0 properties become nested `contentBox`, `style`, and `paint`, with +`workloads/font-assets` already produced, commit it by parenting and forcing `updateMatrixWorld`, and read `error` plus +explicit `measureLayout()` or `inspectLayout()` results instead of awaiting readiness. Flat merged-v0 properties become nested `contentBox`, `style`, and `paint`, with the paragraph measure expressed as an exact width constraint and the live colour as `#ffffff`, which resolves through the same transfer function as the numeric constant it replaces. Because a rejected generation would otherwise leave the failed candidate font leased and undisposable, each scene commits through one apply-or-roll-back step that restores the previously @@ -241,10 +241,10 @@ committed inputs before rethrowing. Their presentation transitions are now owned by the application. Merged v0 exported `captureBitmapGlyphPositions` and `createBitmapGlyphPositionTransition`, which packaged glyph identity matching and interpolation together for Bitmap only. -Target-v1 core deliberately stops at owned glyph snapshots and topology-guarded displayed-origin writes, so -`techniques/shared/glyph-origin-transition.ts` reimplements the policy once for all three techniques: it matches glyphs on -the identity merged v0 used — font handle, glyph id, cluster, exact font size, and occurrence index — interpolates toward -the shaped origins rather than the current displayed ones, writes through `setGlyphOrigins`, clears the overrides when +Target-v1 exposes explicit Rust inspection and topology-guarded renderer presentation writes, so +`techniques/shared/glyph-origin-transition.ts` owns the application policy once for all three techniques: it matches glyphs on +font handle, glyph id, cluster, and occurrence index, interpolates toward the shaped origins rather than the current +displayed ones, writes through `setGlyphOrigins`, clears the overrides when settled, and reports `matchedGlyphs` so the existing viewport telemetry keeps its meaning. Bitmap keeps its host-driven progress because its React viewport already animates the timeline; MTSDF and Slug, whose surfaces do not drive progress, advance the same smoothstep from their own frame clock and gain the transition they previously lacked. diff --git a/docs/packages/text.md b/docs/packages/text.md index 21b0d486..bda48bfd 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:aa033b7909cfedec42bced6bd5e9b6b69d4c7fc7490a1f85e4277858498bee23' +source_digest: 'sha256:34af8782a43930eebccf5c1fe5c045ecfb26caf7b88c548bd99dc4c8d1d9b595' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -924,11 +924,11 @@ The imperative Three binding no longer constructs a TypeScript `ParagraphBatch` standalone `Text` owns the same path with a private session. Rust receives paragraph/text/style/constraint mutations, publishes one command-buffer delta, and compatible group children become one indexed draw under the group root. The executor retains only renderer resources needed to apply later deltas. It does not receive paragraph layout arrays; -Three's old layout, glyph-snapshot, and glyph-origin override surface is removed. Any later interaction or measurement -API must query retained Rust layout state separately and on demand. Focused integration covers mixed-font spans, custom -material factories, two-child indexed batching, renderer-local transform updates, reparenting, and disposal. R3F, -which constructs the same imperative objects, is cut over as well. TypeGPU and the portable legacy core still own the -remaining cutover and deletion work. +Three's old implicit `layout` and renderer-owned glyph snapshot surface is removed. Interaction and measurement APIs +query retained Rust layout state separately and on demand. Focused integration covers mixed-font spans, custom material +factories, two-child indexed batching, renderer-local transform updates, reparenting, and disposal. R3F, which constructs +the same imperative objects, is cut over as well. TypeGPU and the portable legacy core still own the remaining cutover +and deletion work. Third-party Three techniques use the same Rust planner instead of reviving the removed target transaction. Public `registerThreeRasterPlanProgram` accepts a static policy descriptor, a cold compiler that lowers the package's validated @@ -945,6 +945,19 @@ sidecar. A semantic edit clears the cache; repeated measurement of the same comm object without another crossing. Rust tests cover exact and at-most box resolution plus overflow, and the compiled-Wasm Three fixture proves measurement retains the existing mesh while the removed `Text.layout` arrays remain absent. +`Text.inspectLayout()` is a separate explicit mask for consumers that actually need lines and semantic glyphs. It copies +stable glyph IDs, font handles, glyph IDs, clusters, sizes, flags, and shaped origins—including non-rendering spaces—but +none of those arrays enter an ordinary render publication. Presentation motion is instead a directed policy +augmentation: the first-party programs write one stable `u32` glyph ID per renderable record in buffer 14, and Three +pairs that stream with the technique's existing origin buffer. `snapshotGlyphOrigins`, `setGlyphOrigins`, and +`clearGlyphOriginOverrides` operate only on renderer-local displayed values guarded by the exact inspection object. +Before any later Rust plan is applied, the executor restores its captured target values, allowing the authoritative +patch/retirement transaction to proceed without a parallel candidate/current target state machine. A shared two-text +fixture proves session-global IDs isolate overrides inside one indexed draw; a semantic resize publishes a new inspection +and clears the old presentation override. The complete optimized shaper at this checkpoint measures 1,089,889 raw, +414,204 gzip, and 325,805 Brotli bytes on the canonical Darwin arm64 host; deletion of the remaining legacy TypeScript +path and a deliberate Rust size pass remain required before release acceptance. + Public font stacks no longer repeat a raster technique or require every fallback font to share one. Their generic type is the union of the concrete loaded-font techniques, while runtime construction still requires one text-runtime domain, unique font identities, and live font leases. Public Three `TextGroup` likewise has no authored technique: its retained diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 86b3af14..b1ba8cab 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -311,6 +311,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-225 | A third-party Three raster integrates with the Rust command buffer through one declarative `registerThreeRasterPlanProgram` registration. Its static policy descriptor is compiled and validated before the engine session exists; it contains input, physical-buffer, scalar-operation, and batching-key data but no hot JavaScript callback. Its cold font compiler lowers validated package-owned raster fields and resource references into one Rust binding, while its renderer factory realizes a material from exact plan buffers only when a draw requires it. The external glyph-example package no longer owns a `ParagraphBatchTarget`, candidate revision, slack planner, dirty-range copier, mesh transaction, or renderer-side layout loop. Compiled-Wasm lifecycle coverage proves Rust-packed buffers and retained mesh/geometry identity. A hardware-browser proof produces two deterministic samples on both WebGPU and forced WebGL2 with the same RGBA SHA-256 `817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`. Indexed visibility remains renderer-local: hiding one public `Text` zeros only its matrix-sidecar slot and preserves the shared draw; direct-policy draws mirror that object's mesh visibility. | Accepted | | D-226 | Semantic layout inspection is an explicit demand-shaped Rust query, not renderer input and not a reason to retain the legacy TypeScript layout path. The query uses the existing `semanticViewMask` on `text_update`; zero remains the ordinary rendering request. Its records may share the immutable A/B publication lifetime, but `ThreeTextRenderPlanExecutor` ignores them. The first public `Text.measureLayout()` view emits one paragraph summary plus its line records, performs one extra crossing only on an uncached explicit request, and invalidates on a committed semantic update. Record-level Rust tests cover exact/at-most sizing and overflow, while a real compiled-Wasm Three fixture proves the query leaves `Text.layout` absent and retains the existing mesh. Per-glyph inspection, caret, selection, hit testing, accessibility, and diagnostics remain separate masks and do not enter the render plan by default. | Accepted | +| D-227 | Explicit per-glyph inspection and presentation motion do not restore a renderer-side layout or target transaction. `Text.inspectLayout()` requests the Rust sidecar mask only on demand and copies paragraph summary, lines, semantic glyph IDs, clusters, font identity, size, flags, and shaped origins; ordinary render updates still request none of it. The first-party policy deliberately augments each renderable instance with its session-global stable glyph ID in `u32` buffer 14. Three pairs that policy output with the existing origin stream, snapshots renderer-local displayed origins, and permits topology-guarded presentation overrides for Bitmap, MSDF, and Slug. Before applying any later Rust command-buffer delta, the executor restores the captured authoritative origins; the plan then patches, replaces, or retires resources normally. Thus Rust remains the sole render-state transition authority, while Three retains only resource/draw tables and reversible presentation state—no parallel candidate/current `ParagraphBatchTarget` state machine. Rust record tests and compiled-Wasm one- and two-paragraph fixtures prove semantic spaces remain inspectable, stable IDs address shared draws without collision, overrides do not mutate shaped targets, transform-only updates preserve them, and semantic updates retire them. The optimized shaper is 1,089,889 raw / 414,204 gzip / 325,805 Brotli bytes on the canonical Darwin arm64 host. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index ef4410b2..a153e5b3 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -755,9 +755,17 @@ The render plan contains: - **retirement:** the earliest generation after which resources, slots, and output bytes may be reused. An explicitly requested semantic query may share the immutable A/B publication and its lifetime, but it is a sidecar, -not a render-plan table and not an input to the renderer executor. The first admitted view contains one paragraph -measurement record plus its line records; its mask is zero on ordinary rendering updates. Glyph inspection, caret, -selection, hit testing, accessibility, and diagnostics must each prove a bounded record shape before admission. +not a render-plan table and not an input to the renderer executor. The measurement view contains one paragraph summary +plus its line records. The separately requested inspection view adds semantic glyph identity, font, cluster, size, +flags, and shaped origins, including glyphs such as spaces that deliberately produce no render instance. Both masks are +zero on ordinary rendering updates. Caret, selection, hit testing, accessibility, and diagnostics must each prove a +bounded record shape before admission. + +A policy may request a renderer augmentation without moving semantic layout into the plan. The first-party Three policy +writes each renderable glyph's session-global stable ID to one compact `u32` stream beside the technique's existing +origin stream. Three uses that directed identifier only to implement optional presentation motion over retained GPU +records. It restores authoritative target origins before every later plan application, so the Rust command buffer remains +the sole render-state transition and no renderer-side candidate/current target state machine is required. The initial adapter lowers this IR to Three attributes, TSL storage nodes, and draws. The same graph runs through Three's WebGPU backend and forced WebGL2 backend. In Three 0.185.1, WebGL PBO setup replaces the supplied typed array with diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 3be3c391..93cf723b 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -17,12 +17,13 @@ use crate::engine::frame::{ SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, - SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_MASK, SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, - SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, - STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, - STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, - STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, - STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + SEMANTIC_U32_STABLE_GLYPH_ID, SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_LAYOUT_INSPECTION, SEMANTIC_VIEW_MASK, + SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, + STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, + STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; @@ -45,8 +46,9 @@ use crate::engine::render_plan::{ RETIRE_OUTPUT_BYTES, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, ResourceRecord, RetirementRecord, }; use crate::engine::semantic_view::{ - SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_INSERTED_GLYPH, SEMANTIC_LINE, - SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, SEMANTIC_SELECTION, SemanticRecord, + SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_GLYPH, SEMANTIC_INSERTED_GLYPH, + SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, SEMANTIC_SELECTION, + SemanticRecord, }; pub const ABI_VERSION: u32 = 0; @@ -2648,7 +2650,8 @@ pub fn json() -> String { "clusterId": SEMANTIC_U32_CLUSTER_ID, "regionId": SEMANTIC_U32_REGION_ID, "flowThreadId": SEMANTIC_U32_FLOW_THREAD_ID, - "transformIndex": SEMANTIC_U32_TRANSFORM_INDEX + "transformIndex": SEMANTIC_U32_TRANSFORM_INDEX, + "stableGlyphId": SEMANTIC_U32_STABLE_GLYPH_ID }, "paragraphMutationOpcodes": { "upsert": PARAGRAPH_MUTATION_UPSERT, @@ -2755,7 +2758,8 @@ pub fn json() -> String { }, "semanticViewMasks": { "all": SEMANTIC_VIEW_MASK, - "measurement": SEMANTIC_VIEW_MEASUREMENT + "measurement": SEMANTIC_VIEW_MEASUREMENT, + "layoutInspection": SEMANTIC_VIEW_LAYOUT_INSPECTION }, "measurementFlags": { "overflowed": crate::engine::layout_query::MEASUREMENT_FLAG_OVERFLOWED @@ -2768,7 +2772,8 @@ pub fn json() -> String { "caret": SEMANTIC_CARET, "selection": SEMANTIC_SELECTION, "insertedGlyph": SEMANTIC_INSERTED_GLYPH, - "paragraphMeasurement": SEMANTIC_PARAGRAPH_MEASUREMENT + "paragraphMeasurement": SEMANTIC_PARAGRAPH_MEASUREMENT, + "glyph": SEMANTIC_GLYPH }, "resourceActions": { "create": RESOURCE_ACTION_CREATE, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index 10670a6b..f42446fc 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -1,6 +1,8 @@ pub(crate) const RESULT_FLAG_CHECKPOINT: u32 = 1; pub(crate) const SEMANTIC_VIEW_MEASUREMENT: u32 = 1 << 0; -pub(crate) const SEMANTIC_VIEW_MASK: u32 = SEMANTIC_VIEW_MEASUREMENT; +pub(crate) const SEMANTIC_VIEW_LAYOUT_INSPECTION: u32 = 1 << 1; +pub(crate) const SEMANTIC_VIEW_MASK: u32 = + SEMANTIC_VIEW_MEASUREMENT | SEMANTIC_VIEW_LAYOUT_INSPECTION; pub(crate) const TEXT_MUTATION_REPLACE_UTF16: u8 = 1; pub(crate) const TEXT_ENCODING_UTF16_LE: u8 = 1; @@ -81,6 +83,7 @@ pub(crate) const SEMANTIC_U32_CLUSTER_ID: u8 = 1; pub(crate) const SEMANTIC_U32_REGION_ID: u8 = 2; pub(crate) const SEMANTIC_U32_FLOW_THREAD_ID: u8 = 3; pub(crate) const SEMANTIC_U32_TRANSFORM_INDEX: u8 = 4; +pub(crate) const SEMANTIC_U32_STABLE_GLYPH_ID: u8 = 5; pub(crate) const PARAGRAPH_MUTATION_UPSERT: u8 = 1; pub(crate) const PARAGRAPH_MUTATION_REMOVE: u8 = 2; diff --git a/packages/text/rust/shaper/src/engine/layout_query.rs b/packages/text/rust/shaper/src/engine/layout_query.rs index a424d041..b0b8102b 100644 --- a/packages/text/rust/shaper/src/engine/layout_query.rs +++ b/packages/text/rust/shaper/src/engine/layout_query.rs @@ -10,7 +10,10 @@ use super::{ flow_composition::{FlowFragment, FlowLayoutArena, FlowLine}, flow_geometry::FlowGeometryArena, frame::{AXIS_AT_MOST, AXIS_EXACT, AXIS_UNCONSTRAINED}, - semantic_view::{SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SemanticRecord}, + positioning::SemanticGlyph, + semantic_view::{ + SEMANTIC_GLYPH, SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SemanticRecord, + }, }; pub(crate) const MEASUREMENT_FLAG_OVERFLOWED: u16 = 1; @@ -22,6 +25,10 @@ pub(crate) fn append_measurement( cluster_count: usize, geometry: &FlowGeometryArena, flow: &FlowLayoutArena, + positioned_glyphs: &[SemanticGlyph], + semantic_line_glyph_starts: &[u32], + semantic_line_glyph_counts: &[u32], + include_glyphs: bool, ) -> Result<(), EngineError> { let constraint = geometry .constraints @@ -30,13 +37,39 @@ pub(crate) fn append_measurement( if constraint.paragraph_id != paragraph_id { return Err(EngineError::InvalidRequest); } + if semantic_line_glyph_starts.len() != flow.lines.len() + || semantic_line_glyph_counts.len() != flow.lines.len() + { + return Err(EngineError::InvalidRequest); + } + let mut line_count = 0_usize; + for line in flow.lines.iter().copied() { + if line.flow_thread_id == constraint.flow_thread_id + && !line_fragments(flow, line)?.is_empty() + { + line_count = line_count + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + } + } + let reserve = line_count + .saturating_add(1) + .saturating_add(if include_glyphs { + positioned_glyphs.len() + } else { + 0 + }); target - .try_reserve(flow.lines.len().saturating_add(1)) + .try_reserve(reserve) .map_err(|_| EngineError::ResultTooLarge)?; let summary_index = target.len(); let line_start = u32::try_from(summary_index.saturating_add(1)).map_err(|_| EngineError::ResultTooLarge)?; + let glyph_record_start = summary_index + .checked_add(1) + .and_then(|value| value.checked_add(line_count)) + .ok_or(EngineError::ResultTooLarge)?; target.push(SemanticRecord::default()); let mut content_width = 0.0_f64; let mut content_height = 0.0_f64; @@ -73,6 +106,19 @@ pub(crate) fn append_measurement( parent_id: paragraph_id, text_start: first.line.text_start, text_end: last.line.text_end, + item_start: if include_glyphs { + u32::try_from(glyph_record_start) + .map_err(|_| EngineError::ResultTooLarge)? + .checked_add(semantic_line_glyph_starts[index]) + .ok_or(EngineError::ResultTooLarge)? + } else { + 0 + }, + item_count: if include_glyphs { + semantic_line_glyph_counts[index] + } else { + 0 + }, block_start: finite_f32(line.block_start + line.baseline)?, inline_extent: finite_nonnegative_f32(advance)?, block_extent: finite_nonnegative_f32(line.height)?, @@ -80,12 +126,36 @@ pub(crate) fn append_measurement( }); } + if include_glyphs { + if target.len() != glyph_record_start { + return Err(EngineError::InvalidRequest); + } + for glyph in positioned_glyphs { + target.push(SemanticRecord { + id: glyph.stable_id, + kind: SEMANTIC_GLYPH, + flags: glyph.flags, + parent_id: paragraph_id, + text_start: glyph.cluster, + text_end: glyph.font_handle, + item_start: u32::from(glyph.glyph_id), + inline_start: glyph.inline_origin, + block_start: glyph.block_origin, + inline_extent: glyph.font_size, + ..SemanticRecord::default() + }); + } + } + let width = resolve_axis(constraint.width_mode, constraint.width, content_width)?; let height = resolve_axis(constraint.height_mode, constraint.height, content_height)?; let overflowed = consumed_clusters < cluster_count || content_width > f64::from(width) || content_height > f64::from(height); - let line_count = target.len().saturating_sub(summary_index + 1); + let missing_glyph_count = positioned_glyphs + .iter() + .filter(|glyph| glyph.glyph_id == 0) + .count(); target[summary_index] = SemanticRecord { id: paragraph_id, kind: SEMANTIC_PARAGRAPH_MEASUREMENT, @@ -94,6 +164,9 @@ pub(crate) fn append_measurement( } else { 0 }, + parent_id: u32::try_from(positioned_glyphs.len()) + .map_err(|_| EngineError::ResultTooLarge)?, + text_start: u32::try_from(missing_glyph_count).map_err(|_| EngineError::ResultTooLarge)?, text_end: u32::try_from(text_length).map_err(|_| EngineError::ResultTooLarge)?, item_start: line_start, item_count: u32::try_from(line_count).map_err(|_| EngineError::ResultTooLarge)?, @@ -183,12 +256,27 @@ mod tests { }], }; let mut records = vec![]; - append_measurement(&mut records, 7, 2, 2, &geometry, &flow).unwrap(); + let positioned = [layout_glyph(3), layout_glyph(0)]; + append_measurement( + &mut records, + 7, + 2, + 2, + &geometry, + &flow, + &positioned, + &[0], + &[2], + false, + ) + .unwrap(); assert_eq!(records.len(), 2); assert_eq!(records[0].kind, SEMANTIC_PARAGRAPH_MEASUREMENT); assert_eq!(records[0].id, 7); assert_eq!(records[0].flags, 0); + assert_eq!(records[0].parent_id, 2); + assert_eq!(records[0].text_start, 1); assert_eq!(records[0].item_start, 1); assert_eq!(records[0].item_count, 1); assert_eq!(records[0].inline_start, 20.0); @@ -199,6 +287,28 @@ mod tests { assert_eq!(records[1].parent_id, 7); assert_eq!(records[1].block_start, 4.0); assert_eq!(records[1].inline_extent, 7.0); + + let mut inspected = vec![]; + append_measurement( + &mut inspected, + 7, + 2, + 2, + &geometry, + &flow, + &positioned, + &[0], + &[2], + true, + ) + .unwrap(); + assert_eq!(inspected.len(), 4); + assert_eq!(inspected[1].item_start, 2); + assert_eq!(inspected[1].item_count, 2); + assert_eq!(inspected[2].kind, SEMANTIC_GLYPH); + assert_eq!(inspected[2].item_start, 3); + assert_eq!(inspected[3].kind, SEMANTIC_GLYPH); + assert_eq!(inspected[3].item_start, 0); } #[test] @@ -234,7 +344,19 @@ mod tests { }], }; let mut records = vec![]; - append_measurement(&mut records, 7, 2, 2, &geometry, &flow).unwrap(); + append_measurement( + &mut records, + 7, + 2, + 2, + &geometry, + &flow, + &[], + &[0], + &[0], + false, + ) + .unwrap(); assert_eq!(records[0].flags, MEASUREMENT_FLAG_OVERFLOWED); assert_eq!(records[0].inline_start, 6.0); @@ -265,4 +387,17 @@ mod tests { block_align: 1, } } + + fn layout_glyph(glyph_id: u16) -> SemanticGlyph { + SemanticGlyph { + stable_id: u32::from(glyph_id) + 1, + font_handle: 1, + glyph_id, + cluster: 0, + flags: 0, + font_size: 16.0, + inline_origin: 0.0, + block_origin: 0.0, + } + } } diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index 4346485c..6514f754 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -16,7 +16,7 @@ use super::{ }; pub(crate) const SEMANTIC_F32_FIELD_COUNT: usize = 6; -pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 5; +pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 6; pub(crate) const ALL_SEMANTIC_CHANGES: u16 = (1 << (SEMANTIC_F32_FIELD_COUNT + SEMANTIC_U32_FIELD_COUNT)) - 1; @@ -34,9 +34,24 @@ const BIDI_RLI: u8 = 20; const BIDI_FSI: u8 = 21; const BIDI_PDI: u8 = 22; +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct SemanticGlyph { + pub stable_id: u32, + pub font_handle: u32, + pub cluster: u32, + pub glyph_id: u16, + pub flags: u16, + pub font_size: f32, + pub inline_origin: f32, + pub block_origin: f32, +} + #[derive(Default)] pub(crate) struct PositionedGlyphArena { glyphs: Vec, + semantic_glyphs: Vec, + semantic_line_glyph_starts: Vec, + semantic_line_glyph_counts: Vec, semantic_change_masks: Vec, semantic_f32: [Vec; SEMANTIC_F32_FIELD_COUNT], semantic_u32: [Vec; SEMANTIC_U32_FIELD_COUNT], @@ -48,6 +63,7 @@ pub(crate) struct PositionedGlyphArena { impl PositionedGlyphArena { pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { reserve(&mut self.glyphs, capacity)?; + reserve(&mut self.semantic_glyphs, capacity)?; reserve(&mut self.semantic_change_masks, capacity)?; for field in &mut self.semantic_f32 { reserve(field, capacity)?; @@ -78,15 +94,21 @@ impl PositionedGlyphArena { ) -> Result<(), EngineError> { self.clear(); self.reserve(shape.glyph_ids.len())?; + reserve(&mut self.semantic_line_glyph_starts, flow.lines.len())?; + reserve(&mut self.semantic_line_glyph_counts, flow.lines.len())?; let visually_ltr = is_trivially_ltr(bidi, runs); for (line_index, line) in flow.lines.iter().copied().enumerate() { + let semantic_line_start = self.semantic_glyphs.len(); let fragments = line_fragments(flow, line)?; - let Some(first) = fragments.first() else { - continue; - }; - let Some(last) = fragments.last() else { + if fragments.is_empty() { + self.semantic_line_glyph_starts.push( + u32::try_from(semantic_line_start).map_err(|_| EngineError::ResultTooLarge)?, + ); + self.semantic_line_glyph_counts.push(0); continue; - }; + } + let first = fragments.first().ok_or(EngineError::InvalidRequest)?; + let last = fragments.last().ok_or(EngineError::InvalidRequest)?; if !visually_ltr { prepare_line_levels( &mut self.line_levels, @@ -115,12 +137,25 @@ impl PositionedGlyphArena { extents_for, )?; } + self.semantic_line_glyph_starts + .push(u32::try_from(semantic_line_start).map_err(|_| EngineError::ResultTooLarge)?); + self.semantic_line_glyph_counts.push( + u32::try_from( + self.semantic_glyphs + .len() + .saturating_sub(semantic_line_start), + ) + .map_err(|_| EngineError::ResultTooLarge)?, + ); } self.assign_content_revisions(previous, identity_index, next_content_revision) } pub(crate) fn clear(&mut self) { self.glyphs.clear(); + self.semantic_glyphs.clear(); + self.semantic_line_glyph_starts.clear(); + self.semantic_line_glyph_counts.clear(); self.semantic_change_masks.clear(); for field in &mut self.semantic_f32 { field.clear(); @@ -137,6 +172,17 @@ impl PositionedGlyphArena { &self.glyphs } + pub(crate) fn semantic_glyphs(&self) -> &[SemanticGlyph] { + &self.semantic_glyphs + } + + pub(crate) fn semantic_line_glyph_spans(&self) -> (&[u32], &[u32]) { + ( + &self.semantic_line_glyph_starts, + &self.semantic_line_glyph_counts, + ) + } + pub(crate) fn semantic_change_masks(&self) -> &[u16] { &self.semantic_change_masks } @@ -283,19 +329,37 @@ impl PositionedGlyphArena { .copied() .ok_or(EngineError::InvalidRequest)?, ) * scale; + let stable_id = *clusters + .glyph_stable_ids + .get(adjacency) + .ok_or(EngineError::InvalidRequest)?; + let flags = *shape + .glyph_flags + .get(shaped) + .ok_or(EngineError::InvalidRequest)?; + let origin_inline = cursor + x_offset; + let origin_block = baseline - y_offset - f64::from(style.baseline_shift); + self.semantic_glyphs.push(SemanticGlyph { + stable_id, + font_handle, + cluster: *shape + .clusters + .get(shaped) + .ok_or(EngineError::InvalidRequest)?, + glyph_id: u16::try_from(glyph_id).map_err(|_| EngineError::ResultTooLarge)?, + flags, + font_size: style.font_size, + inline_origin: finite_f32(origin_inline)?, + block_origin: finite_f32(origin_block)?, + }); if let Some(extents) = extents_for(font_handle, glyph_id) { - let origin_inline = cursor + x_offset; - let origin_block = baseline - y_offset - f64::from(style.baseline_shift); let inline_start = origin_inline + f64::from(extents.x_min) * scale; let block_start = origin_block - f64::from(extents.y_max) * scale; let inline_extent = f64::from(extents.x_max - extents.x_min) * scale; let block_extent = f64::from(extents.y_max - extents.y_min) * scale; self.push_glyph( LayoutGlyph { - stable_id: *clusters - .glyph_stable_ids - .get(adjacency) - .ok_or(EngineError::InvalidRequest)?, + stable_id, content_revision: 0, binding_handle, font_handle, @@ -349,7 +413,14 @@ impl PositionedGlyphArena { for (field, value) in self.semantic_f32.iter_mut().zip(f32_values) { field.push(value); } - let u32_values = [foreground, cluster, region, flow_thread, transform_index]; + let u32_values = [ + foreground, + cluster, + region, + flow_thread, + transform_index, + glyph.stable_id, + ]; for (field, value) in self.semantic_u32.iter_mut().zip(u32_values) { field.push(value); } diff --git a/packages/text/rust/shaper/src/engine/render_plan_wire.rs b/packages/text/rust/shaper/src/engine/render_plan_wire.rs index ddf6f097..773a5d61 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_wire.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_wire.rs @@ -12,9 +12,9 @@ use crate::{ RetirementRecord, }, engine::semantic_view::{ - SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_INSERTED_GLYPH, - SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, SEMANTIC_SELECTION, - SemanticRecord, + SEMANTIC_CARET, SEMANTIC_CLUSTER, SEMANTIC_FRAGMENT, SEMANTIC_GLYPH, + SEMANTIC_INSERTED_GLYPH, SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SEMANTIC_RUN, + SEMANTIC_SELECTION, SemanticRecord, }, }; @@ -213,8 +213,10 @@ fn validate_plan(plan: RenderPlanView<'_>, semantic_views: &[SemanticRecord]) -> | SEMANTIC_SELECTION | SEMANTIC_INSERTED_GLYPH | SEMANTIC_PARAGRAPH_MEASUREMENT + | SEMANTIC_GLYPH ) - || record.text_start > record.text_end + || (!matches!(record.kind, SEMANTIC_PARAGRAPH_MEASUREMENT | SEMANTIC_GLYPH) + && record.text_start > record.text_end) || !finite4( record.inline_start, record.block_start, diff --git a/packages/text/rust/shaper/src/engine/semantic_view.rs b/packages/text/rust/shaper/src/engine/semantic_view.rs index f2eadc99..f88d0483 100644 --- a/packages/text/rust/shaper/src/engine/semantic_view.rs +++ b/packages/text/rust/shaper/src/engine/semantic_view.rs @@ -11,6 +11,7 @@ pub const SEMANTIC_CARET: u16 = 5; pub const SEMANTIC_SELECTION: u16 = 6; pub const SEMANTIC_INSERTED_GLYPH: u16 = 7; pub const SEMANTIC_PARAGRAPH_MEASUREMENT: u16 = 8; +pub const SEMANTIC_GLYPH: u16 = 9; #[repr(C)] #[derive(Clone, Copy, Debug, Default, PartialEq)] diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 20b12297..8470cccf 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -661,7 +661,13 @@ impl TextEngine { ) .map_err(plan_error)?; } - if request.semantic_view_mask & super::frame::SEMANTIC_VIEW_MEASUREMENT != 0 { + let include_layout_inspection = + request.semantic_view_mask & super::frame::SEMANTIC_VIEW_LAYOUT_INSPECTION != 0; + if request.semantic_view_mask + & (super::frame::SEMANTIC_VIEW_MEASUREMENT + | super::frame::SEMANTIC_VIEW_LAYOUT_INSPECTION) + != 0 + { let mut records = core::mem::take(&mut session.semantic_records); let query = (|| { for order_index in 0..session.active_order().len() { @@ -690,6 +696,13 @@ impl TextEngine { } else { &state.flow_layout }; + let positioned = if state.positioned_prepared { + &state.pending_positioned + } else { + &state.positioned + }; + let (line_glyph_starts, line_glyph_counts) = + positioned.semantic_line_glyph_spans(); super::layout_query::append_measurement( &mut records, paragraph_id, @@ -697,6 +710,10 @@ impl TextEngine { clusters.starts.len(), geometry, flow, + positioned.semantic_glyphs(), + line_glyph_starts, + line_glyph_counts, + include_layout_inspection, )?; } Ok(()) diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index c5b50d31..66c8b982 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -146,6 +146,7 @@ export const textShaperAbi = { "caret": 5, "cluster": 4, "fragment": 2, + "glyph": 9, "insertedGlyph": 7, "line": 1, "paragraphMeasurement": 8, @@ -157,10 +158,12 @@ export const textShaperAbi = { "flowThreadId": 3, "foregroundRgba": 0, "regionId": 2, + "stableGlyphId": 5, "transformIndex": 4 }, "semanticViewMasks": { - "all": 1, + "all": 3, + "layoutInspection": 2, "measurement": 1 }, "styleFields": { diff --git a/packages/text/src/index.ts b/packages/text/src/index.ts index 46183ba8..c814640b 100644 --- a/packages/text/src/index.ts +++ b/packages/text/src/index.ts @@ -36,7 +36,13 @@ export { defineFont } from './font.js'; export type { FontHandle, FontKey, FontSlot, LocalGlyphId, RasterHandle, RasterKey, Sha256Hex } from './identity.js'; -export type { FontSlotRecord, ParagraphLayout, ParagraphMeasurement } from './layout.js'; +export type { + FontSlotRecord, + ParagraphLayout, + ParagraphLayoutInspection, + ParagraphLayoutSummary, + ParagraphMeasurement, +} from './layout.js'; export type { FontLoadDiagnostic, diff --git a/packages/text/src/internal/layout-query-view.ts b/packages/text/src/internal/layout-query-view.ts index 459a8f1b..181a2b77 100644 --- a/packages/text/src/internal/layout-query-view.ts +++ b/packages/text/src/internal/layout-query-view.ts @@ -1,16 +1,16 @@ import { textShaperAbi } from '../generated/text-shaper-abi.js'; -import type { ParagraphMeasurement } from '../layout.js'; +import type { ParagraphLayoutInspection, ParagraphLayoutSummary } from '../layout.js'; import type { TextEnginePublication } from './text-engine-host.js'; /** Reads an explicitly requested semantic sidecar. Rendering never calls this reader. */ export function readTextEngineMeasurements( publication: TextEnginePublication, -): ReadonlyMap { +): ReadonlyMap { const view = new SemanticViewReader(publication); const table = view.table(); const recordLayout = textShaperAbi.layouts.engineSemanticView; const kinds = textShaperAbi.engine.semanticKinds; - const measurements = new Map(); + const measurements = new Map(); for (let index = 0; index < table.count; index += 1) { const record = view.record(table, index); if (view.u16(record + recordLayout.kind) !== kinds.paragraphMeasurement) continue; @@ -41,12 +41,133 @@ export function readTextEngineMeasurements( firstBaseline, lastBaseline, overflowed: (view.u16(record + recordLayout.flags) & textShaperAbi.engine.measurementFlags.overflowed) !== 0, + glyphCount: view.u32(record + recordLayout.parentId), + lineCount, + missingGlyphCount: view.u32(record + recordLayout.textStart), }), ); } return measurements; } +/** Copies one explicitly requested retained layout out of borrowed Wasm publication memory. */ +export function readTextEngineLayouts( + publication: TextEnginePublication, +): ReadonlyMap { + const view = new SemanticViewReader(publication); + const table = view.table(); + const recordLayout = textShaperAbi.layouts.engineSemanticView; + const kinds = textShaperAbi.engine.semanticKinds; + const measurements = readTextEngineMeasurements(publication); + const layouts = new Map(); + for (let index = 0; index < table.count; index += 1) { + const summary = view.record(table, index); + if (view.u16(summary + recordLayout.kind) !== kinds.paragraphMeasurement) continue; + const paragraphId = view.u32(summary + recordLayout.id); + const measurement = measurements.get(paragraphId); + if (measurement === undefined) throw new TypeError('layout inspection has no paragraph measurement'); + const lineStart = view.u32(summary + recordLayout.itemStart); + const lineCount = view.u32(summary + recordLayout.itemCount); + const glyphStart = checkedAdd(lineStart, lineCount, 'layout inspection glyph start'); + const glyphCount = measurement.glyphCount; + if (checkedAdd(glyphStart, glyphCount, 'layout inspection glyph end') > table.count) { + throw new RangeError('layout inspection glyph span is outside the query'); + } + + const fontHandles: number[] = []; + const fontSlots = new Map(); + const glyphStableIds = new Uint32Array(glyphCount); + const glyphFontSlots = new Uint16Array(glyphCount); + const glyphIds = new Uint16Array(glyphCount); + const clusters = new Uint32Array(glyphCount); + const glyphFontSizes = new Float32Array(glyphCount); + const x = new Float32Array(glyphCount); + const y = new Float32Array(glyphCount); + const glyphFlags = new Uint16Array(glyphCount); + for (let glyphIndex = 0; glyphIndex < glyphCount; glyphIndex += 1) { + const glyph = view.record(table, glyphStart + glyphIndex); + if ( + view.u16(glyph + recordLayout.kind) !== kinds.glyph || + view.u32(glyph + recordLayout.parentId) !== paragraphId + ) { + throw new TypeError('layout inspection references a foreign semantic glyph'); + } + const fontHandle = view.u32(glyph + recordLayout.textEnd); + let fontSlot = fontSlots.get(fontHandle); + if (fontSlot === undefined) { + fontSlot = fontHandles.length; + if (fontSlot > 0xffff) throw new RangeError('layout inspection exceeds the font-slot range'); + fontSlots.set(fontHandle, fontSlot); + fontHandles.push(fontHandle); + } + glyphStableIds[glyphIndex] = view.u32(glyph + recordLayout.id); + glyphFontSlots[glyphIndex] = fontSlot; + glyphIds[glyphIndex] = view.u32(glyph + recordLayout.itemStart); + clusters[glyphIndex] = view.u32(glyph + recordLayout.textStart); + glyphFontSizes[glyphIndex] = view.f32(glyph + recordLayout.inlineExtent); + x[glyphIndex] = view.f32(glyph + recordLayout.inlineStart); + y[glyphIndex] = view.f32(glyph + recordLayout.blockStart); + glyphFlags[glyphIndex] = view.u16(glyph + recordLayout.flags); + } + + const lineTextStarts = new Uint32Array(lineCount); + const lineTextEnds = new Uint32Array(lineCount); + const lineGlyphStarts = new Uint32Array(lineCount); + const lineGlyphCounts = new Uint32Array(lineCount); + const lineBaselines = new Float32Array(lineCount); + const lineAdvances = new Float32Array(lineCount); + for (let lineIndex = 0; lineIndex < lineCount; lineIndex += 1) { + const line = view.record(table, lineStart + lineIndex); + if (view.u16(line + recordLayout.kind) !== kinds.line || view.u32(line + recordLayout.parentId) !== paragraphId) { + throw new TypeError('layout inspection references a foreign semantic line'); + } + const absoluteGlyphStart = view.u32(line + recordLayout.itemStart); + const count = view.u32(line + recordLayout.itemCount); + if ( + absoluteGlyphStart < glyphStart || + checkedAdd(absoluteGlyphStart, count, 'line glyph end') > glyphStart + glyphCount + ) { + throw new RangeError('layout inspection line glyph span is outside its paragraph'); + } + lineTextStarts[lineIndex] = view.u32(line + recordLayout.textStart); + lineTextEnds[lineIndex] = view.u32(line + recordLayout.textEnd); + lineGlyphStarts[lineIndex] = absoluteGlyphStart - glyphStart; + lineGlyphCounts[lineIndex] = count; + lineBaselines[lineIndex] = view.f32(line + recordLayout.blockStart); + lineAdvances[lineIndex] = view.f32(line + recordLayout.inlineExtent); + } + + layouts.set( + paragraphId, + Object.freeze({ + ...measurement, + fontHandles: Uint32Array.from(fontHandles), + glyphStableIds, + glyphFontSlots, + glyphIds, + clusters, + glyphFontSizes, + x, + y, + glyphFlags, + lineTextStarts, + lineTextEnds, + lineGlyphStarts, + lineGlyphCounts, + lineBaselines, + lineAdvances, + }), + ); + } + return layouts; +} + +function checkedAdd(left: number, right: number, label: string): number { + const value = left + right; + if (!Number.isSafeInteger(value) || value < 0) throw new RangeError(`${label} overflows`); + return value; +} + interface SemanticViewTable { readonly offset: number; readonly count: number; diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index c56c0092..df9cfa06 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -3,6 +3,7 @@ import { textShaperAbi } from '../generated/text-shaper-abi.js'; const MAX_U32 = 0xffff_ffff; const encoder = new TextEncoder(); export const FIRST_PARTY_TRANSFORM_BUFFER_ID = 15; +export const FIRST_PARTY_STABLE_GLYPH_BUFFER_ID = 14; export type PolicyInputScope = keyof typeof textShaperAbi.policy.inputScopes; @@ -160,6 +161,7 @@ function bitmapProgram(techniqueId: number, programId: number, transformMode: Th const { loadF32, loadU32, binary, storeF32, storeU32 } = context; loadF32(15); loadU32(31, 0); + loadU32(30, 1); binary('multiplyF32', 15, 7, 2); binary('addF32', 16, 0, 15); binary('multiplyF32', 17, 8, 2); @@ -174,13 +176,14 @@ function bitmapProgram(techniqueId: number, programId: number, transformMode: Th [5, [3, 4, 5, 6]], ]); if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + storeU32(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, 0, 30); return createProgram( techniqueId, programId, context, transformMode === 'indexed' - ? [...floatBuffers([2, 2, 2, 2, 4]), transformIndexBuffer()] - : floatBuffers([2, 2, 2, 2, 4]), + ? [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer(), transformIndexBuffer()] + : [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer()], transformMode, ); } @@ -189,8 +192,9 @@ function msdfProgram(techniqueId: number, programId: number, transformMode: Thre const context = programContext('glyph', 10, 1); const { operations, loadF32, loadU32, binary, constantF32, storeF32, storeU32 } = context; loadF32(17); - loadU32(17, 1); + loadU32(17, 2); loadU32(31, 0); + loadU32(30, 1); binary('multiplyF32', 18, 7, 2); binary('addF32', 19, 0, 18); binary('multiplyF32', 20, 8, 2); @@ -209,11 +213,16 @@ function msdfProgram(techniqueId: number, programId: number, transformMode: Thre [7, [25, 25, 25, 24]], ]); if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + storeU32(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, 0, 30); return createProgram( techniqueId, programId, context, - [...floatBuffers([4, 4, 4, 4, 4, 4, 4]), ...(transformMode === 'indexed' ? [transformIndexBuffer()] : [])], + [ + ...floatBuffers([4, 4, 4, 4, 4, 4, 4]), + stableGlyphIdBuffer(), + ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), + ], transformMode, ); } @@ -223,7 +232,8 @@ function slugProgram(techniqueId: number, programId: number, transformMode: Thre const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; loadF32(16); loadU32(31, 0); - for (let field = 0; field < 6; field += 1) loadU32(21 + field, field + 1); + loadU32(30, 1); + for (let field = 0; field < 6; field += 1) loadU32(21 + field, field + 2); binary('multiplyF32', 16, 8, 2); binary('addF32', 17, 0, 16); binary('multiplyF32', 18, 9, 2); @@ -244,6 +254,7 @@ function slugProgram(techniqueId: number, programId: number, transformMode: Thre [7, [25, 26, 29, 29]], ]); if (transformMode === 'indexed') storeU32(FIRST_PARTY_TRANSFORM_BUFFER_ID, 0, 31); + storeU32(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, 0, 30); return createProgram( techniqueId, programId, @@ -251,6 +262,7 @@ function slugProgram(techniqueId: number, programId: number, transformMode: Thre [ ...floatBuffers([4, 4, 4, 4, 4]), ...u32Buffers([4, 4], 6), + stableGlyphIdBuffer(), ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), ], transformMode, @@ -296,13 +308,14 @@ function programContext( ...(inverseFontSize ? [{ scope: 'semantic' as const, field: semantic.inverseFontSize }] : []), ...Array.from({ length: bindingF32Count }, (_, field) => ({ scope: bindingScope, field })), { scope: 'semantic', field: semanticU32.transformIndex }, + { scope: 'semantic', field: semanticU32.stableGlyphId }, ...Array.from({ length: bindingU32Count }, (_, field) => ({ scope: bindingScope, field })), ]; return { inputs, operations, f32InputCount: 7 + (inverseFontSize ? 1 : 0) + bindingF32Count, - u32InputCount: bindingU32Count + 1, + u32InputCount: bindingU32Count + 2, loadF32(count) { for (let field = 0; field < count; field += 1) { operations.push({ opcode: textShaperAbi.policy.opcodes.loadF32, target: field, operand0: field }); @@ -401,6 +414,14 @@ function transformIndexBuffer(): PolicyBuffer { }; } +function stableGlyphIdBuffer(): PolicyBuffer { + return { + id: FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, + scalar: textShaperAbi.policy.scalarTypes.u32, + vectorWidth: 1, + }; +} + export function compileRenderPolicy(descriptor: PolicyDescriptor): Uint8Array { const request = textShaperAbi.layouts.policyRequest; const capability = textShaperAbi.layouts.policyCapabilitySet; diff --git a/packages/text/src/layout.ts b/packages/text/src/layout.ts index b05b16fc..9b7997db 100644 --- a/packages/text/src/layout.ts +++ b/packages/text/src/layout.ts @@ -20,6 +20,18 @@ export interface ParagraphMeasurement { readonly overflowed: boolean; } +/** + * Bounded aggregate inspection of one retained layout. Unlike `ParagraphLayout`, this contains no per-glyph arrays and + * is suitable for positioning UI, telemetry, and missing-glyph admission checks. + */ +export interface ParagraphLayoutSummary extends ParagraphMeasurement { + /** Positioned glyphs retained by layout, including non-rendering glyphs such as spaces. */ + readonly glyphCount: number; + readonly lineCount: number; + /** Positioned `.notdef` glyphs (`glyphId === 0`). */ + readonly missingGlyphCount: number; +} + /** * Positioned glyph output in paragraph-local coordinates. The origin is the * paragraph box's top-left corner; positive X is right and positive Y is down. @@ -42,6 +54,11 @@ export interface ParagraphLayout extends ParagraphMeasurement { readonly lineAdvances: Float32Array; } +/** Explicit demand-shaped inspection of retained Rust layout, including stable identities for directed augmentation. */ +export interface ParagraphLayoutInspection extends ParagraphLayout, ParagraphLayoutSummary { + readonly glyphStableIds: Uint32Array; +} + export interface FontSlotRecord { readonly slot: number; readonly font: FontHandle; diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 3011cb77..3d9be5d5 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -48,4 +48,12 @@ export type { ThreeSlugShaderResources, } from './three/slug-shader.js'; export { Text, TextGroup } from './three/text.js'; -export type { StandaloneTextProperties, TextGroupOptions, TextProperties, TextSpan, TextUpdate } from './three/text.js'; +export type { + StandaloneTextProperties, + TextGlyphOriginSnapshot, + TextGlyphOriginUpdate, + TextGroupOptions, + TextProperties, + TextSpan, + TextUpdate, +} from './three/text.js'; diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 3e81eea2..c3aa69f1 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -3,7 +3,7 @@ import * as THREE from 'three/webgpu'; import { textShaperAbi } from '../generated/text-shaper-abi.js'; import type { TextEnginePublication } from '../internal/text-engine-host.js'; -import { FIRST_PARTY_TRANSFORM_BUFFER_ID } from '../internal/render-policy-wire.js'; +import { FIRST_PARTY_STABLE_GLYPH_BUFFER_ID, FIRST_PARTY_TRANSFORM_BUFFER_ID } from '../internal/render-policy-wire.js'; import { TextEngineRenderPlanView, type RenderPlanTable } from '../internal/render-plan-view.js'; import { bitmap, type BitmapPageData } from '../raster/bitmap-technique.js'; import { msdf, type MsdfData } from '../raster/msdf.js'; @@ -48,6 +48,20 @@ interface MaterialRealization { readonly buffers: readonly Readonly<{ id: number; generation: number }>[]; } +interface OriginSegment { + readonly origins: RetainedBuffer; + readonly stableIds: RetainedBuffer; + readonly start: number; + readonly count: number; +} + +interface OriginRecord { + readonly buffer: RetainedBuffer; + readonly index: number; + targetX: number; + targetY: number; +} + type TransformRealization = | Readonly<{ kind: 'direct'; transformId: number }> | Readonly<{ kind: 'indexed'; indices: RetainedBuffer }>; @@ -71,12 +85,14 @@ export class ThreeTextRenderPlanExecutor { readonly #materials = new Map(); readonly #ownedMaterials = new WeakSet(); readonly #activeTransformIndices = new Set(); + readonly #originRecords = new Map(); readonly #rootInverse = new THREE.Matrix4(); readonly #relativeTransform = new THREE.Matrix4(); #transformAttribute = transformAttribute(1); #transformGeneration = 1; #draws: THREE.Mesh[] = []; #drawKeys: string[] = []; + #originSegments: OriginSegment[] = []; #disposed = false; constructor(coordinator: ThreeTextEngineCoordinator, owner: ThreeTextEnginePlanOwner) { @@ -106,6 +122,7 @@ export class ThreeTextRenderPlanExecutor { apply(publication: TextEnginePublication): void { if (this.#disposed) throw new Error('Three text-engine plan target has been disposed'); + this.#restoreOriginTargets(); const plan = this.#view.bind(publication); const resources = plan.table('resources'); const buffers = plan.table('buffers'); @@ -127,6 +144,68 @@ export class ThreeTextRenderPlanExecutor { } this.syncTransforms(); this.#applyRetirements(plan, retirements); + this.#captureOriginTargets(); + } + + snapshotGlyphOrigins( + stableIds: Uint32Array, + fallbackX: Float32Array, + fallbackY: Float32Array, + ): Readonly<{ shapedX: Float32Array; shapedY: Float32Array; displayedX: Float32Array; displayedY: Float32Array }> { + if (stableIds.length !== fallbackX.length || stableIds.length !== fallbackY.length) { + throw new RangeError('glyph origin snapshot arrays must be parallel'); + } + const shapedX = fallbackX.slice(); + const shapedY = fallbackY.slice(); + const displayedX = fallbackX.slice(); + const displayedY = fallbackY.slice(); + for (let index = 0; index < stableIds.length; index += 1) { + const record = this.#originRecords.get(stableIds[index]!); + if (record === undefined || !(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + shapedX[index] = record.targetX; + shapedY[index] = record.targetY; + displayedX[index] = record.buffer.array[offset]!; + displayedY[index] = record.buffer.array[offset + 1]!; + } + return { shapedX, shapedY, displayedX, displayedY }; + } + + setGlyphOriginOverrides(stableIds: Uint32Array, x: Float32Array, y: Float32Array): void { + if (stableIds.length !== x.length || stableIds.length !== y.length) { + throw new RangeError('glyph origin override arrays must be parallel'); + } + const touched = new Map(); + for (let index = 0; index < stableIds.length; index += 1) { + const record = this.#originRecords.get(stableIds[index]!); + if (record === undefined || !(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + record.buffer.array[offset] = x[index]!; + record.buffer.array[offset + 1] = y[index]!; + const range = touched.get(record.buffer); + touched.set(record.buffer, [ + Math.min(range?.[0] ?? offset, offset), + Math.max(range?.[1] ?? offset + 2, offset + 2), + ]); + } + markOriginRanges(touched); + } + + clearGlyphOriginOverrides(stableIds: Uint32Array): void { + const touched = new Map(); + for (const stableId of stableIds) { + const record = this.#originRecords.get(stableId); + if (record === undefined || !(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + record.buffer.array[offset] = record.targetX; + record.buffer.array[offset + 1] = record.targetY; + const range = touched.get(record.buffer); + touched.set(record.buffer, [ + Math.min(range?.[0] ?? offset, offset), + Math.max(range?.[1] ?? offset + 2, offset + 2), + ]); + } + markOriginRanges(touched); } /** Upload changed scene transforms without crossing into Wasm or invalidating text layout. */ @@ -198,6 +277,8 @@ export class ThreeTextRenderPlanExecutor { this.#buffers.clear(); this.#resources.clear(); this.#activeTransformIndices.clear(); + this.#originRecords.clear(); + this.#originSegments = []; } #readResources(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { @@ -306,6 +387,7 @@ export class ThreeTextRenderPlanExecutor { const resourceLayout = textShaperAbi.layouts.engineResource; const next: THREE.Mesh[] = []; const nextKeys: string[] = []; + const nextOriginSegments: OriginSegment[] = []; const previous = new Map(); for (let index = 0; index < this.#draws.length; index += 1) { const key = this.#drawKeys[index]!; @@ -344,6 +426,14 @@ export class ThreeTextRenderPlanExecutor { const material = this.#material(resource, byPolicyId, materialId, transform); const recordIndex = plan.u32(primitive + primitiveLayout.recordIndex); const recordCount = plan.u16(primitive + primitiveLayout.recordCount); + const origins = byPolicyId.get(1); + const stableIds = byPolicyId.get(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID); + if (origins !== undefined && stableIds !== undefined) { + if (!(origins.array instanceof Float32Array) || !(stableIds.array instanceof Uint32Array)) { + throw new TypeError('glyph-origin augmentation buffers have invalid scalar types'); + } + nextOriginSegments.push({ origins, stableIds, start: recordIndex, count: recordCount }); + } const key = drawRealizationKey( plan.u32(draw + drawLayout.programId), resource, @@ -397,10 +487,50 @@ export class ThreeTextRenderPlanExecutor { this.#disposeDraws(reused); this.#draws = next; this.#drawKeys = nextKeys; + this.#originSegments = nextOriginSegments; this.#activeTransformIndices.clear(); for (const transformIndex of transformIndices) this.#activeTransformIndices.add(transformIndex); } + #restoreOriginTargets(): void { + const touched = new Map(); + for (const record of this.#originRecords.values()) { + if (!(record.buffer.array instanceof Float32Array)) continue; + const offset = record.index * record.buffer.vectorWidth; + if (record.buffer.array[offset] === record.targetX && record.buffer.array[offset + 1] === record.targetY) + continue; + record.buffer.array[offset] = record.targetX; + record.buffer.array[offset + 1] = record.targetY; + const range = touched.get(record.buffer); + touched.set(record.buffer, [ + Math.min(range?.[0] ?? offset, offset), + Math.max(range?.[1] ?? offset + 2, offset + 2), + ]); + } + markOriginRanges(touched); + } + + #captureOriginTargets(): void { + this.#originRecords.clear(); + for (const segment of this.#originSegments) { + if (!(segment.origins.array instanceof Float32Array) || !(segment.stableIds.array instanceof Uint32Array)) + continue; + for (let index = segment.start; index < segment.start + segment.count; index += 1) { + const stableId = segment.stableIds.array[index]; + if (stableId === undefined || stableId === 0) + throw new Error('origin augmentation references an invalid glyph'); + const offset = index * segment.origins.vectorWidth; + if (this.#originRecords.has(stableId)) throw new Error('origin augmentation repeats a stable glyph identity'); + this.#originRecords.set(stableId, { + buffer: segment.origins, + index, + targetX: segment.origins.array[offset]!, + targetY: segment.origins.array[offset + 1]!, + }); + } + } + } + #transformRealization(buffers: ReadonlyMap, transformId: number): TransformRealization { if (transformId !== 0) return { kind: 'direct', transformId }; const indices = buffers.get(FIRST_PARTY_TRANSFORM_BUFFER_ID); @@ -1105,6 +1235,12 @@ function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: num invalidatePboTexture(buffer.attribute); } +function markOriginRanges(ranges: ReadonlyMap): void { + for (const [buffer, [start, end]] of ranges) { + markUpdated(buffer, start * buffer.array.BYTES_PER_ELEMENT, (end - start) * buffer.array.BYTES_PER_ELEMENT, true); + } +} + function unitQuad(): THREE.InstancedBufferGeometry { const geometry = new THREE.InstancedBufferGeometry(); geometry.setAttribute( diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index ba0163d7..63801263 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -28,8 +28,8 @@ import { type TextEngineTextMutation, } from '../internal/engine-frame-wire.js'; import type { TextEnginePublication, TextEngineSession } from '../internal/text-engine-host.js'; -import { readTextEngineMeasurements } from '../internal/layout-query-view.js'; -import type { ParagraphMeasurement } from '../layout.js'; +import { readTextEngineLayouts, readTextEngineMeasurements } from '../internal/layout-query-view.js'; +import type { ParagraphLayoutInspection, ParagraphLayoutSummary } from '../layout.js'; import { textShaperAbi } from '../generated/text-shaper-abi.js'; import { ThreeTextRenderPlanExecutor } from './engine-plan-target.js'; import { @@ -71,6 +71,20 @@ export interface TextGroupOptions { readonly material?: ThreeTextMaterial; } +export interface TextGlyphOriginSnapshot { + readonly layout: ParagraphLayoutInspection; + readonly shapedX: Float32Array; + readonly shapedY: Float32Array; + readonly displayedX: Float32Array; + readonly displayedY: Float32Array; +} + +export interface TextGlyphOriginUpdate { + readonly layout: ParagraphLayoutInspection; + readonly x: Float32Array; + readonly y: Float32Array; +} + interface DesiredTextState { readonly font: FontSelection; readonly text: string; @@ -207,10 +221,28 @@ export class Text extends THREE.Object3D { this.#binding?.retry(); } /** Performs one explicit Rust query when this committed layout has not already been measured. */ - measureLayout(): ParagraphMeasurement | undefined { + measureLayout(): ParagraphLayoutSummary | undefined { this.#assertActive(); return this.#binding?.measurement(eraseTextTechnique(this)); } + /** Copies the committed per-line and per-glyph Rust layout only when explicitly requested. */ + inspectLayout(): ParagraphLayoutInspection | undefined { + this.#assertActive(); + return this.#binding?.layoutInspection(eraseTextTechnique(this)); + } + snapshotGlyphOrigins(): TextGlyphOriginSnapshot | undefined { + this.#assertActive(); + return this.#binding?.glyphOriginSnapshot(eraseTextTechnique(this)); + } + setGlyphOrigins(update: TextGlyphOriginUpdate): void { + this.#assertActive(); + if (this.#binding === undefined) throw new Error('glyph origins require a bound Text'); + this.#binding.setGlyphOrigins(eraseTextTechnique(this), update); + } + clearGlyphOriginOverrides(): void { + this.#assertActive(); + this.#binding?.clearGlyphOrigins(eraseTextTechnique(this)); + } override updateMatrixWorld(force?: boolean): void { if (this.#disposed) { @@ -414,7 +446,8 @@ class ThreeTextBatchBinding { readonly #paragraphs = new Map, RetainedEngineParagraph>(); readonly #textsByParagraph = new Map>(); readonly #removed: RetainedEngineParagraph[] = []; - readonly #measurements = new Map, ParagraphMeasurement>(); + readonly #measurements = new Map, ParagraphLayoutSummary>(); + readonly #layoutInspections = new Map, ParagraphLayoutInspection>(); #nextParagraphId = 1; #engineRevision = 0; #planRevision = 0; @@ -465,26 +498,12 @@ class ThreeTextBatchBinding { get renderOrderBase(): number { return this.#group?.renderOrder ?? 0; } - measurement(text: Text): ParagraphMeasurement | undefined { + measurement(text: Text): ParagraphLayoutSummary | undefined { if (!this.#paragraphs.has(text)) return undefined; this.synchronize(); const cached = this.#measurements.get(text); if (cached !== undefined) return cached; - const totalTextLength = [...this.#paragraphs.keys()].reduce((total, entry) => total + entry.text.length, 0); - const publication = this.#session.update( - compileTextEngineFrameUpdate({ - sessionId: this.#session.handle, - policyHandle: this.#coordinator.policyHandle, - capabilitySet: 1, - expectedEngineRevision: this.#engineRevision, - consumedPlanRevision: this.#planRevision, - acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, - semanticViewMask: textShaperAbi.engine.semanticViewMasks.measurement, - limits: engineLimits(this.#paragraphs.size, totalTextLength, this.#paragraphs.size, this.#resultCapacity), - }), - ); - this.#engineRevision = publication.engineRevision; - this.#planRevision = publication.planRevision; + const publication = this.#querySemanticViews(textShaperAbi.engine.semanticViewMasks.measurement); const measurements = readTextEngineMeasurements(publication); this.#acknowledgedPublicationGeneration = publication.publicationGeneration; for (const [paragraphId, measurement] of measurements) { @@ -494,6 +513,41 @@ class ThreeTextBatchBinding { } return this.#measurements.get(text); } + layoutInspection(text: Text): ParagraphLayoutInspection | undefined { + if (!this.#paragraphs.has(text)) return undefined; + this.synchronize(); + const cached = this.#layoutInspections.get(text); + if (cached !== undefined) return cached; + const publication = this.#querySemanticViews(textShaperAbi.engine.semanticViewMasks.layoutInspection); + const layouts = readTextEngineLayouts(publication); + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + for (const [paragraphId, layout] of layouts) { + const inspectedText = this.#textsByParagraph.get(paragraphId); + if (inspectedText === undefined) throw new Error(`text engine inspected unknown paragraph ${paragraphId}`); + this.#measurements.set(inspectedText, layout); + this.#layoutInspections.set(inspectedText, layout); + } + return this.#layoutInspections.get(text); + } + glyphOriginSnapshot(text: Text): TextGlyphOriginSnapshot | undefined { + const layout = this.layoutInspection(text); + if (layout === undefined) return undefined; + return { layout, ...this.#target.snapshotGlyphOrigins(layout.glyphStableIds, layout.x, layout.y) }; + } + setGlyphOrigins(text: Text, update: TextGlyphOriginUpdate): void { + const layout = this.layoutInspection(text); + if (layout === undefined || update.layout !== layout) { + throw new TypeError('glyph origins do not match the committed layout inspection'); + } + if (update.x.length !== layout.glyphStableIds.length || update.y.length !== layout.glyphStableIds.length) { + throw new RangeError('glyph origin arrays do not match the inspected glyph count'); + } + this.#target.setGlyphOriginOverrides(layout.glyphStableIds, update.x, update.y); + } + clearGlyphOrigins(text: Text): void { + const layout = this.#layoutInspections.get(text); + if (layout !== undefined) this.#target.clearGlyphOriginOverrides(layout.glyphStableIds); + } reconcile(texts: readonly Text[]): void { const desired = new Set(texts); for (const text of [...this.#paragraphs.keys()]) if (!desired.has(text)) this.removeText(text); @@ -607,6 +661,7 @@ class ThreeTextBatchBinding { } this.#materialInvalidated = false; this.#measurements.clear(); + this.#layoutInspections.clear(); committed = true; try { this.#target.apply(publication); @@ -661,6 +716,7 @@ class ThreeTextBatchBinding { this.#paragraphs.clear(); this.#textsByParagraph.clear(); this.#measurements.clear(); + this.#layoutInspections.clear(); this.#removed.length = 0; } #ensureText(text: Text, group: TextGroup | undefined): void { @@ -695,6 +751,25 @@ class ThreeTextBatchBinding { if (this.#group !== undefined) return this.#group.renderOrder; return this.#paragraphs.keys().next().value?.renderOrder ?? 0; } + + #querySemanticViews(semanticViewMask: number): TextEnginePublication { + const totalTextLength = [...this.#paragraphs.keys()].reduce((total, entry) => total + entry.text.length, 0); + const publication = this.#session.update( + compileTextEngineFrameUpdate({ + sessionId: this.#session.handle, + policyHandle: this.#coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: this.#engineRevision, + consumedPlanRevision: this.#planRevision, + acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + semanticViewMask, + limits: engineLimits(this.#paragraphs.size, totalTextLength, this.#paragraphs.size, this.#resultCapacity), + }), + ); + this.#engineRevision = publication.engineRevision; + this.#planRevision = publication.planRevision; + return publication; + } } function compileEngineStyles( diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 6628af8f..2ea78c8b 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -99,6 +99,7 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as flowThreadId: 3, foregroundRgba: 0, regionId: 2, + stableGlyphId: 5, transformIndex: 4, }); assert.equal(fn.createSession(sessionId, requestLayout.size, resultLayout.size, 0), abi.status.ok); diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index caf2f6a1..9a83d84d 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -47,10 +47,32 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.ok(measurement.firstBaseline > 0); assert.equal(measurement.firstBaseline, measurement.lastBaseline); assert.equal(measurement.overflowed, false); + assert.equal(measurement.glyphCount, 11, 'layout summary retains the non-rendering space glyph'); + assert.equal(measurement.lineCount, 1); + assert.equal(measurement.missingGlyphCount, 0); assert.equal(label.measureLayout(), measurement, 'an unchanged committed layout must reuse its queried measurement'); + const inspection = label.inspectLayout(); + assert.ok(inspection, 'per-glyph layout must be available only through an explicit Rust inspection query'); + assert.equal(inspection.glyphIds.length, measurement.glyphCount); + assert.equal(inspection.glyphStableIds.length, inspection.glyphIds.length); + assert.equal(inspection.lineGlyphCounts.length, measurement.lineCount); + assert.equal(label.inspectLayout(), inspection, 'an unchanged committed layout must reuse its copied inspection'); assert.equal(label.layout, undefined, 'query data must not restore layout arrays to rendering'); assert.equal(group.children.filter((child) => child.isMesh)[0], firstDraws[0]); + const origins = label.snapshotGlyphOrigins(); + assert.equal(origins.layout, inspection); + assert.deepEqual(origins.displayedX, origins.shapedX); + assert.deepEqual(origins.displayedY, origins.shapedY); + const presentedX = origins.shapedX.slice(); + presentedX[0] += 3; + label.setGlyphOrigins({ layout: inspection, x: presentedX, y: origins.shapedY }); + const presented = label.snapshotGlyphOrigins(); + assert.equal(presented.shapedX[0], origins.shapedX[0], 'presentation must not mutate authoritative layout'); + assert.equal(presented.displayedX[0], origins.shapedX[0] + 3); + label.clearGlyphOriginOverrides(); + assert.deepEqual(label.snapshotGlyphOrigins().displayedX, origins.shapedX); + group.renderOrder = 20; scene.updateMatrixWorld(); assert.equal(firstDraws[0].renderOrder, 20, 'group render order must update existing draw proxies'); @@ -72,6 +94,7 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr 'compatible revisions must retain draws and resize live counts', ); assert.notEqual(label.measureLayout(), measurement, 'a semantic update must invalidate the measurement cache'); + assert.notEqual(label.inspectLayout(), inspection, 'a semantic update must invalidate the inspection cache'); scene.add(label); scene.updateMatrixWorld(); @@ -129,12 +152,37 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn assert.equal(transforms.array[1 * 16 + 12], 2); assert.equal(transforms.array[2 * 16 + 12], 5); + const leftOrigins = left.snapshotGlyphOrigins(); + const rightOrigins = right.snapshotGlyphOrigins(); + assert.equal(leftOrigins.shapedX.length, 2); + assert.equal(rightOrigins.shapedX.length, 2); + const shiftedRightX = rightOrigins.shapedX.slice(); + shiftedRightX[0] += 4; + right.setGlyphOrigins({ layout: rightOrigins.layout, x: shiftedRightX, y: rightOrigins.shapedY }); + assert.equal(left.snapshotGlyphOrigins().displayedX[0], leftOrigins.shapedX[0]); + assert.equal(right.snapshotGlyphOrigins().displayedX[0], rightOrigins.shapedX[0] + 4); + const version = transforms.version; right.position.x = 7; scene.updateMatrixWorld(); assert.equal(group.children.filter((child) => child.isMesh)[0], draws[0]); assert.equal(transforms.version, version + 1); assert.equal(transforms.array[2 * 16 + 12], 7); + assert.equal( + right.snapshotGlyphOrigins().displayedX[0], + rightOrigins.shapedX[0] + 4, + 'transform-only updates must not cross into Rust or discard presentation overrides', + ); + + right.style = { ...right.style, fontSize: 20 }; + scene.updateMatrixWorld(); + const resizedOrigins = right.snapshotGlyphOrigins(); + assert.notEqual(resizedOrigins.layout, rightOrigins.layout); + assert.deepEqual( + resizedOrigins.displayedX, + resizedOrigins.shapedX, + 'an authoritative command-buffer update must retire the previous presentation override', + ); group.dispose(); left.dispose(); From 457a220c2029b8c9e0782feb2c2f308732e272a7 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 04:41:26 -0400 Subject: [PATCH 080/128] refactor(three): remove retired batch targets --- apps/benchmarks/scripts/verify-v1-bitmap.mts | 10 +- .../src/generated/package-sizes.json | 30 +- apps/benchmarks/src/v1-compose-proof.ts | 199 +------- docs/log.md | 9 + docs/packages/benchmarks.md | 10 +- docs/packages/text.md | 40 +- docs/planning/decision-register.md | 1 + docs/planning/three-material-authority.md | 22 +- packages/text/package.json | 6 +- packages/text/src/three.ts | 6 - packages/text/src/three/bitmap-shader.ts | 2 +- packages/text/src/three/bitmap-target.ts | 262 ----------- packages/text/src/three/bitmap.ts | 8 - packages/text/src/three/engine-plan-target.ts | 6 +- packages/text/src/three/msdf-shader.ts | 2 +- packages/text/src/three/msdf-target.ts | 327 ------------- packages/text/src/three/msdf.ts | 6 - packages/text/src/three/program-registry.ts | 57 --- packages/text/src/three/retained-target.ts | 156 ------- packages/text/src/three/slug-shader.ts | 2 +- packages/text/src/three/slug-target.ts | 428 ------------------ packages/text/src/three/slug.ts | 6 - 22 files changed, 85 insertions(+), 1510 deletions(-) delete mode 100644 packages/text/src/three/bitmap-target.ts delete mode 100644 packages/text/src/three/msdf-target.ts delete mode 100644 packages/text/src/three/program-registry.ts delete mode 100644 packages/text/src/three/retained-target.ts delete mode 100644 packages/text/src/three/slug-target.ts diff --git a/apps/benchmarks/scripts/verify-v1-bitmap.mts b/apps/benchmarks/scripts/verify-v1-bitmap.mts index 0efe4dfb..40475155 100644 --- a/apps/benchmarks/scripts/verify-v1-bitmap.mts +++ b/apps/benchmarks/scripts/verify-v1-bitmap.mts @@ -2,7 +2,7 @@ import { spawn } from 'node:child_process'; /* @workflow { "name": "benchmark:v1-bitmap", - "summary": "Render the target-v1 core, Three Bitmap/MTSDF/Slug, and a composed third-party program on WebGPU and WebGL2.", + "summary": "Render the Rust command-buffer path for Three Bitmap/MTSDF/Slug and a custom material on WebGPU and WebGL2.", "requirements": "Playwright Chromium, WebGPU, WebGL2, and baked Inter fixtures.", "writes": "No repository files." } @@ -99,7 +99,7 @@ try { if (message.type() === 'error') errors.push(message.text()); }); page.on('pageerror', (error) => errors.push(error.message)); - await page.goto(`http://127.0.0.1:5177/v1-msdf.html?backend=${expected}`, { + await page.goto(`http://127.0.0.1:5177/v1-mtsdf.html?backend=${expected}`, { waitUntil: 'domcontentloaded', }); const result = await page.evaluate( @@ -162,11 +162,11 @@ try { if (result.drawCount < 1 || result.glyphCount !== 16 || result.canonicalGreenPixels !== result.canonicalLitPixels) throw new Error(`compose proof did not establish a canonical baseline: ${JSON.stringify(result)}`); // Composing over the exported shader may repaint the glyphs but must not move or reshape them: an identical lit set - // proves the custom program inherited the canonical position and coverage rather than reimplementing them. + // proves the custom material inherited the canonical position and coverage rather than reimplementing them. if (result.litPixels !== result.canonicalLitPixels || result.redPixels !== result.canonicalLitPixels) - throw new Error(`composed program did not reproduce the canonical coverage: ${JSON.stringify(result)}`); + throw new Error(`custom material did not reproduce the canonical coverage: ${JSON.stringify(result)}`); if (result.greenPixels !== 0) - throw new Error(`composed program did not apply its own final output: ${JSON.stringify(result)}`); + throw new Error(`custom material did not apply its own final output: ${JSON.stringify(result)}`); process.stdout.write(`${expected} compose: ${JSON.stringify(result)}\n`); await page.close(); } diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 198538e4..eb293300 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -76,33 +76,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "9fe7b5c0870b8ded2d7c0ecef42de8bbb5944cbda1f82429c87619bfaa751854", - "rawBytes": 322956, - "minifiedBytes": 213729, - "gzipBytes": 54264, - "brotliBytes": 45666 + "sha256": "ce8c8813854c5441323e1e75838433752cdd61403aa94fb2fd4c5a17b10342fc", + "rawBytes": 322911, + "minifiedBytes": 213656, + "gzipBytes": 54245, + "brotliBytes": 45644 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "5595d030e06fbd5e082176d2a0397970aca88519165d643625078953f835b6bc", - "rawBytes": 322952, - "minifiedBytes": 213794, - "gzipBytes": 54249, - "brotliBytes": 45633 + "sha256": "7118c509be608166fb7d22560a7c9616b9a93bd054fdbd3dfeb65a4a1726603b", + "rawBytes": 322907, + "minifiedBytes": 213721, + "gzipBytes": 54229, + "brotliBytes": 45635 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "175e819b7840b23cffeb52958cefb71d73a8003aee8d31d90819e33797de75ec", - "rawBytes": 322954, - "minifiedBytes": 213721, - "gzipBytes": 54223, - "brotliBytes": 45660 + "sha256": "c2ad3d5577db8bd19689d5b358f41cc41f3adaf6bdce0e9cbee355ae27c5abd3", + "rawBytes": 322909, + "minifiedBytes": 213648, + "gzipBytes": 54203, + "brotliBytes": 45587 }, { "id": "bitmap-baker-wasm", diff --git a/apps/benchmarks/src/v1-compose-proof.ts b/apps/benchmarks/src/v1-compose-proof.ts index 54474daf..c07c24dc 100644 --- a/apps/benchmarks/src/v1-compose-proof.ts +++ b/apps/benchmarks/src/v1-compose-proof.ts @@ -1,21 +1,6 @@ -import type { - GlyphBatchKey, - LoadedFont, - ParagraphBatchTarget, - ParagraphBatchTargetUpdate, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '@pmndrs/text'; -import { defineRasterTechnique } from '@pmndrs/text'; -import { bitmap, type BitmapPageData } from '@pmndrs/text/three/bitmap'; -import { - bitmapShader, - FontLoader, - registerThreeRasterProgram, - Text, - type ThreeRasterTargetOwner, -} from '@pmndrs/text/three'; +import type { LoadedFont } from '@pmndrs/text'; +import { bitmap } from '@pmndrs/text/three/bitmap'; +import { defineTextMaterial, FontLoader, Text } from '@pmndrs/text/three'; import * as TSL from 'three/tsl'; import * as THREE from 'three/webgpu'; @@ -36,17 +21,16 @@ interface TargetV1ComposeResult { readonly canonicalGreenPixels: number; } -/** - * A third-party technique is only a distinct program key here: every portable operation stays the first-party Bitmap - * implementation, so any rendering difference this proof observes comes from the composed shader alone. - */ -const composedBitmap = defineRasterTechnique({ ...bitmap, id: 'benchmarks.composed-bitmap' }); - /** * The composed program keeps the canonical position and coverage and tints only the resolved colour. Preserving the * canonical glyph footprint while changing the paint is what proves it reuses the exported technique shader. */ -registerThreeRasterProgram(composedBitmap, (owner) => new ComposedBitmapTarget(owner)); +const composedMaterial = defineTextMaterial((context) => { + if (context.technique !== bitmap.id) throw new TypeError('compose proof requires the Bitmap material context'); + const material = context.createDefaultMaterial(); + material.colorNode = context.shader.color.mul(TSL.vec3(1, 0, 0)); + return material; +}); window.targetV1ComposeReady = render(); @@ -59,9 +43,8 @@ async function render(): Promise { const target = new THREE.RenderTarget(256, 128, { format: THREE.RGBAFormat, type: THREE.UnsignedByteType }); target.texture.colorSpace = THREE.NoColorSpace; let canonicalText: Text | undefined; - let composedText: Text | undefined; + let composedText: Text | undefined; let canonicalFont: LoadedFont | undefined; - let composedFont: LoadedFont | undefined; try { renderer.setSize(256, 128, false); renderer.setPixelRatio(1); @@ -92,15 +75,12 @@ async function render(): Promise { canonicalText.dispose(); canonicalText = undefined; - composedFont = await loader.loadAsync({ - input: { baked: '/fixtures/rendering/inter-bitmap-16.font.glb' }, - raster: { technique: composedBitmap, options: { strikes: [16] } }, - }); composedText = new Text({ - font: composedFont, + font: canonicalFont, text: 'Target v1 Bitmap', style: { fontSize: 28 }, paint: { color: '#ffffff' }, + material: composedMaterial, }); composedText.position.set(-112, 24, 0); scene.add(composedText); @@ -123,7 +103,6 @@ async function render(): Promise { composedText?.removeFromParent(); composedText?.dispose(); canonicalFont?.dispose(); - composedFont?.dispose(); loader.dispose(); target.dispose(); renderer.dispose(); @@ -148,157 +127,3 @@ async function countPixels(renderer: THREE.WebGPURenderer, target: THREE.RenderT } return { lit, red, green }; } - -interface ComposedRevision { - readonly sourceRevision: number; - dispose(): void; -} - -interface ComposedResource { - readonly material: THREE.MeshBasicNodeMaterial; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -/** - * A deliberately minimal third-party Three program: it owns its own attributes, geometry, and material, and rebuilds - * them on every revision. Only the node graph is shared, and it comes from the exported canonical Bitmap shader. - */ -class ComposedBitmapTarget implements ParagraphBatchTarget { - readonly technique: typeof composedBitmap = composedBitmap; - readonly #owner: ThreeRasterTargetOwner; - readonly #textures = new Map(); - - constructor(owner: ThreeRasterTargetOwner) { - this.#owner = owner; - } - - stage( - _previous: ComposedRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - const resources = new Map(); - for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch)); - const draws: THREE.Mesh[] = []; - const parents: ParagraphId[] = []; - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const resource = resources.get(run.batch); - if (resource === undefined) throw new Error('composed run references an unknown physical batch'); - const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material); - mesh.userData.pmndrsTextRunStart = run.start; - mesh.frustumCulled = false; - mesh.renderOrder = this.#owner.renderOrderBase + index; - draws.push(mesh); - parents.push(run.paragraph); - } - const dispose = (): void => { - for (const draw of draws) { - draw.removeFromParent(); - draw.geometry.dispose(); - } - for (const resource of resources.values()) resource.dispose(); - }; - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('composed stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) - this.#owner.objectForParagraph(parents[index]!).add(draws[index]!); - return { sourceRevision: next.revision, dispose }; - }, - abort: () => { - if (finished) return; - finished = true; - dispose(); - }, - }, - }; - } - - dispose(): void { - for (const texture of this.#textures.values()) texture.dispose(); - this.#textures.clear(); - } - - #createResource(batch: PreparedGlyphBatch): ComposedResource { - const page = batch.font.data.strikes[batch.binding.strike]?.pages[batch.binding.page]; - if (page === undefined) throw new TypeError('composed binding references a missing decoded page'); - const storage = batch.storage; - const origins = storageAttribute(storage.origins, 2); - const sizes = storageAttribute(storage.sizes, 2); - const uvOrigins = storageAttribute(storage.uvOrigins, 2); - const uvSizes = storageAttribute(storage.uvSizes, 2); - const colors = storageAttribute(storage.colors, 4); - const runStart = TSL.uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = TSL.instanceIndex.add(runStart); - const shader = bitmapShader( - { - origin: TSL.storage(origins, 'vec2', origins.count).setPBO(true).element(instance), - size: TSL.storage(sizes, 'vec2', sizes.count).setPBO(true).element(instance), - uvOrigin: TSL.storage(uvOrigins, 'vec2', uvOrigins.count).setPBO(true).element(instance), - uvSize: TSL.storage(uvSizes, 'vec2', uvSizes.count).setPBO(true).element(instance), - color: TSL.storage(colors, 'vec4', colors.count).setPBO(true).element(instance), - }, - { page: this.#texture(page) }, - ); - const material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = shader.position; - material.vertexNode = shader.clipPosition; - material.colorNode = shader.color.mul(TSL.vec3(1, 0, 0)); - material.opacityNode = shader.opacity; - return { - material, - geometry(count) { - const geometry = new THREE.InstancedBufferGeometry(); - geometry.setAttribute( - 'position', - new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0], 3), - ); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); - geometry.instanceCount = count; - geometry.setAttribute('_composedOrigins', origins); - geometry.setAttribute('_composedSizes', sizes); - geometry.setAttribute('_composedUvOrigins', uvOrigins); - geometry.setAttribute('_composedUvSizes', uvSizes); - geometry.setAttribute('_composedColors', colors); - return geometry; - }, - dispose() { - material.dispose(); - }, - }; - } - - #texture(page: BitmapPageData): THREE.DataTexture { - let texture = this.#textures.get(page.resource); - if (texture !== undefined) return texture; - texture = new THREE.DataTexture(page.bytes, page.width, page.height, THREE.RedFormat, THREE.UnsignedByteType); - texture.colorSpace = THREE.NoColorSpace; - texture.magFilter = THREE.LinearFilter; - texture.minFilter = THREE.LinearFilter; - texture.generateMipmaps = false; - texture.flipY = false; - texture.needsUpdate = true; - this.#textures.set(page.resource, texture); - return texture; - } -} - -function storageAttribute(array: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute { - const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(array), itemSize); - attribute.setUsage(THREE.DynamicDrawUsage); - attribute.needsUpdate = true; - return attribute; -} diff --git a/docs/log.md b/docs/log.md index d175d188..04c98745 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-09 +- **Deleted the redundant Three paragraph-target transaction** — The Rust command buffer is now the sole render-state + transition authority. Removed the candidate/current `ThreeBitmapTarget`, `ThreeMtsdfTarget`, `ThreeSlugTarget`, retained + revision, and old renderer-program registry; the executor keeps only GPU resource/draw/material tables, synchronization, + and reversible presentation overrides. First-party technique imports no longer register targets as side effects. Migrated + the composition proof to ordinary Bitmap policy packing plus `defineTextMaterial`, so customization changes canonical + shader output without owning layout, attributes, geometry, or another transaction. This deletes 1,496 source lines. The + measured technique runtime graphs shrink only 45 raw bytes each (19–20 gzip bytes for Bitmap/MTSDF and 20 for Slug), + proving the deleted targets were already outside those consumer graphs rather than attributing an invented payload win. + - **Kept layout inspection and presentation outside rendering authority** — Added an explicit Rust semantic-glyph inspection mask alongside measurement; ordinary rendering still publishes no layout arrays. First-party policy programs now carry one stable glyph ID per renderable instance so Three can direct optional Bitmap/MTSDF/Slug origin diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index b4a8fd9f..9d7fd9e5 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:7781a56401ad8744d121c3c5635163d37e25a575bf4422c47d75f06ca549a673' +source_digest: 'sha256:229e0b516533ab53e674b7907037769a07845260521ed2f505425071583749f3' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -214,10 +214,10 @@ when a visibly populated draw claims no GPU residency, so the accessor is proven a unit fixture. The Worker proof distinguishes call-time snapshots, later desired state, supersession, abort, progress, and one reusable module Worker. -A fifth proof covers composition over the exported canonical technique shaders. It renders one paragraph through the -pre-registered Bitmap program, then through a third-party program that owns its own attributes, geometry, and material and -composes only its final colour over `bitmapShader`. The verification compares the two passes on the same page rather than -against a stored golden: an identical lit-pixel set proves the composed program inherited the canonical placement, +A fifth proof covers material customization over the canonical Bitmap command-buffer path. It renders one paragraph with +the default Bitmap material, then renders the same Rust-produced draw with a `defineTextMaterial` factory that starts from +`createDefaultMaterial()` and changes only its final colour. The verification compares the two passes on the same page +rather than against a stored golden: an identical lit-pixel set proves the custom material inherited canonical placement, snapping, and coverage, and an empty green channel proves it still emitted its own output. The finite Bitmap conformance lane now drives that adapter directly. `bitmap-finite-scene` builds its paragraph with the diff --git a/docs/packages/text.md b/docs/packages/text.md index bda48bfd..1fa1ab71 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:34af8782a43930eebccf5c1fe5c045ecfb26caf7b88c548bd99dc4c8d1d9b595' +source_digest: 'sha256:daaf3807f91b929a868a11d00f29a98b4259ccdb8fd4d229449f14d54777e69a' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -210,10 +210,10 @@ than allocating a glyph-sized set each time; a technique that needs a field beyo decode and authenticate CPU resources without importing Three, explicitly omit absent records, select stable physical bindings, and pack positive-down paragraph origins plus technique fields into typed canonical arrays. Bitmap selects a strike/page per glyph and retains R8 pages; MTSDF retains one RGBA8 atlas-array binding per font; Slug retains its original -RGBA16F curve, R32 header, and R16 reference bytes so Three's R16-to-R32 workaround remains target-owned. Focused package +RGBA16F curve, R32 header, and R16 reference bytes so Three's R16-to-R32 workaround remains executor-owned. Focused package tests prove selection, range writes, binding identity, coordinates, paint, and analytic addresses. The merged-v0 Bitmap and Slug renderer modules, the `/raster/msdf` spelling, the merged-v0 `Text`, and the `/react` binding are deleted; `/raster/bitmap`, `/raster/mtsdf`, `/raster/slug`, `/three`, `/r3f`, and `/typegpu` are the whole renderer surface. The -Bitmap conformance lane no longer needs a fallback: driven by the target-v1 `Text`, `ThreeBitmapTarget`, and +Bitmap conformance lane no longer needs a fallback: driven by command-buffer-backed `Text`, the Three executor, and `LoadedFont` raster data, it reproduces the benchmark's independent CPU atlas compositor in zero mismatched bytes and returns the same pinned full-frame hash `a47930d3…e893`, the same 5,930 lit and 3,473 half-coverage pixels, and the same `[68, 18, 313, 112]` ink bounds the merged-v0 renderer produced. Reaching that required two corrections to the exported @@ -222,38 +222,36 @@ inherited merged-v0's vertical atlas flip, which belongs to that renderer's `fli target-v1 pages, and it had dropped the physical-pixel snap the strike's integer placement depends on. Every Presentation surface now renders through the target-v1 techniques and the `/three` adapter. -The `/three` adapter resolves each technique's target through a program registry keyed by the technique's stable -identifier rather than its object identity, and pre-registers the three first-party programs. Identifier keying preserves -the public raster extension boundary proven in milestone 10: a third party registers a Three program for its own technique -through `registerThreeRasterProgram`, and an application may wrap a first-party technique to instrument its runtime baker -without the wrapper losing its program. An unregistered technique fails at batch construction with a typed error naming -the identifier instead of rendering nothing. `registerThreeRasterProgram` infers that technique, so a program may type its -prepared batches, storage, and binding concretely; the registry itself stays heterogeneous and holds the erased form after -the pairing is proven at the registration call. +The `/three` adapter registers each raster technique through `registerThreeRasterPlanProgram`, keyed by the technique's +stable identifier rather than object identity. A registration contributes static policy bytecode, cold font/resource +binding compilation, and material realization. Rust interprets the policy while shaping, laying out, packing, and emitting +the command buffer; no JavaScript callback runs in that hot path. The Three executor owns only resource tables, GPU +objects, synchronization, and reversible presentation overrides. It does not retain a candidate/current paragraph target +or independently derive layout and render state. An unregistered technique fails during engine setup with its identifier. `/three` also exports each canonical technique shader as `bitmapShader`, `mtsdfShader`, and `slugShader`.[^three-v1] Each takes one glyph instance's resolved nodes plus that batch's bound GPU resources and returns a named readonly output: position, coverage, resolved colour, opacity, and the intermediate stages the technique produces, such as MTSDF's separate fill, outline-ring, and shadow coverage or Slug's dilated render coordinate. These are not a parallel copy maintained for -external use. `ThreeBitmapTarget`, `ThreeMtsdfTarget`, and `ThreeSlugTarget` build their materials from exactly these -functions, so a composed program cannot drift from what the first-party path renders and deleting an export breaks the -built-in target rather than an unused mirror. Each function reads `positionLocal` and `uv()` from the technique's unit -quad, so a program supplying its own geometry owns that correspondence. +external use. The command-buffer executor builds the first-party Bitmap, MTSDF, and Slug materials from exactly these +functions. `defineTextMaterial` receives that same canonical shader result and a `createDefaultMaterial()` factory, so +customization does not need to own packing, geometry, or layout. Each function reads `positionLocal` and `uv()` from the +executor's unit quad. -`bitmapShader` additionally publishes `clipPosition`, the projected quad rounded to whole physical pixels, which a program +`bitmapShader` additionally publishes `clipPosition`, the projected quad rounded to whole physical pixels, which the executor assigns to `material.vertexNode`. Bitmap coverage is authored at one atlas texel per device pixel, so an unsnapped quad resamples the strike rather than reproducing it, and placing that snap in the exported shader rather than in the built-in -target is what makes a composed program inherit it by construction instead of by convention. The output carries no other +executor is what makes a custom material inherit it by construction instead of by convention. The output carries no other route to a vertex stage, so the seam cannot be silently skipped. MTSDF and Slug deliberately publish no such member: a distance field reconstructs its edge from the screen-space gradient and Slug integrates coverage analytically from outlines, so both are correct at any subpixel placement and must keep the default projection. Bitmap pages upload in the atlas's own top-down row order with `flipY` disabled, and `atlasUv` addresses that same space directly, so the sampled row is the baked row on both backends. -The composed-program proof renders one paragraph twice on native WebGPU and forced WebGL2: once through the pre-registered -Bitmap program, then through a third-party program that owns its own attributes, geometry, and material and composes only -its final colour over `bitmapShader`. Both passes light an identical 2,616-pixel set while the composed pass emits no green -channel, so the custom program inherited the canonical placement, snapping, and coverage instead of reimplementing them. +The custom-material proof renders one paragraph twice on native WebGPU and forced WebGL2: once with the default Bitmap +material, then with a `defineTextMaterial` factory that begins from `createDefaultMaterial()` and changes only final colour. +Both passes light an identical 2,616-pixel set while the customized pass emits no green channel, so it inherited canonical +placement, snapping, coverage, policy packing, and command-buffer batching instead of reimplementing them. The retained proof pages light 2,606 pixels for Bitmap, 1,935 for MTSDF, and 1,510 for Slug on both backends. Two target-v1 raster defects surfaced when the benchmark began driving these programs against the exact conformance diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index b1ba8cab..7f03e890 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -312,6 +312,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-226 | Semantic layout inspection is an explicit demand-shaped Rust query, not renderer input and not a reason to retain the legacy TypeScript layout path. The query uses the existing `semanticViewMask` on `text_update`; zero remains the ordinary rendering request. Its records may share the immutable A/B publication lifetime, but `ThreeTextRenderPlanExecutor` ignores them. The first public `Text.measureLayout()` view emits one paragraph summary plus its line records, performs one extra crossing only on an uncached explicit request, and invalidates on a committed semantic update. Record-level Rust tests cover exact/at-most sizing and overflow, while a real compiled-Wasm Three fixture proves the query leaves `Text.layout` absent and retains the existing mesh. Per-glyph inspection, caret, selection, hit testing, accessibility, and diagnostics remain separate masks and do not enter the render plan by default. | Accepted | | D-227 | Explicit per-glyph inspection and presentation motion do not restore a renderer-side layout or target transaction. `Text.inspectLayout()` requests the Rust sidecar mask only on demand and copies paragraph summary, lines, semantic glyph IDs, clusters, font identity, size, flags, and shaped origins; ordinary render updates still request none of it. The first-party policy deliberately augments each renderable instance with its session-global stable glyph ID in `u32` buffer 14. Three pairs that policy output with the existing origin stream, snapshots renderer-local displayed origins, and permits topology-guarded presentation overrides for Bitmap, MSDF, and Slug. Before applying any later Rust command-buffer delta, the executor restores the captured authoritative origins; the plan then patches, replaces, or retires resources normally. Thus Rust remains the sole render-state transition authority, while Three retains only resource/draw tables and reversible presentation state—no parallel candidate/current `ParagraphBatchTarget` state machine. Rust record tests and compiled-Wasm one- and two-paragraph fixtures prove semantic spaces remain inspectable, stable IDs address shared draws without collision, overrides do not mutate shaped targets, transform-only updates preserve them, and semantic updates retire them. The optimized shaper is 1,089,889 raw / 414,204 gzip / 325,805 Brotli bytes on the canonical Darwin arm64 host. | Accepted | +| D-228 | A Rust command buffer makes the generic Three paragraph-target transaction redundant. The deleted `ThreeBitmapTarget`, `ThreeMtsdfTarget`, `ThreeSlugTarget`, `RetainedThreeTargetRevision`, and old raster-program registry no longer stage candidate/current rendering state beside Rust. `ThreeTextRenderPlanExecutor` directly applies the authoritative plan and retains only GPU resources, draw/material caches, synchronization, and reversible presentation state. First-party technique modules no longer register renderer targets as import side effects. The composition proof uses ordinary Bitmap policy packing plus `defineTextMaterial`; custom material code changes the canonical shader output without owning attributes, geometry, layout, or plan transitions. Third-party techniques continue through declarative `registerThreeRasterPlanProgram`. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/three-material-authority.md b/docs/planning/three-material-authority.md index ebe65c62..26a8ab12 100644 --- a/docs/planning/three-material-authority.md +++ b/docs/planning/three-material-authority.md @@ -21,9 +21,9 @@ sources: - id: render-plan resource: ../../packages/text/rust/shaper/src/engine/render_plan.rs title: Rust render-plan records - - id: bitmap-target - resource: ../../packages/text/src/three/bitmap-target.ts - title: Three Bitmap target and current program-owned material + - id: plan-executor + resource: ../../packages/text/src/three/engine-plan-target.ts + title: Three command-buffer executor and material realization generated: by: openai-codex/gpt-5.6 at: '2026-08-08T00:00:00Z' @@ -106,11 +106,10 @@ text.setSpan(0, { start: 0, end: 3, material: warning }); `createDefaultMaterial()` is the DRY path for changing ordinary material state while retaining the package's canonical placement, coverage, color, and opacity nodes. Creating another `NodeMaterial` is the low-level path for lighting, shadows, depth writes/tests, and other standard Three behavior. Neither path may replace or duplicate the technique's -glyph coverage algorithm unless the application registers a complete custom raster program. +glyph coverage algorithm unless the application registers a complete custom raster plan program. -The construction function, runtime-scoped identity registry, and command-buffer executor are implemented. The public -`TextGroup`/`Text`/span `material` properties land with the atomic Rust-session cutover; until then callers cannot yet -author a material through those objects even though the executor route is tested. +The construction function, runtime-scoped identity registry, command-buffer executor, and public `TextGroup`/`Text`/span +`material` properties are implemented through the atomic Rust-session path. ## Identity and render-plan route @@ -168,14 +167,13 @@ ordinary Three/TSL code outside the text core contract. - batch, text, and nested-span material cascade reaches exact `materialId` draw records without shaping/layout work; - two materials over one resource share physical glyph buffers and produce ordered draw spans; -- Bitmap, MTSDF, and Slug factories consume the exact canonical shaders used by their default targets; +- Bitmap, MTSDF, and Slug factories consume the exact canonical shaders used by the command-buffer executor; - WebGPURenderer and its WebGL2 fallback preserve placement and coverage for default and custom materials; - replacement, failure, cache eviction, renderer retirement, and disposal preserve the previous complete frame; - a lit/depth-writing material proves standard Three lighting, depth, and shadow participation where Three supports it; - untouched text pays no material-factory call, allocation, pipeline rebuild, or extra package import; and - package raw/minified/gzip/Brotli and first-pipeline costs are reported before the API is marked implemented. -The numeric `material_id` route, material-directed draw compatibility, shared physical glyph storage, and rejection of a -second effects vocabulary are settled inputs to the Rust plan. The exact Three factory types above remain provisional for -the later material-design pass. Until its gates pass, the current first-party Three targets remain the implementation -gap; documentation must not describe the factory as already shipped. +The numeric `material_id` route, material-directed draw compatibility, shared physical glyph storage, rejection of a +second effects vocabulary, and the exact Three factory types above are implemented inputs to the Rust plan. The remaining +evidence bullets are release gates for broadening material behavior, not authority for a parallel renderer target. diff --git a/packages/text/package.json b/packages/text/package.json index 834db4a2..b517f06b 100644 --- a/packages/text/package.json +++ b/packages/text/package.json @@ -14,11 +14,7 @@ "!dist/internal/raster-baker-profile.js" ], "type": "module", - "sideEffects": [ - "./dist/three/bitmap.js", - "./dist/three/msdf.js", - "./dist/three/slug.js" - ], + "sideEffects": false, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 3d9be5d5..2f5134ae 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -29,12 +29,6 @@ export type { } from './three/plan-program-registry.js'; export { msdfShader } from './three/msdf-shader.js'; export type { ThreeMsdfInstanceNodes, ThreeMsdfShaderOutput, ThreeMsdfShaderResources } from './three/msdf-shader.js'; -export { registerThreeRasterProgram } from './three/program-registry.js'; -export type { - ThreeRasterProgram, - ThreeRasterTargetAccounting, - ThreeRasterTargetOwner, -} from './three/program-registry.js'; export type { ThreeFontLoaderOptions as FontLoaderOptions, ThreeLoadedFontRequest as LoadedFontRequest, diff --git a/packages/text/src/three/bitmap-shader.ts b/packages/text/src/three/bitmap-shader.ts index 22352f77..425dc1da 100644 --- a/packages/text/src/three/bitmap-shader.ts +++ b/packages/text/src/three/bitmap-shader.ts @@ -42,7 +42,7 @@ export interface ThreeBitmapShaderOutput { } /** - * Builds the canonical Bitmap node graph. This is the exact graph `ThreeBitmapTarget` renders, so a program that + * Builds the canonical Bitmap node graph. This is the exact graph the command-buffer executor renders, so a program that * composes over the returned nodes inherits the technique's coverage sampling and pixel snapping instead of * reimplementing them. * diff --git a/packages/text/src/three/bitmap-target.ts b/packages/text/src/three/bitmap-target.ts deleted file mode 100644 index cb1ebcd9..00000000 --- a/packages/text/src/three/bitmap-target.ts +++ /dev/null @@ -1,262 +0,0 @@ -import * as TSL from 'three/tsl'; -import * as THREE from 'three/webgpu'; - -import type { - GlyphBatchKey, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '../paragraph-batch.js'; -import type { ParagraphBatchTarget, ParagraphBatchTargetUpdate } from '../paragraph-batch-attachment.js'; -import { bitmap, type BitmapPageData } from '../raster/bitmap-technique.js'; -import { bitmapShader } from './bitmap-shader.js'; -import { - instanceStorageBytes, - invalidatePboTexture, - retainedRunIdentities, - RetainedThreeGpuBytes, - RetainedThreeTargetRevision, - type RetainedThreeTargetResource, -} from './retained-target.js'; - -export interface ThreeBitmapTargetOwner { - objectForParagraph(paragraph: ParagraphId): THREE.Object3D; - readonly renderOrderBase: number; -} - -interface BitmapTargetResource extends RetainedThreeTargetResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - readonly attributes: readonly THREE.StorageInstancedBufferAttribute[]; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -export class ThreeBitmapTargetRevision extends RetainedThreeTargetRevision {} - -export class ThreeBitmapTarget implements ParagraphBatchTarget< - typeof bitmap, - Variant, - ThreeBitmapTargetRevision -> { - readonly technique: typeof bitmap = bitmap; - readonly #owner: ThreeBitmapTargetOwner; - readonly #textures = new Map(); - readonly #gpuBytes = new RetainedThreeGpuBytes(); - #disposed = false; - - constructor(owner: ThreeBitmapTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#gpuBytes.total; - } - - stage( - previous: ThreeBitmapTargetRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('Three bitmap target has been disposed'); - if (previous?.canReuse(next) === true) { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three bitmap stage is no longer active'); - finished = true; - const state = previous.transfer(next, this.#owner.renderOrderBase); - return this.#gpuBytes.retain( - new ThreeBitmapTargetRevision(next.revision, state.draws, state.resources, state.runIdentities), - ); - }, - abort: () => { - finished = true; - }, - }, - }; - } - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch)); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const resource = resources.get(run.batch); - if (resource === undefined) throw new Error('bitmap run references an unknown physical batch'); - const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material); - mesh.userData.pmndrsTextRunStart = run.start; - mesh.frustumCulled = false; - mesh.renderOrder = this.#owner.renderOrderBase + index; - draws.push(mesh); - } - let finished = false; - return { - status: 'ready' as const, - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three bitmap stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) { - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - } - return this.#gpuBytes.retain( - new ThreeBitmapTargetRevision(next.revision, draws, resources, retainedRunIdentities(next)), - ); - }, - abort: () => { - if (finished) return; - finished = true; - disposeStaged(draws, resources.values()); - }, - }, - }; - } catch (error) { - disposeStaged(draws, resources.values()); - throw error; - } - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const texture of this.#textures.values()) texture.dispose(); - this.#textures.clear(); - this.#gpuBytes.release(); - } - - #createResource(batch: PreparedGlyphBatch): BitmapTargetResource { - const page = batch.font.data.strikes[batch.binding.strike]?.pages[batch.binding.page]; - if (page === undefined) throw new TypeError('bitmap binding references a missing decoded page'); - const texture = this.#texture(page); - return createBitmapTargetResource(batch, texture); - } - - #texture(page: BitmapPageData): THREE.DataTexture { - let texture = this.#textures.get(page.resource); - if (texture !== undefined) return texture; - texture = new THREE.DataTexture(page.bytes, page.width, page.height, THREE.RedFormat, THREE.UnsignedByteType); - texture.colorSpace = THREE.NoColorSpace; - texture.magFilter = THREE.LinearFilter; - texture.minFilter = THREE.LinearFilter; - texture.generateMipmaps = false; - texture.flipY = false; - texture.needsUpdate = true; - this.#textures.set(page.resource, texture); - this.#gpuBytes.addShared(page.bytes.byteLength); - return texture; - } -} - -function createBitmapTargetResource( - batch: PreparedGlyphBatch, - texture: THREE.DataTexture, -): BitmapTargetResource { - const storage = batch.storage; - const origins = storageAttribute(storage.origins, 2); - const sizes = storageAttribute(storage.sizes, 2); - const uvOrigins = storageAttribute(storage.uvOrigins, 2); - const uvSizes = storageAttribute(storage.uvSizes, 2); - const colors = storageAttribute(storage.colors, 4); - const attributes = [origins, sizes, uvOrigins, uvSizes, colors] as const; - const runStart = TSL.uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = TSL.instanceIndex.add(runStart); - const shader = bitmapShader( - { - origin: TSL.storage(origins, 'vec2', origins.count).setPBO(true).element(instance), - size: TSL.storage(sizes, 'vec2', sizes.count).setPBO(true).element(instance), - uvOrigin: TSL.storage(uvOrigins, 'vec2', uvOrigins.count).setPBO(true).element(instance), - uvSize: TSL.storage(uvSizes, 'vec2', uvSizes.count).setPBO(true).element(instance), - color: TSL.storage(colors, 'vec4', colors.count).setPBO(true).element(instance), - }, - { page: texture }, - ); - const material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = shader.position; - material.vertexNode = shader.clipPosition; - material.colorNode = shader.color; - material.opacityNode = shader.opacity; - - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: instanceStorageBytes(attributes), - material, - attributes, - update(next) { - updateStorageAttribute(origins, next.storage.origins, 2, next.dirtyRanges); - updateStorageAttribute(sizes, next.storage.sizes, 2, next.dirtyRanges); - updateStorageAttribute(uvOrigins, next.storage.uvOrigins, 2, next.dirtyRanges); - updateStorageAttribute(uvSizes, next.storage.uvSizes, 2, next.dirtyRanges); - updateStorageAttribute(colors, next.storage.colors, 4, next.dirtyRanges); - }, - geometry(count) { - const geometry = unitQuad(); - geometry.instanceCount = count; - geometry.setAttribute('_pmndrsTextOrigins', origins); - geometry.setAttribute('_pmndrsTextSizes', sizes); - geometry.setAttribute('_pmndrsTextUvOrigins', uvOrigins); - geometry.setAttribute('_pmndrsTextUvSizes', uvSizes); - geometry.setAttribute('_pmndrsTextColors', colors); - return geometry; - }, - dispose() { - material.dispose(); - }, - }; -} - -function updateStorageAttribute( - attribute: THREE.StorageInstancedBufferAttribute, - source: Float32Array, - itemSize: number, - ranges: readonly { readonly start: number; readonly count: number }[], -): void { - if (ranges.length === 0) return; - const target = attribute.array as Float32Array; - attribute.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * itemSize; - const count = range.count * itemSize; - target.set(source.subarray(start, start + count), start); - attribute.addUpdateRange(start, count); - } - attribute.needsUpdate = true; - invalidatePboTexture(attribute); -} - -function storageAttribute(array: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute { - const copy = new Float32Array(array); - const attribute = new THREE.StorageInstancedBufferAttribute(copy, itemSize); - attribute.setUsage(THREE.DynamicDrawUsage); - attribute.needsUpdate = true; - return attribute; -} - -function unitQuad(): THREE.InstancedBufferGeometry { - const geometry = new THREE.InstancedBufferGeometry(); - geometry.setAttribute( - 'position', - new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0], 3), - ); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); - return geometry; -} - -function disposeStaged(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) draw.geometry.dispose(); - for (const resource of resources) resource.dispose(); -} diff --git a/packages/text/src/three/bitmap.ts b/packages/text/src/three/bitmap.ts index 072f4384..3aba616d 100644 --- a/packages/text/src/three/bitmap.ts +++ b/packages/text/src/three/bitmap.ts @@ -1,9 +1 @@ -import { bitmap } from '../raster/bitmap-technique.js'; -import { ThreeBitmapTarget, type ThreeBitmapTargetOwner } from './bitmap-target.js'; -import { registerThreeRasterProgram } from './program-registry.js'; - -// Pairing the technique with its program here is what keeps them separable: importing this subpath wires exactly one -// technique, so an application that never names MSDF or Slug does not carry their shaders, decoders, or targets. -registerThreeRasterProgram(bitmap, (owner: ThreeBitmapTargetOwner) => new ThreeBitmapTarget(owner)); - export * from '../raster/bitmap-technique.js'; diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index c3aa69f1..c9c7e57a 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -11,7 +11,6 @@ import { slug, type SlugPageData } from '../raster/slug-technique.js'; import { bitmapShader } from './bitmap-shader.js'; import type { ThreeTextEngineCoordinator, ThreeTextEngineResource } from './engine-runtime.js'; import { msdfShader } from './msdf-shader.js'; -import { invalidatePboTexture } from './retained-target.js'; import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; import type { ThreeTextMaterialContext } from './material.js'; import type { ThreePlanProgramBuffer } from './plan-program-registry.js'; @@ -1241,6 +1240,11 @@ function markOriginRanges(ranges: ReadonlyMap { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -export class ThreeMsdfTargetRevision extends RetainedThreeTargetRevision {} - -export class ThreeMsdfTarget implements ParagraphBatchTarget { - readonly technique: typeof msdf = msdf; - readonly #owner: ThreeMsdfTargetOwner; - readonly #atlases = new Map(); - readonly #gpuBytes = new RetainedThreeGpuBytes(); - #disposed = false; - - constructor(owner: ThreeMsdfTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#gpuBytes.total; - } - - stage( - previous: ThreeMsdfTargetRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('Three MSDF target has been disposed'); - if (previous?.canReuse(next) === true) { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three MSDF stage is no longer active'); - finished = true; - const state = previous.transfer(next, this.#owner.renderOrderBase); - return this.#gpuBytes.retain( - new ThreeMsdfTargetRevision(next.revision, state.draws, state.resources, state.runIdentities), - ); - }, - abort: () => { - finished = true; - }, - }, - }; - } - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) - resources.set(batch.key, createMsdfTargetResource(batch, this.#atlas(batch.font.data))); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const resource = resources.get(run.batch); - if (resource === undefined) throw new Error('MSDF run references an unknown physical batch'); - const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material); - mesh.userData.pmndrsTextRunStart = run.start; - mesh.frustumCulled = false; - mesh.renderOrder = this.#owner.renderOrderBase + index; - draws.push(mesh); - } - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three MSDF stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - return this.#gpuBytes.retain( - new ThreeMsdfTargetRevision(next.revision, draws, resources, retainedRunIdentities(next)), - ); - }, - abort: () => { - if (finished) return; - finished = true; - disposeStaged(draws, resources.values()); - }, - }, - }; - } catch (error) { - disposeStaged(draws, resources.values()); - throw error; - } - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const atlas of this.#atlases.values()) atlas.dispose(); - this.#atlases.clear(); - this.#gpuBytes.release(); - } - - #atlas(data: MsdfData): THREE.DataArrayTexture { - let atlas = this.#atlases.get(data.resource); - if (atlas !== undefined) return atlas; - const bytes = new Uint8Array(data.binding.width * data.binding.height * data.binding.layers * 4); - for (let layer = 0; layer < data.pages.length; layer += 1) { - const page = data.pages[layer]!; - for (let row = 0; row < page.height; row += 1) { - const source = row * page.width * 4; - const target = (layer * data.binding.height + row) * data.binding.width * 4; - bytes.set(page.bytes.subarray(source, source + page.width * 4), target); - } - } - atlas = new THREE.DataArrayTexture(bytes, data.binding.width, data.binding.height, data.binding.layers); - atlas.format = THREE.RGBAFormat; - atlas.type = THREE.UnsignedByteType; - atlas.colorSpace = THREE.NoColorSpace; - atlas.magFilter = THREE.LinearFilter; - atlas.minFilter = THREE.LinearFilter; - atlas.generateMipmaps = false; - atlas.needsUpdate = true; - this.#atlases.set(data.resource, atlas); - this.#gpuBytes.addShared(bytes.byteLength); - return atlas; - } -} - -function createMsdfTargetResource( - batch: PreparedGlyphBatch, - atlas: THREE.DataArrayTexture, -): MsdfTargetResource { - const geometryValues = new Float32Array(batch.capacity * 4); - const uvValues = new Float32Array(batch.capacity * 4); - const boundsValues = new Float32Array(batch.capacity * 4); - const fillValues = new Float32Array(batch.capacity * 4); - const outlineValues = new Float32Array(batch.capacity * 4); - const shadowValues = new Float32Array(batch.capacity * 4); - const effectsValues = new Float32Array(batch.capacity * 4); - const arrays = { - geometry: geometryValues, - uv: uvValues, - bounds: boundsValues, - fill: fillValues, - outline: outlineValues, - shadow: shadowValues, - effects: effectsValues, - }; - writeMsdfStorage(batch, arrays, 0, batch.instanceCount); - const attributes = { - geometry: floatStorage(geometryValues, 4), - uv: floatStorage(uvValues, 4), - bounds: floatStorage(boundsValues, 4), - fill: floatStorage(fillValues, 4), - outline: floatStorage(outlineValues, 4), - shadow: floatStorage(shadowValues, 4), - effects: floatStorage(effectsValues, 4), - }; - const runStart = TSL.uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = TSL.instanceIndex.add(runStart); - const geometry = TSL.storage(attributes.geometry, 'vec4', attributes.geometry.count).setPBO(true).element(instance); - const uvData = TSL.storage(attributes.uv, 'vec4', attributes.uv.count).setPBO(true).element(instance); - const uvBounds = TSL.storage(attributes.bounds, 'vec4', attributes.bounds.count).setPBO(true).element(instance); - const fillColor = TSL.storage(attributes.fill, 'vec4', attributes.fill.count).setPBO(true).element(instance); - const outlineColor = TSL.storage(attributes.outline, 'vec4', attributes.outline.count).setPBO(true).element(instance); - const shadowColor = TSL.storage(attributes.shadow, 'vec4', attributes.shadow.count).setPBO(true).element(instance); - const effects = TSL.storage(attributes.effects, 'vec4', attributes.effects.count).setPBO(true).element(instance); - const shader = msdfShader( - { - origin: geometry.xy, - size: geometry.zw, - uvOrigin: uvData.xy, - uvSize: uvData.zw, - uvBounds, - fillColor, - outlineColor, - shadowColor, - shadowOffset: effects.xy, - outlineWidth: effects.z, - pageIndex: effects.w, - }, - { - atlas, - atlasWidth: batch.binding.width, - atlasHeight: batch.binding.height, - pixelRange: batch.font.data.pixelRange, - }, - ); - const material = new THREE.MeshBasicNodeMaterial({ - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = shader.position; - material.colorNode = shader.color; - material.opacityNode = shader.opacity; - const allAttributes = Object.entries(attributes); - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: instanceStorageBytes(Object.values(attributes)), - material, - update(next) { - for (const range of next.dirtyRanges) writeMsdfStorage(next, arrays, range.start, range.count); - markStorageRanges(attributes.geometry, arrays.geometry, next.dirtyRanges); - markStorageRanges(attributes.uv, arrays.uv, next.dirtyRanges); - markStorageRanges(attributes.bounds, arrays.bounds, next.dirtyRanges); - markStorageRanges(attributes.fill, arrays.fill, next.dirtyRanges); - markStorageRanges(attributes.outline, arrays.outline, next.dirtyRanges); - markStorageRanges(attributes.shadow, arrays.shadow, next.dirtyRanges); - markStorageRanges(attributes.effects, arrays.effects, next.dirtyRanges); - }, - geometry(count) { - const result = unitQuad(); - result.instanceCount = count; - for (const [name, attribute] of allAttributes) result.setAttribute(`_pmndrsText_${name}`, attribute); - return result; - }, - dispose() { - material.dispose(); - }, - }; -} - -interface MsdfTargetArrays { - readonly geometry: Float32Array; - readonly uv: Float32Array; - readonly bounds: Float32Array; - readonly fill: Float32Array; - readonly outline: Float32Array; - readonly shadow: Float32Array; - readonly effects: Float32Array; -} - -function writeMsdfStorage( - batch: PreparedGlyphBatch, - arrays: MsdfTargetArrays, - start: number, - count: number, -): void { - const storage = batch.storage; - for (let index = start; index < start + count; index += 1) { - const pair = index * 2; - const vector = index * 4; - arrays.geometry.set(storage.origins.subarray(pair, pair + 2), vector); - arrays.geometry.set(storage.sizes.subarray(pair, pair + 2), vector + 2); - arrays.uv.set(storage.uvOrigins.subarray(pair, pair + 2), vector); - arrays.uv.set(storage.uvSizes.subarray(pair, pair + 2), vector + 2); - arrays.bounds.set(storage.uvBounds.subarray(vector, vector + 4), vector); - arrays.fill.set(storage.fillColors.subarray(vector, vector + 4), vector); - arrays.outline.set(storage.outlineColors.subarray(vector, vector + 4), vector); - arrays.shadow.set(storage.shadowColors.subarray(vector, vector + 4), vector); - arrays.effects[vector] = storage.shadowOffsets[pair]!; - arrays.effects[vector + 1] = storage.shadowOffsets[pair + 1]!; - arrays.effects[vector + 2] = storage.outlineWidths[index]!; - arrays.effects[vector + 3] = storage.pageIndices[index]!; - } -} - -function markStorageRanges( - attribute: THREE.StorageInstancedBufferAttribute, - source: Float32Array, - ranges: readonly { readonly start: number; readonly count: number }[], -): void { - if (ranges.length === 0) return; - const target = attribute.array as Float32Array; - attribute.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * 4; - const count = range.count * 4; - target.set(source.subarray(start, start + count), start); - attribute.addUpdateRange(start, count); - } - attribute.needsUpdate = true; - invalidatePboTexture(attribute); -} - -function floatStorage(array: Float32Array, itemSize: number): THREE.StorageInstancedBufferAttribute { - const attribute = new THREE.StorageInstancedBufferAttribute(new Float32Array(array), itemSize); - attribute.setUsage(THREE.DynamicDrawUsage); - attribute.needsUpdate = true; - return attribute; -} - -function unitQuad(): THREE.InstancedBufferGeometry { - const geometry = new THREE.InstancedBufferGeometry(); - geometry.setAttribute( - 'position', - new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0], 3), - ); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); - return geometry; -} - -function disposeStaged(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) draw.geometry.dispose(); - for (const resource of resources) resource.dispose(); -} diff --git a/packages/text/src/three/msdf.ts b/packages/text/src/three/msdf.ts index 013f94b1..74051134 100644 --- a/packages/text/src/three/msdf.ts +++ b/packages/text/src/three/msdf.ts @@ -1,7 +1 @@ -import { msdf } from '../raster/msdf.js'; -import { ThreeMsdfTarget, type ThreeMsdfTargetOwner } from './msdf-target.js'; -import { registerThreeRasterProgram } from './program-registry.js'; - -registerThreeRasterProgram(msdf, (owner: ThreeMsdfTargetOwner) => new ThreeMsdfTarget(owner)); - export * from '../raster/msdf.js'; diff --git a/packages/text/src/three/program-registry.ts b/packages/text/src/three/program-registry.ts deleted file mode 100644 index 9def05f7..00000000 --- a/packages/text/src/three/program-registry.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type * as THREE from 'three/webgpu'; - -import type { ParagraphBatchTarget, ParagraphBatchTargetRevision } from '../paragraph-batch-attachment.js'; -import type { ParagraphId } from '../paragraph-batch.js'; -import type { AnyRasterTechnique, RasterTechniqueId } from '../raster-technique.js'; - -/** - * The scene-side state a Three raster program needs to place its draws. Every first-party target and every third-party - * program receives exactly this view; a program never reaches the owning `Text` or `TextGroup`. - */ -export interface ThreeRasterTargetOwner { - objectForParagraph(paragraph: ParagraphId): THREE.Object3D; - readonly renderOrderBase: number; -} - -/** - * Optional GPU accounting a Three target may report: the bytes of the engine resources it currently retains. Only the - * target knows its realized allocation, so a portable technique never reports one; a third-party program may omit it. - */ -export interface ThreeRasterTargetAccounting { - readonly gpuBytes?: number; -} - -/** - * Builds the Three target that realizes one technique's prepared glyph batches as engine resources and draws. Core - * owns partitioning, packing, and ordering; a program owns shaders, pipelines, and final draw compilation. - */ -export type ThreeRasterProgram = ( - owner: ThreeRasterTargetOwner, -) => ParagraphBatchTarget & ThreeRasterTargetAccounting; - -const programs = new Map(); - -/** - * Registers the Three program for a raster technique, keyed by the technique's stable identifier rather than its object - * identity. Identifier keying lets an application wrap a technique — to instrument its runtime baker, for example — - * without losing the ability to render it. - * - * The technique is inferred, so a program may type its prepared batches, storage, and binding concretely; the registry - * itself is heterogeneous and holds the erased form, having already proven the pairing at this call. - */ -export function registerThreeRasterProgram( - technique: Technique, - program: ThreeRasterProgram, -): void { - const erased = program as ThreeRasterProgram; - const existing = programs.get(technique.id); - if (existing !== undefined && existing !== erased) { - throw new TypeError(`a different Three raster program is already registered for "${technique.id}"`); - } - programs.set(technique.id, erased); -} - -/** Resolves the registered Three program for a technique, or `undefined` when no program has been registered. */ -export function threeRasterProgram(technique: AnyRasterTechnique): ThreeRasterProgram | undefined { - return programs.get(technique.id); -} diff --git a/packages/text/src/three/retained-target.ts b/packages/text/src/three/retained-target.ts deleted file mode 100644 index 32186750..00000000 --- a/packages/text/src/three/retained-target.ts +++ /dev/null @@ -1,156 +0,0 @@ -import * as THREE from 'three/webgpu'; - -import type { - GlyphBatchKey, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '../paragraph-batch.js'; -import type { ParagraphBatchTargetRevision } from '../paragraph-batch-attachment.js'; -import type { AnyRasterTechnique } from '../raster-technique.js'; - -export interface RetainedThreeTargetResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - update(batch: PreparedGlyphBatch): void; - dispose(): void; -} - -export interface RetainedThreeRunIdentity { - readonly paragraph: ParagraphId; - readonly batch: GlyphBatchKey; -} - -export interface TransferredThreeTargetState< - Technique extends AnyRasterTechnique, - Resource extends RetainedThreeTargetResource, -> { - readonly draws: readonly THREE.Mesh[]; - readonly resources: ReadonlyMap; - readonly runIdentities: readonly RetainedThreeRunIdentity[]; -} - -export class RetainedThreeTargetRevision< - Technique extends AnyRasterTechnique, - Resource extends RetainedThreeTargetResource, -> implements ParagraphBatchTargetRevision { - readonly sourceRevision: number; - readonly draws: readonly THREE.Mesh[]; - readonly #resources: ReadonlyMap; - readonly #runIdentities: readonly RetainedThreeRunIdentity[]; - #transferred = false; - #disposed = false; - - constructor( - sourceRevision: number, - draws: readonly THREE.Mesh[], - resources: ReadonlyMap, - runIdentities: readonly RetainedThreeRunIdentity[], - ) { - this.sourceRevision = sourceRevision; - this.draws = draws; - this.#resources = resources; - this.#runIdentities = runIdentities; - } - - get gpuBytes(): number { - if (this.#disposed || this.#transferred) return 0; - let bytes = 0; - for (const resource of this.#resources.values()) bytes += resource.gpuBytes; - return bytes; - } - - setRenderOrderBase(base: number): void { - for (let index = 0; index < this.draws.length; index += 1) this.draws[index]!.renderOrder = base + index; - } - - canReuse(next: PreparedParagraphBatchRevision): boolean { - if (this.#disposed || this.#transferred || next.glyphBatches.length !== this.#resources.size) return false; - for (const batch of next.glyphBatches) { - const resource = this.#resources.get(batch.key); - if (resource === undefined || resource.capacity !== batch.capacity) return false; - } - if (next.glyphRuns.length !== this.#runIdentities.length) return false; - return next.glyphRuns.every((run, index) => { - const identity = this.#runIdentities[index]; - return identity?.paragraph === run.paragraph && identity.batch === run.batch; - }); - } - - transfer( - next: PreparedParagraphBatchRevision, - renderOrderBase: number, - ): TransferredThreeTargetState { - if (!this.canReuse(next)) throw new Error('Three target revision is not compatible for reuse'); - for (const batch of next.glyphBatches) this.#resources.get(batch.key)!.update(batch); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const draw = this.draws[index]!; - draw.userData.pmndrsTextRunStart = run.start; - (draw.geometry as THREE.InstancedBufferGeometry).instanceCount = run.count; - draw.renderOrder = renderOrderBase + index; - } - this.#transferred = true; - return { draws: this.draws, resources: this.#resources, runIdentities: this.#runIdentities }; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - if (this.#transferred) return; - for (const draw of this.draws) { - draw.removeFromParent(); - draw.geometry.dispose(); - } - for (const resource of this.#resources.values()) resource.dispose(); - } -} - -/** - * GPU residency of one retained Three target: the resources it shares across batches plus the instance buffers its - * committed revision owns. A revision that has transferred its resources to a successor reports nothing, so a warm - * commit never counts the same buffers twice. - */ -export class RetainedThreeGpuBytes< - Technique extends AnyRasterTechnique, - Resource extends RetainedThreeTargetResource, -> { - #shared = 0; - #revision: RetainedThreeTargetRevision | undefined; - - get total(): number { - return this.#shared + (this.#revision?.gpuBytes ?? 0); - } - - addShared(bytes: number): void { - this.#shared += bytes; - } - - retain>(revision: Revision): Revision { - this.#revision = revision; - return revision; - } - - release(): void { - this.#shared = 0; - this.#revision = undefined; - } -} - -export function instanceStorageBytes(attributes: Iterable): number { - let bytes = 0; - for (const attribute of attributes) bytes += attribute.array.byteLength; - return bytes; -} - -export function retainedRunIdentities( - revision: PreparedParagraphBatchRevision, -): readonly RetainedThreeRunIdentity[] { - return revision.glyphRuns.map((run) => ({ paragraph: run.paragraph, batch: run.batch })); -} - -export function invalidatePboTexture(attribute: THREE.StorageInstancedBufferAttribute): void { - const pbo = (attribute as THREE.StorageInstancedBufferAttribute & { pbo?: THREE.DataTexture }).pbo; - if (pbo !== undefined) pbo.needsUpdate = true; -} diff --git a/packages/text/src/three/slug-shader.ts b/packages/text/src/three/slug-shader.ts index a91fefab..26fb4b7c 100644 --- a/packages/text/src/three/slug-shader.ts +++ b/packages/text/src/three/slug-shader.ts @@ -94,7 +94,7 @@ export interface ThreeSlugShaderOutput { } /** - * Builds the canonical Slug node graph. This is the exact graph `ThreeSlugTarget` renders, so a program that composes + * Builds the canonical Slug node graph. This is the exact graph the command-buffer executor renders, so a program that composes * over the returned nodes inherits the technique's band walk, quadratic solve, and antialiasing footprint. * * `position` and `coverage` are two halves of one graph: the vertex half writes the varying the fragment half diff --git a/packages/text/src/three/slug-target.ts b/packages/text/src/three/slug-target.ts deleted file mode 100644 index 75b8092b..00000000 --- a/packages/text/src/three/slug-target.ts +++ /dev/null @@ -1,428 +0,0 @@ -import * as TSL from 'three/tsl'; -import * as THREE from 'three/webgpu'; -import type { UniformNode } from 'three/webgpu'; - -import type { - GlyphBatchKey, - ParagraphId, - PreparedGlyphBatch, - PreparedParagraphBatchRevision, -} from '../paragraph-batch.js'; -import type { ParagraphBatchTarget, ParagraphBatchTargetUpdate } from '../paragraph-batch-attachment.js'; -import { slug, type SlugPageData } from '../raster/slug-technique.js'; -import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; -import { - instanceStorageBytes, - invalidatePboTexture, - retainedRunIdentities, - RetainedThreeGpuBytes, - RetainedThreeTargetRevision, - type RetainedThreeTargetResource, -} from './retained-target.js'; - -export interface ThreeSlugTargetOwner { - objectForParagraph(paragraph: ParagraphId): THREE.Object3D; - readonly renderOrderBase: number; -} - -interface ThreeSlugPage extends ThreeSlugPageResources { - readonly curveHeight: number; - readonly headerHeight: number; - readonly referenceHeight: number; - dispose(): void; -} - -interface SlugTargetResource extends RetainedThreeTargetResource { - readonly key: GlyphBatchKey; - readonly capacity: number; - readonly gpuBytes: number; - readonly material: THREE.MeshBasicNodeMaterial; - readonly viewport: UniformNode<'vec2', THREE.Vector2>; - readonly mvpRow0: UniformNode<'vec4', THREE.Vector4>; - readonly mvpRow1: UniformNode<'vec4', THREE.Vector4>; - readonly mvpRow3: UniformNode<'vec4', THREE.Vector4>; - update(batch: PreparedGlyphBatch): void; - geometry(count: number): THREE.InstancedBufferGeometry; - dispose(): void; -} - -const drawingBufferSize = new THREE.Vector2(); -const modelViewProjectionMatrix = new THREE.Matrix4(); - -export class ThreeSlugTargetRevision extends RetainedThreeTargetRevision {} - -export class ThreeSlugTarget implements ParagraphBatchTarget { - readonly technique: typeof slug = slug; - readonly #owner: ThreeSlugTargetOwner; - readonly #pages = new Map(); - readonly #gpuBytes = new RetainedThreeGpuBytes(); - #disposed = false; - - constructor(owner: ThreeSlugTargetOwner) { - this.#owner = owner; - } - - get gpuBytes(): number { - return this.#gpuBytes.total; - } - - stage( - previous: ThreeSlugTargetRevision | undefined, - next: PreparedParagraphBatchRevision, - ): ParagraphBatchTargetUpdate { - if (this.#disposed) throw new Error('Three Slug target has been disposed'); - if (previous?.canReuse(next) === true) { - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three Slug stage is no longer active'); - finished = true; - const state = previous.transfer(next, this.#owner.renderOrderBase); - return this.#gpuBytes.retain( - new ThreeSlugTargetRevision(next.revision, state.draws, state.resources, state.runIdentities), - ); - }, - abort: () => { - finished = true; - }, - }, - }; - } - const resources = new Map(); - const draws: THREE.Mesh[] = []; - try { - for (const batch of next.glyphBatches) resources.set(batch.key, this.#createResource(batch)); - for (let index = 0; index < next.glyphRuns.length; index += 1) { - const run = next.glyphRuns[index]!; - const resource = resources.get(run.batch); - if (resource === undefined) throw new Error('Slug run references an unknown physical batch'); - const mesh = new THREE.Mesh(resource.geometry(run.count), resource.material); - mesh.userData.pmndrsTextRunStart = run.start; - mesh.frustumCulled = false; - mesh.renderOrder = this.#owner.renderOrderBase + index; - mesh.onBeforeRender = (renderer, _scene, camera): void => { - renderer.getDrawingBufferSize(drawingBufferSize); - resource.viewport.value.copy(drawingBufferSize); - updateMvpUniforms(resource, mesh, camera); - }; - draws.push(mesh); - } - let finished = false; - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit: () => { - if (finished) throw new Error('Three Slug stage is no longer active'); - finished = true; - for (let index = 0; index < draws.length; index += 1) - this.#owner.objectForParagraph(next.glyphRuns[index]!.paragraph).add(draws[index]!); - return this.#gpuBytes.retain( - new ThreeSlugTargetRevision(next.revision, draws, resources, retainedRunIdentities(next)), - ); - }, - abort: () => { - if (finished) return; - finished = true; - disposeStaged(draws, resources.values()); - }, - }, - }; - } catch (error) { - disposeStaged(draws, resources.values()); - throw error; - } - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const page of this.#pages.values()) page.dispose(); - this.#pages.clear(); - this.#gpuBytes.release(); - } - - #createResource(batch: PreparedGlyphBatch): SlugTargetResource { - const page = batch.font.data.pages[batch.binding.page]; - if (page === undefined) throw new TypeError('Slug binding references a missing decoded page'); - return createSlugTargetResource(batch, this.#page(page)); - } - - #page(data: SlugPageData): ThreeSlugPage { - let page = this.#pages.get(data.resource); - if (page !== undefined) return page; - const curves = ownedUint16(data.curveBytes); - const headers = ownedUint32(data.headerBytes); - const packedReferences = packReferencePairs(ownedUint16(data.referenceBytes), data.referenceWidth); - const curveTexture = dataTexture(curves, data.curveWidth, data.curveHeight, THREE.RGBAFormat, THREE.HalfFloatType); - const headerTexture = dataTexture( - headers, - data.headerWidth, - data.headerHeight, - THREE.RedIntegerFormat, - THREE.UnsignedIntType, - ); - const referenceTexture = dataTexture( - packedReferences.data, - packedReferences.width, - packedReferences.height, - THREE.RedIntegerFormat, - THREE.UnsignedIntType, - ); - page = { - curveTexture, - curveWidth: data.curveWidth, - curveHeight: data.curveHeight, - headerTexture, - headerWidth: data.headerWidth, - headerHeight: data.headerHeight, - referenceTexture, - referenceWidth: packedReferences.width, - referenceHeight: packedReferences.height, - dispose() { - curveTexture.dispose(); - headerTexture.dispose(); - referenceTexture.dispose(); - }, - }; - this.#pages.set(data.resource, page); - this.#gpuBytes.addShared(curves.byteLength + headers.byteLength + packedReferences.data.byteLength); - return page; - } -} - -function createSlugTargetResource(batch: PreparedGlyphBatch, page: ThreeSlugPage): SlugTargetResource { - const geometryValues = new Float32Array(batch.capacity * 4); - const emValues = new Float32Array(batch.capacity * 4); - const bandValues = new Float32Array(batch.capacity * 4); - const colorValues = new Float32Array(batch.capacity * 4); - const scalarValues = new Float32Array(batch.capacity * 4); - const addressValues = new Uint32Array(batch.capacity * 4); - const countValues = new Uint32Array(batch.capacity * 4); - const arrays = { - geometry: geometryValues, - em: emValues, - band: bandValues, - color: colorValues, - scalar: scalarValues, - address: addressValues, - count: countValues, - }; - writeSlugStorageRange(batch, arrays, 0, batch.instanceCount); - const attributes = { - geometry: storageAttribute(geometryValues), - em: storageAttribute(emValues), - band: storageAttribute(bandValues), - color: storageAttribute(colorValues), - scalar: storageAttribute(scalarValues), - address: storageAttribute(addressValues), - count: storageAttribute(countValues), - }; - const runStart = TSL.uniform(0, 'uint').onObjectUpdate( - ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, - ); - const instance = TSL.instanceIndex.add(runStart); - const geometry = TSL.storage(attributes.geometry, 'vec4', attributes.geometry.count).setPBO(true).element(instance); - const em = TSL.storage(attributes.em, 'vec4', attributes.em.count).setPBO(true).element(instance); - const bandTransform = TSL.storage(attributes.band, 'vec4', attributes.band.count).setPBO(true).element(instance); - const color = TSL.storage(attributes.color, 'vec4', attributes.color.count).setPBO(true).element(instance); - const inverseScale = TSL.storage(attributes.scalar, 'vec4', attributes.scalar.count).setPBO(true).element(instance).x; - const addresses = TSL.storage(attributes.address, 'uvec4', attributes.address.count).setPBO(true).element(instance); - const counts = TSL.storage(attributes.count, 'uvec4', attributes.count.count).setPBO(true).element(instance); - const viewport: UniformNode<'vec2', THREE.Vector2> = TSL.uniform(new THREE.Vector2(1, 1)); - const mvpRow0: UniformNode<'vec4', THREE.Vector4> = TSL.uniform(new THREE.Vector4(1, 0, 0, 0)); - const mvpRow1: UniformNode<'vec4', THREE.Vector4> = TSL.uniform(new THREE.Vector4(0, 1, 0, 0)); - const mvpRow3: UniformNode<'vec4', THREE.Vector4> = TSL.uniform(new THREE.Vector4(0, 0, 0, 1)); - const shader = slugShader( - { - origin: geometry.xy, - size: geometry.zw, - emOrigin: em.xy, - emSize: em.zw, - inverseScale, - color, - bandTransform, - curveBaseTexel: addresses.x, - horizontalHeaderBase: addresses.y, - verticalHeaderBase: addresses.z, - referenceBase: addresses.w, - horizontalBandCount: counts.x, - verticalBandCount: counts.y, - }, - { - page, - viewport, - modelViewProjectionRow0: mvpRow0, - modelViewProjectionRow1: mvpRow1, - modelViewProjectionRow3: mvpRow3, - }, - ); - const material = new THREE.MeshBasicNodeMaterial({ - blending: THREE.NormalBlending, - depthTest: false, - depthWrite: false, - side: THREE.DoubleSide, - transparent: true, - }); - material.positionNode = shader.position; - material.colorNode = shader.color; - material.opacityNode = shader.opacity; - const allAttributes = Object.entries(attributes); - return { - key: batch.key, - capacity: batch.capacity, - gpuBytes: instanceStorageBytes(Object.values(attributes)), - material, - viewport, - mvpRow0, - mvpRow1, - mvpRow3, - update(next) { - for (const range of next.dirtyRanges) writeSlugStorageRange(next, arrays, range.start, range.count); - markStorageRanges(attributes.geometry, arrays.geometry, next.dirtyRanges); - markStorageRanges(attributes.em, arrays.em, next.dirtyRanges); - markStorageRanges(attributes.band, arrays.band, next.dirtyRanges); - markStorageRanges(attributes.color, arrays.color, next.dirtyRanges); - markStorageRanges(attributes.scalar, arrays.scalar, next.dirtyRanges); - markStorageRanges(attributes.address, arrays.address, next.dirtyRanges); - markStorageRanges(attributes.count, arrays.count, next.dirtyRanges); - }, - geometry(count) { - const result = unitQuad(); - result.instanceCount = count; - for (const [name, attribute] of allAttributes) result.setAttribute(`_pmndrsText_${name}`, attribute); - return result; - }, - dispose() { - material.dispose(); - }, - }; -} - -interface SlugTargetArrays { - readonly geometry: Float32Array; - readonly em: Float32Array; - readonly band: Float32Array; - readonly color: Float32Array; - readonly scalar: Float32Array; - readonly address: Uint32Array; - readonly count: Uint32Array; -} - -function writeSlugStorageRange( - batch: PreparedGlyphBatch, - arrays: SlugTargetArrays, - start: number, - count: number, -): void { - const storage = batch.storage; - for (let index = start; index < start + count; index += 1) { - const pair = index * 2; - const vector = index * 4; - arrays.geometry.set(storage.origins.subarray(pair, pair + 2), vector); - arrays.geometry.set(storage.sizes.subarray(pair, pair + 2), vector + 2); - arrays.em.set(storage.emOrigins.subarray(pair, pair + 2), vector); - arrays.em.set(storage.emSizes.subarray(pair, pair + 2), vector + 2); - arrays.band.set(storage.bandTransforms.subarray(vector, vector + 4), vector); - arrays.color.set(storage.colors.subarray(vector, vector + 4), vector); - arrays.scalar[vector] = storage.inverseScales[index]!; - arrays.address[vector] = storage.curveBases[index]!; - arrays.address[vector + 1] = storage.horizontalHeaderBases[index]!; - arrays.address[vector + 2] = storage.verticalHeaderBases[index]!; - arrays.address[vector + 3] = storage.referenceBases[index]!; - arrays.count[vector] = storage.horizontalBandCounts[index]!; - arrays.count[vector + 1] = storage.verticalBandCounts[index]!; - } -} - -function markStorageRanges( - attribute: THREE.StorageInstancedBufferAttribute, - source: Float32Array | Uint32Array, - ranges: readonly { readonly start: number; readonly count: number }[], -): void { - if (ranges.length === 0) return; - const target = attribute.array as Float32Array | Uint32Array; - attribute.clearUpdateRanges(); - for (const range of ranges) { - const start = range.start * 4; - const count = range.count * 4; - target.set(source.subarray(start, start + count), start); - attribute.addUpdateRange(start, count); - } - attribute.needsUpdate = true; - invalidatePboTexture(attribute); -} - -function updateMvpUniforms(resource: SlugTargetResource, object: THREE.Object3D, camera: THREE.Camera): void { - modelViewProjectionMatrix.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); - modelViewProjectionMatrix.multiply(object.matrixWorld); - const values = modelViewProjectionMatrix.elements; - resource.mvpRow0.value.set(values[0]!, values[4]!, values[8]!, values[12]!); - resource.mvpRow1.value.set(values[1]!, values[5]!, values[9]!, values[13]!); - resource.mvpRow3.value.set(values[3]!, values[7]!, values[11]!, values[15]!); -} - -function storageAttribute(array: Float32Array | Uint32Array): THREE.StorageInstancedBufferAttribute { - const attribute = new THREE.StorageInstancedBufferAttribute(array, 4); - attribute.setUsage(THREE.DynamicDrawUsage); - attribute.needsUpdate = true; - return attribute; -} - -function dataTexture( - data: Uint16Array | Uint32Array, - width: number, - height: number, - format: THREE.PixelFormat, - type: THREE.TextureDataType, -): THREE.DataTexture { - const texture = new THREE.DataTexture(data, width, height, format, type); - texture.colorSpace = THREE.NoColorSpace; - texture.flipY = false; - texture.generateMipmaps = false; - texture.minFilter = THREE.NearestFilter; - texture.magFilter = THREE.NearestFilter; - texture.needsUpdate = true; - return texture; -} - -function ownedUint16(bytes: Uint8Array): Uint16Array { - const copy = bytes.slice(); - return new Uint16Array(copy.buffer, copy.byteOffset, copy.byteLength / 2); -} - -function ownedUint32(bytes: Uint8Array): Uint32Array { - const copy = bytes.slice(); - return new Uint32Array(copy.buffer, copy.byteOffset, copy.byteLength / 4); -} - -function packReferencePairs( - references: Uint16Array, - preferredWidth: number, -): { readonly data: Uint32Array; readonly width: number; readonly height: number } { - const texelCount = Math.ceil(references.length / 2); - const width = Math.min(preferredWidth, texelCount); - const height = Math.ceil(texelCount / width); - const data = new Uint32Array(width * height); - for (let index = 0; index < references.length; index += 1) - data[index >>> 1] = data[index >>> 1]! | (references[index]! << ((index & 1) * 16)); - return { data, width, height }; -} - -function unitQuad(): THREE.InstancedBufferGeometry { - const geometry = new THREE.InstancedBufferGeometry(); - geometry.setAttribute( - 'position', - new THREE.Float32BufferAttribute([0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0, 0, 1, 1, 0, 0, 1, 0], 3), - ); - geometry.setAttribute('uv', new THREE.Float32BufferAttribute([0, 0, 1, 0, 1, 1, 0, 0, 1, 1, 0, 1], 2)); - return geometry; -} - -function disposeStaged(draws: readonly THREE.Mesh[], resources: Iterable): void { - for (const draw of draws) draw.geometry.dispose(); - for (const resource of resources) resource.dispose(); -} diff --git a/packages/text/src/three/slug.ts b/packages/text/src/three/slug.ts index df5bc303..d54da1ef 100644 --- a/packages/text/src/three/slug.ts +++ b/packages/text/src/three/slug.ts @@ -1,7 +1 @@ -import { slug } from '../raster/slug-technique.js'; -import { registerThreeRasterProgram } from './program-registry.js'; -import { ThreeSlugTarget, type ThreeSlugTargetOwner } from './slug-target.js'; - -registerThreeRasterProgram(slug, (owner: ThreeSlugTargetOwner) => new ThreeSlugTarget(owner)); - export * from '../raster/slug-technique.js'; From e8a2de758f8ca4c1f143a92aeb1bffd73290360c Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 04:48:21 -0400 Subject: [PATCH 081/128] fix(benchmarks): assert Rust glyph workload --- docs/log.md | 8 +++++++ docs/packages/text.md | 10 +++++++- docs/planning/rust-layout-engine.md | 13 +++++++++++ .../scripts/benchmark-rust-layout-engine.mjs | 23 +++++++++++++++---- 4 files changed, 49 insertions(+), 5 deletions(-) diff --git a/docs/log.md b/docs/log.md index 04c98745..22fcd796 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Made the 25,515-glyph Rust benchmark self-validating** — The result header's `primitiveCount` counts primitive-table + rows, so the benchmark's former “1 renderable instance” label did not prove the workload even though its 1–2.4 MiB + writes showed full packing. It now sums glyph primitive `recordCount` values and rejects an undersized plan. On the + unchanged `--glyphs 22000` fixture, Rust publishes 21,805 renderable records from 25,515 positioned TypeScript glyphs. + Five-warmup/11-sample Bitmap/MTSDF/Slug resize medians are 3.779/4.345/5.082 ms with p95 + 4.156/4.951/5.679 ms, versus TypeScript's 8.33 ms median. Rust wins, but all policies still fail the sub-4 ms p95 gate; + isolated warm-session Wasm high-water marks of 64.75/77.75/78.56 MiB also remain open rather than accepted costs. + - **Deleted the redundant Three paragraph-target transaction** — The Rust command buffer is now the sole render-state transition authority. Removed the candidate/current `ThreeBitmapTarget`, `ThreeMtsdfTarget`, `ThreeSlugTarget`, retained revision, and old renderer-program registry; the executor keeps only GPU resource/draw/material tables, synchronization, diff --git a/docs/packages/text.md b/docs/packages/text.md index 1fa1ab71..e505a04a 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:daaf3807f91b929a868a11d00f29a98b4259ccdb8fd4d229449f14d54777e69a' +source_digest: 'sha256:8098207158fd7bf3bbe4f7c17b68fdbbde4bcaae415503857b145e74a85434e7' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -956,6 +956,14 @@ and clears the old presentation override. The complete optimized shaper at this 414,204 gzip, and 325,805 Brotli bytes on the canonical Darwin arm64 host; deletion of the remaining legacy TypeScript path and a deliberate Rust size pass remain required before release acceptance. +The canonical Rust benchmark now derives its glyph workload from the published plan's glyph primitive record counts and +rejects an undersized run; `primitiveCount` is the number of primitive-table rows, not glyphs. With the unchanged +`--glyphs 22000` fixture, five warmups, and 11 samples, Rust produces 21,805 renderable records from the TypeScript +fixture's 25,515 positioned glyphs. Bitmap/MTSDF/Slug column-resize medians are 3.779/4.345/5.082 ms and p95 values are +4.156/4.951/5.679 ms. The same TypeScript width path measures 8.33 ms median. Rust therefore beats TypeScript, but no +technique yet passes the required sub-4 ms p95 gate; the measurement also stops before Three patch application and GPU +submission. Isolated warm-session Wasm high-water marks of 64.75/77.75/78.56 MiB likewise remain an optimization gate. + Public font stacks no longer repeat a raster technique or require every fallback font to share one. Their generic type is the union of the concrete loaded-font techniques, while runtime construction still requires one text-runtime domain, unique font identities, and live font leases. Public Three `TextGroup` likewise has no authored technique: its retained diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index a153e5b3..347832dc 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1262,6 +1262,19 @@ paragraph-keyed mutation/removal and atomic child commit remain the next impleme Bitmap column-resize medians are 4.083 and 4.078 ms at 8 warmups/31 samples with 5.8%/6.1% RSD, so this slice makes no speedup claim and shows no material regression. Optimized Wasm is 1,070,685 / 402,154 / 319,914 raw/gzip/Brotli bytes. +After the public Three command-buffer cutover and deletion of its parallel target transaction, the canonical +`--glyphs 22000` workload was revalidated with five warmups and 11 samples. The benchmark now sums glyph primitive +`recordCount` values from the published Rust plan and rejects a workload below 95% of the requested target; the former +`primitiveCount` label counted primitive-table rows and misleadingly reported one. The unchanged fixture produces 25,515 +positioned TypeScript glyphs and 21,805 Rust renderable records. TypeScript cold/font-size/width/text medians are +61.29/11.40/8.33/39.15 ms. Complete Rust `text_update` plus Bitmap plan publication measures +14.77/4.95/3.78/14.43 ms for cold/font-size/column-resize/suffix-edit, MTSDF measures +15.37/5.35/4.35/14.84 ms, and Slug measures 15.43/6.11/5.08/15.61 ms. All three beat the TypeScript implementation; +none closes the required resize p95 below 4 ms (4.16/4.95/5.68 ms). This scope includes request copying into retained +Wasm memory and publication of exact plan bytes, but not Three patch application or GPU submission. The sequential +process reaches 86.50/100.88/101.69 MiB Wasm high-water marks; isolated warm sessions still reach 64.75/77.75/78.56 +MiB. Both latency and memory right-sizing remain foundation gates rather than accepted release costs. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index d15de2ea..00876f09 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -83,7 +83,7 @@ function measureCold() { for (let index = 0; index < options.warmup + options.repetitions; index += 1) { createSession(initial.byteLength); const result = execute(initial, true); - glyphs = result.primitiveCount; + glyphs = result.glyphCount; if (index >= options.warmup) { samples.push(result.durationMs); plans.push(result); @@ -96,7 +96,7 @@ function measureCold() { function measureWarm(name) { createSession(initial.byteLength); let state = execute(initial, true); - const livePrimitiveCount = state.primitiveCount; + const liveGlyphCount = state.glyphCount; const localizedText = [...utf16]; let suffixLength = utf16.length; const samples = []; @@ -152,7 +152,7 @@ function measureWarm(name) { } } requireStatus(fn.disposeSession(sessionId), `dispose ${name} session`); - return summarize(name, livePrimitiveCount, samples, plans); + return summarize(name, liveGlyphCount, samples, plans); } function createSession(requestCapacity) { @@ -202,12 +202,24 @@ function execute(bytes, allowGrowth = false, operation = 'text_update') { writeBytes += patch.getUint32(patchLayout.byteLength, true); } } + const primitiveCount = result.getUint32(layout.primitiveCount, true); + const primitivesOffset = result.getUint32(layout.primitivesOffset, true); + const primitiveLayout = abi.layouts.enginePrimitive; + let glyphCount = 0; + for (let index = 0; index < primitiveCount; index += 1) { + const at = resultPointer + primitivesOffset + index * primitiveLayout.size; + const primitive = new DataView(memory.buffer, at, primitiveLayout.size); + if (primitive.getUint8(primitiveLayout.kind) === abi.engine.primitiveKinds.glyph) { + glyphCount += primitive.getUint16(primitiveLayout.recordCount, true); + } + } return { durationMs, engineRevision: result.getUint32(layout.engineRevision, true), planRevision: result.getUint32(layout.planRevision, true), publicationGeneration: result.getUint32(layout.publicationGeneration, true), - primitiveCount: result.getUint32(layout.primitiveCount, true), + primitiveCount, + glyphCount, patchCount, writeBytes, }; @@ -267,6 +279,9 @@ function registerPolicy() { } function summarize(name, glyphs, samples, plans) { + if (glyphs < Math.floor(options.glyphs * 0.95)) { + throw new Error(`benchmark planned only ${glyphs} glyph records for a ${options.glyphs}-glyph fixture target`); + } const sorted = samples.toSorted((left, right) => left - right); const patchCounts = plans.map((plan) => plan.patchCount).toSorted((left, right) => left - right); const writeBytes = plans.map((plan) => plan.writeBytes).toSorted((left, right) => left - right); From 13a63b3de7061a94fa7197e04a9f649011d7d01f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 05:22:57 -0400 Subject: [PATCH 082/128] fix(text): grow command buffer publications --- docs/log.md | 7 ++ docs/packages/text.md | 13 ++- docs/planning/decision-register.md | 1 + docs/planning/rust-layout-engine.md | 9 ++ .../text/src/internal/text-engine-host.ts | 97 +++++++++++-------- packages/text/src/three/text.ts | 11 ++- .../integration/three-engine-runtime.test.mjs | 10 +- 7 files changed, 101 insertions(+), 47 deletions(-) diff --git a/docs/log.md b/docs/log.md index 22fcd796..4d6079d5 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-09 +- **Made cold command-buffer growth recover instead of failing the benchmark scene** — Decoupled the 64 MiB output + safety limit from the smaller retained A/B arenas. Rust's exact required-result watermark now drives one bounded cold + reserve/retry; the host re-resolves the request pointer and recopies after possible Wasm-memory detachment. A + compiled-Wasm test forces growth from a header-sized arena. The live MTSDF paragraph-stress scene consequently publishes + 11,510 glyphs in one draw instead of status 7 at 1,382,592 bytes. Three settled WebGPU A/B runs rejected an eighth, + split origin/size storage binding: CPU submit was unchanged and median GPU time trended worse, so MTSDF stays packed. + - **Made the 25,515-glyph Rust benchmark self-validating** — The result header's `primitiveCount` counts primitive-table rows, so the benchmark's former “1 renderable instance” label did not prove the workload even though its 1–2.4 MiB writes showed full packing. It now sums glyph primitive `recordCount` values and rejects an undersized plan. On the diff --git a/docs/packages/text.md b/docs/packages/text.md index e505a04a..ef239c9c 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:8098207158fd7bf3bbe4f7c17b68fdbbde4bcaae415503857b145e74a85434e7' +source_digest: 'sha256:f847d77312d4439847462a8d6969bf9b5725f320d1bb9ee3951a8d1f95d11c85' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -190,7 +190,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-09T06:36:53Z' + at: '2026-08-09T09:19:33Z' --- # Package reference: `@pmndrs/text` @@ -964,6 +964,15 @@ fixture's 25,515 positioned glyphs. Bitmap/MTSDF/Slug column-resize medians are technique yet passes the required sub-4 ms p95 gate; the measurement also stops before Three patch application and GPU submission. Isolated warm-session Wasm high-water marks of 64.75/77.75/78.56 MiB likewise remain an optimization gate. +Cold result growth is now a negotiated frame-ABI path rather than a renderer failure. Three declares the engine's 64 MiB +output safety ceiling independently from the smaller retained A/B arenas. If a publication does not fit, Rust aborts the +prepared state and returns the exact required watermark; the host reserves once, re-resolves and recopies the request +after possible Wasm-memory detachment, and retries without advancing a revision. A compiled-Wasm fixture begins with only +the result header and publishes a nonempty plan. In the product benchmark, this changes MTSDF paragraph stress from status +7 at 1,382,592 required bytes to one live draw containing 11,510 glyphs. Three settled hardware-WebGPU A/B samples retain +the seven-`vec4` MTSDF policy: splitting origin and size did not improve CPU submission and trended from 0.748 to 0.767 ms +average median GPU time by adding an eighth storage binding. + Public font stacks no longer repeat a raster technique or require every fallback font to share one. Their generic type is the union of the concrete loaded-font techniques, while runtime construction still requires one text-runtime domain, unique font identities, and live font leases. Public Three `TextGroup` likewise has no authored technique: its retained diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 7f03e890..937e01f9 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -313,6 +313,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-226 | Semantic layout inspection is an explicit demand-shaped Rust query, not renderer input and not a reason to retain the legacy TypeScript layout path. The query uses the existing `semanticViewMask` on `text_update`; zero remains the ordinary rendering request. Its records may share the immutable A/B publication lifetime, but `ThreeTextRenderPlanExecutor` ignores them. The first public `Text.measureLayout()` view emits one paragraph summary plus its line records, performs one extra crossing only on an uncached explicit request, and invalidates on a committed semantic update. Record-level Rust tests cover exact/at-most sizing and overflow, while a real compiled-Wasm Three fixture proves the query leaves `Text.layout` absent and retains the existing mesh. Per-glyph inspection, caret, selection, hit testing, accessibility, and diagnostics remain separate masks and do not enter the render plan by default. | Accepted | | D-227 | Explicit per-glyph inspection and presentation motion do not restore a renderer-side layout or target transaction. `Text.inspectLayout()` requests the Rust sidecar mask only on demand and copies paragraph summary, lines, semantic glyph IDs, clusters, font identity, size, flags, and shaped origins; ordinary render updates still request none of it. The first-party policy deliberately augments each renderable instance with its session-global stable glyph ID in `u32` buffer 14. Three pairs that policy output with the existing origin stream, snapshots renderer-local displayed origins, and permits topology-guarded presentation overrides for Bitmap, MSDF, and Slug. Before applying any later Rust command-buffer delta, the executor restores the captured authoritative origins; the plan then patches, replaces, or retires resources normally. Thus Rust remains the sole render-state transition authority, while Three retains only resource/draw tables and reversible presentation state—no parallel candidate/current `ParagraphBatchTarget` state machine. Rust record tests and compiled-Wasm one- and two-paragraph fixtures prove semantic spaces remain inspectable, stable IDs address shared draws without collision, overrides do not mutate shaped targets, transform-only updates preserve them, and semantic updates retire them. The optimized shaper is 1,089,889 raw / 414,204 gzip / 325,805 Brotli bytes on the canonical Darwin arm64 host. | Accepted | | D-228 | A Rust command buffer makes the generic Three paragraph-target transaction redundant. The deleted `ThreeBitmapTarget`, `ThreeMtsdfTarget`, `ThreeSlugTarget`, `RetainedThreeTargetRevision`, and old raster-program registry no longer stage candidate/current rendering state beside Rust. `ThreeTextRenderPlanExecutor` directly applies the authoritative plan and retains only GPU resources, draw/material caches, synchronization, and reversible presentation state. First-party technique modules no longer register renderer targets as import side effects. The composition proof uses ordinary Bitmap policy packing plus `defineTextMaterial`; custom material code changes the canonical shader output without owning attributes, geometry, layout, or plan transitions. Third-party techniques continue through declarative `registerThreeRasterPlanProgram`. | Accepted | +| D-229 | The frame request's 64 MiB `maxOutputBytes` safety limit is independent of the currently reserved A/B result arenas. When a cold publication exceeds an arena, Rust aborts the prepared update and reports the exact required result watermark without advancing engine, plan, or publication revisions. `TextEngineSession` may reserve once and retry that same request; it re-resolves the request pointer and recopies the bytes after the reserve because Wasm growth can detach every prior view. Warm updates remain one crossing. A compiled-Wasm Three fixture starts with only a result-header-sized arena and must still publish a nonempty plan. The live MTSDF paragraph-stress scene now publishes 11,510 glyphs in one draw instead of failing at 1,382,592 required bytes. An adjacent three-run hardware-WebGPU comparison rejected splitting MTSDF origin and size into two `vec2` bindings: packed and split median CPU submit were both approximately 0.50–0.52 ms, while packed median GPU time averaged 0.748 ms versus 0.767 ms split, so the canonical `vec4` binding remains. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 347832dc..8dc436f7 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1275,6 +1275,15 @@ Wasm memory and publication of exact plan bytes, but not Three patch application process reaches 86.50/100.88/101.69 MiB Wasm high-water marks; isolated warm sessions still reach 64.75/77.75/78.56 MiB. Both latency and memory right-sizing remain foundation gates rather than accepted release costs. +The first product-scale command-buffer scene exposed a missing cold-capacity transition rather than a layout error: +MTSDF paragraph stress required a 1,382,592-byte publication while the retained result arena was smaller. The request's +64 MiB output safety ceiling is now independent of arena capacity. Rust reports the exact watermark after aborting the +prepared update; the host reserves both result slots, re-pins and recopies the request, and retries once. A compiled-Wasm +test forces this path from a header-sized arena, and the live scene publishes 11,510 glyphs in one draw. Warm frames retain +the required one crossing. A three-run settled WebGPU comparison also rejected a policy experiment that split MTSDF +origin and size into separate `vec2` buffers: CPU submission was unchanged, while the additional binding slightly worsened +median GPU time, so the existing packed `vec4` remains. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/src/internal/text-engine-host.ts b/packages/text/src/internal/text-engine-host.ts index 5755ae4b..5792d624 100644 --- a/packages/text/src/internal/text-engine-host.ts +++ b/packages/text/src/internal/text-engine-host.ts @@ -206,52 +206,65 @@ export class TextEngineSession { throw new TypeError('text update request must be a nonempty Uint8Array'); } const requestLength = uint32(request.byteLength, 'text update byte length'); + const initialMemoryBuffer = this.#exports.memory.buffer; if (requestLength > this.#requestCapacity || requestLength > this.#exports.requestCapacity(this.#handle)) { this.reserve(requestLength, this.#resultCapacity); } - const requestPointer = this.#exports.requestPointer(this.#handle); - if (requestPointer === 0) - throw new TextEngineStatusError('resolve text request arena', textShaperAbi.status.sessionMissing); - const before = this.#exports.memory.buffer; - new Uint8Array(before, requestPointer, requestLength).set(request); - const resultPointer = this.#exports.textUpdate(this.#handle, requestPointer, requestLength); - const memoryBuffer = this.#exports.memory.buffer; - if (resultPointer === 0) - throw new TextEngineStatusError('publish text update', textShaperAbi.status.resultTooLarge); - const layout = textShaperAbi.layouts.engineResult; - if (resultPointer + layout.size > memoryBuffer.byteLength) { - throw new RangeError('text engine returned an out-of-bounds result header'); + let retriedResultGrowth = false; + for (;;) { + const requestPointer = this.#exports.requestPointer(this.#handle); + if (requestPointer === 0) + throw new TextEngineStatusError('resolve text request arena', textShaperAbi.status.sessionMissing); + const pinnedMemoryBuffer = this.#exports.memory.buffer; + new Uint8Array(pinnedMemoryBuffer, requestPointer, requestLength).set(request); + const resultPointer = this.#exports.textUpdate(this.#handle, requestPointer, requestLength); + const memoryBuffer = this.#exports.memory.buffer; + if (resultPointer === 0) + throw new TextEngineStatusError('publish text update', textShaperAbi.status.resultTooLarge); + const layout = textShaperAbi.layouts.engineResult; + if (resultPointer + layout.size > memoryBuffer.byteLength) { + throw new RangeError('text engine returned an out-of-bounds result header'); + } + const header = new DataView(memoryBuffer, resultPointer, layout.size); + const status = header.getUint32(layout.status, true); + const requiredRequestCapacity = header.getUint32(layout.requiredRequestCapacity, true); + const requiredResultCapacity = header.getUint32(layout.requiredResultCapacity, true); + if ( + status === textShaperAbi.status.resultTooLarge && + !retriedResultGrowth && + requiredResultCapacity > this.#resultCapacity + ) { + retriedResultGrowth = true; + this.reserve(Math.max(requestLength, requiredRequestCapacity), requiredResultCapacity); + continue; + } + if (status !== textShaperAbi.status.ok) { + throw new TextEngineStatusError('publish text update', status, requiredRequestCapacity, requiredResultCapacity); + } + const byteLength = header.getUint32(layout.byteLength, true); + if (byteLength < layout.size || resultPointer + byteLength > memoryBuffer.byteLength) { + throw new RangeError('text engine returned an out-of-bounds publication'); + } + this.#requestCapacity = header.getUint32(layout.requestCapacity, true); + this.#resultCapacity = header.getUint32(layout.resultCapacity, true); + return { + bytes: new Uint8Array(memoryBuffer, resultPointer, byteLength), + memoryBuffer, + memoryGrew: memoryBuffer !== initialMemoryBuffer, + engineRevision: header.getUint32(layout.engineRevision, true), + planRevision: header.getUint32(layout.planRevision, true), + requiredBaseRevision: header.getUint32(layout.requiredBaseRevision, true), + publicationGeneration: header.getUint32(layout.publicationGeneration, true), + outputSlot: header.getUint32(layout.outputSlot, true), + flags: header.getUint32(layout.flags, true), + policyHandle: header.getUint32(layout.policyHandle, true), + capabilitySet: header.getUint32(layout.capabilitySet, true), + semanticViewCount: header.getUint32(layout.semanticViewCount, true), + primitiveCount: header.getUint32(layout.primitiveCount, true), + patchCount: header.getUint32(layout.patchCount, true), + drawCount: header.getUint32(layout.drawCount, true), + }; } - const header = new DataView(memoryBuffer, resultPointer, layout.size); - const status = header.getUint32(layout.status, true); - const requiredRequestCapacity = header.getUint32(layout.requiredRequestCapacity, true); - const requiredResultCapacity = header.getUint32(layout.requiredResultCapacity, true); - if (status !== textShaperAbi.status.ok) { - throw new TextEngineStatusError('publish text update', status, requiredRequestCapacity, requiredResultCapacity); - } - const byteLength = header.getUint32(layout.byteLength, true); - if (byteLength < layout.size || resultPointer + byteLength > memoryBuffer.byteLength) { - throw new RangeError('text engine returned an out-of-bounds publication'); - } - this.#requestCapacity = header.getUint32(layout.requestCapacity, true); - this.#resultCapacity = header.getUint32(layout.resultCapacity, true); - return { - bytes: new Uint8Array(memoryBuffer, resultPointer, byteLength), - memoryBuffer, - memoryGrew: memoryBuffer !== before, - engineRevision: header.getUint32(layout.engineRevision, true), - planRevision: header.getUint32(layout.planRevision, true), - requiredBaseRevision: header.getUint32(layout.requiredBaseRevision, true), - publicationGeneration: header.getUint32(layout.publicationGeneration, true), - outputSlot: header.getUint32(layout.outputSlot, true), - flags: header.getUint32(layout.flags, true), - policyHandle: header.getUint32(layout.policyHandle, true), - capabilitySet: header.getUint32(layout.capabilitySet, true), - semanticViewCount: header.getUint32(layout.semanticViewCount, true), - primitiveCount: header.getUint32(layout.primitiveCount, true), - patchCount: header.getUint32(layout.patchCount, true), - drawCount: header.getUint32(layout.drawCount, true), - }; } dispose(): void { diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 63801263..2d5a27a5 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -40,6 +40,8 @@ import { } from './engine-runtime.js'; import type { ThreeTextMaterial } from './material.js'; +const MAX_TEXT_ENGINE_OUTPUT_BYTES = 64 * 1024 * 1024; + export type TextSpan = Omit, 'renderVariant'> & Readonly<{ material?: ThreeTextMaterial }>; @@ -620,7 +622,7 @@ class ThreeTextBatchBinding { this.#paragraphs.size, totalTextLength, Math.max(regions.length, this.#paragraphs.size), - this.#resultCapacity, + MAX_TEXT_ENGINE_OUTPUT_BYTES, ); const publication = this.#session.update( compileTextEngineFrameUpdate({ @@ -763,7 +765,12 @@ class ThreeTextBatchBinding { consumedPlanRevision: this.#planRevision, acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, semanticViewMask, - limits: engineLimits(this.#paragraphs.size, totalTextLength, this.#paragraphs.size, this.#resultCapacity), + limits: engineLimits( + this.#paragraphs.size, + totalTextLength, + this.#paragraphs.size, + MAX_TEXT_ENGINE_OUTPUT_BYTES, + ), }), ); this.#engineRevision = publication.engineRevision; diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index c9cdc007..1f88aceb 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -160,7 +160,11 @@ test('Three coordinator shares shaping data across technique bindings and refere const slugFirst = coordinator.acquireFontStack([slugFont, msdfFont, bitmapFont]); assert.equal(shared.handle, first.handle); assert.notEqual(reversed.handle, first.handle, 'fallback order is part of stack identity'); - const session = coordinator.createSession({ requestCapacity: 4_096, resultCapacity: 1024 * 1024, textCapacity: 16 }); + const session = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: textShaperAbi.layouts.engineResult.size, + textCapacity: 16, + }); const initialRequest = compileTextEngineFrameUpdate({ sessionId: session.handle, policyHandle: coordinator.policyHandle, @@ -302,6 +306,10 @@ test('Three coordinator shares shaping data across technique bindings and refere ], }); const publication = session.update(initialRequest); + assert.ok( + publication.bytes.byteLength > textShaperAbi.layouts.engineResult.size, + 'the host must reserve the reported result watermark and retry the cold publication', + ); const plan = new TextEngineRenderPlanView().bind(publication); for (const name of ['resources', 'buffers', 'patches', 'primitives', 'draws']) { assert.ok(plan.table(name).count > 0, `${name} must come from the Rust publication`); From 845d4f15f570d7acda728febc4ebc7bddc7aa7ff Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 06:10:40 -0400 Subject: [PATCH 083/128] fix(text): recycle paragraph storage safely --- docs/log.md | 8 ++ docs/packages/text.md | 12 ++- docs/planning/decision-register.md | 7 +- docs/planning/rust-layout-engine.md | 14 ++- packages/text/rust/shaper/src/abi_contract.rs | 14 +-- packages/text/rust/shaper/src/bidi.rs | 2 +- packages/text/rust/shaper/src/engine/state.rs | 91 ++++++++++++++++--- packages/text/rust/shaper/src/line_break.rs | 8 +- packages/text/rust/shaper/src/unicode.rs | 3 +- packages/text/src/three/text.ts | 55 +++++++---- .../text/tests/integration/three-v1.test.mjs | 40 ++++++++ 11 files changed, 206 insertions(+), 48 deletions(-) diff --git a/docs/log.md b/docs/log.md index 4d6079d5..336fc207 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Made multi-workload Rust sessions recycle storage without recycling semantics** — Frame admission now covers removal + plus insertion records rather than only final paragraph count. A recycled paragraph clears every semantic arena and + identity marker while retaining its allocations, and one session prewarms one reusable paragraph instead of applying a + 4,096-glyph batch capacity to every child. This removes the invalid-request and allocation failures across the + text-ladder, zoom, and 476-paragraph icon-grid transitions. Rust capacity identity, public Three double replacement, + all 201 package tests, all 131 Rust tests, and a complete 27-cell Bitmap/MTSDF/Slug WebGPU transition sweep pass. The + optimized shaper is 1,090,859 raw / 411,106 gzip / 325,149 Brotli bytes. + - **Made cold command-buffer growth recover instead of failing the benchmark scene** — Decoupled the 64 MiB output safety limit from the smaller retained A/B arenas. Rust's exact required-result watermark now drives one bounded cold reserve/retry; the host re-resolves the request pointer and recopies after possible Wasm-memory detachment. A diff --git a/docs/packages/text.md b/docs/packages/text.md index ef239c9c..d3793940 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:f847d77312d4439847462a8d6969bf9b5725f320d1bb9ee3951a8d1f95d11c85' +source_digest: 'sha256:372d0c9c9a307344c22020acf9a7f8ce295070b17b77e5ae5f13c8cdaf49475a' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -190,7 +190,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-09T09:19:33Z' + at: '2026-08-09T10:04:42Z' --- # Package reference: `@pmndrs/text` @@ -229,6 +229,14 @@ the command buffer; no JavaScript callback runs in that hot path. The Three exec objects, synchronization, and reversible presentation overrides. It does not retain a candidate/current paragraph target or independently derive layout and render state. An unregistered technique fails during engine setup with its identifier. +One retained session prewarms one reusable paragraph state rather than multiplying `TextGroup`'s total glyph capacity +through every child. Additional paragraphs grow their Unicode-through-positioning arenas from actual content. When Rust +recycles removed storage, it clears all committed and pending semantics, identity counters, fingerprints, and preparation +flags while preserving vector capacities. Three sizes frame limits from both the final paragraph set and the actual +removal/insertion and text/style mutation tables. Public integration replaces a group's complete child set twice through +one session, and the Rust regression proves the recycled paragraph contains only its new text at the same allocation +capacity. + `/three` also exports each canonical technique shader as `bitmapShader`, `mtsdfShader`, and `slugShader`.[^three-v1] Each takes one glyph instance's resolved nodes plus that batch's bound GPU resources and returns a named readonly output: position, coverage, resolved colour, opacity, and the intermediate stages the technique produces, such as MTSDF's separate diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 937e01f9..f877f59b 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -42,8 +42,8 @@ sources: title: External gpucat integration fitness plan generated: - by: anthropic-claude/opus-5 - at: '2026-08-08T02:40:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-09T10:04:42Z' --- # Decision register @@ -280,7 +280,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-210 | Multi-paragraph frame consumption uses forward-only borrowed spans, not per-record maps or copied semantic arrays. Validated text, style, constraint, and inline-object tables expose a cursor that consumes only contiguous records for the current paragraph, returns an empty borrowed view when absent, and leaves an exact final cursor check to reject skipped or repeated ownership. The existing single-child production transaction now uses the same path. An exact fixture covers present/absent spans in every keyed table. Optimized Wasm changes from 1,073,074 / 403,537 / 321,046 to 1,074,464 / 404,058 / 321,156 raw/gzip/Brotli bytes. | Accepted | -| D-211 | Every retained paragraph is initialized and capacity-reserved through one functional `ParagraphState` path. It prewarms paired style/resolution arenas and reusable scratch, then applies one text-capacity policy to every active/pending Unicode-through-positioning arena. New batch children cannot accidentally omit a retained lane or duplicate setup logic. Optimized Wasm changes from 1,074,464 / 404,058 / 321,156 to 1,074,774 / 404,030 / 321,343 raw/gzip/Brotli bytes. | Accepted | +| D-211 | Every retained paragraph is initialized and capacity-reserved through one functional `ParagraphState` path. It prewarms paired style/resolution arenas and reusable scratch, then applies one text-capacity policy to every active/pending Unicode-through-positioning arena. New batch children cannot accidentally omit a retained lane or duplicate setup logic. Optimized Wasm changes from 1,074,464 / 404,058 / 321,156 to 1,074,774 / 404,030 / 321,343 raw/gzip/Brotli bytes. | Superseded by D-230 | | D-212 | Paragraph transaction finalization has one complete ordered definition. Every preparation failure and explicit session abort invokes `ParagraphState::abort_all`; successful shared-plan publication invokes `commit_all`. This prevents a later child failure from leaving an earlier child's pending Unicode-through-positioning arena live when the retained session becomes multi-paragraph. Rebuilding optimized Wasm also exposed pre-control-record integration fixtures: they now assert the 136-byte frame header and write explicit paragraph IDs through text, style, constraints, and inline objects. Focused compiled-Wasm integration and all 125 Rust unit tests pass. Optimized Wasm changes from 1,074,774 / 404,030 / 321,343 to 1,073,248 / 404,463 / 321,189 raw/gzip/Brotli bytes; no latency claim is attached to this control-flow consolidation. | Accepted | @@ -314,6 +314,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-227 | Explicit per-glyph inspection and presentation motion do not restore a renderer-side layout or target transaction. `Text.inspectLayout()` requests the Rust sidecar mask only on demand and copies paragraph summary, lines, semantic glyph IDs, clusters, font identity, size, flags, and shaped origins; ordinary render updates still request none of it. The first-party policy deliberately augments each renderable instance with its session-global stable glyph ID in `u32` buffer 14. Three pairs that policy output with the existing origin stream, snapshots renderer-local displayed origins, and permits topology-guarded presentation overrides for Bitmap, MSDF, and Slug. Before applying any later Rust command-buffer delta, the executor restores the captured authoritative origins; the plan then patches, replaces, or retires resources normally. Thus Rust remains the sole render-state transition authority, while Three retains only resource/draw tables and reversible presentation state—no parallel candidate/current `ParagraphBatchTarget` state machine. Rust record tests and compiled-Wasm one- and two-paragraph fixtures prove semantic spaces remain inspectable, stable IDs address shared draws without collision, overrides do not mutate shaped targets, transform-only updates preserve them, and semantic updates retire them. The optimized shaper is 1,089,889 raw / 414,204 gzip / 325,805 Brotli bytes on the canonical Darwin arm64 host. | Accepted | | D-228 | A Rust command buffer makes the generic Three paragraph-target transaction redundant. The deleted `ThreeBitmapTarget`, `ThreeMtsdfTarget`, `ThreeSlugTarget`, `RetainedThreeTargetRevision`, and old raster-program registry no longer stage candidate/current rendering state beside Rust. `ThreeTextRenderPlanExecutor` directly applies the authoritative plan and retains only GPU resources, draw/material caches, synchronization, and reversible presentation state. First-party technique modules no longer register renderer targets as import side effects. The composition proof uses ordinary Bitmap policy packing plus `defineTextMaterial`; custom material code changes the canonical shader output without owning attributes, geometry, layout, or plan transitions. Third-party techniques continue through declarative `registerThreeRasterPlanProgram`. | Accepted | | D-229 | The frame request's 64 MiB `maxOutputBytes` safety limit is independent of the currently reserved A/B result arenas. When a cold publication exceeds an arena, Rust aborts the prepared update and reports the exact required result watermark without advancing engine, plan, or publication revisions. `TextEngineSession` may reserve once and retry that same request; it re-resolves the request pointer and recopies the bytes after the reserve because Wasm growth can detach every prior view. Warm updates remain one crossing. A compiled-Wasm Three fixture starts with only a result-header-sized arena and must still publish a nonempty plan. The live MTSDF paragraph-stress scene now publishes 11,510 glyphs in one draw instead of failing at 1,382,592 required bytes. An adjacent three-run hardware-WebGPU comparison rejected splitting MTSDF origin and size into two `vec2` bindings: packed and split median CPU submit were both approximately 0.50–0.52 ms, while packed median GPU time averaged 0.748 ms versus 0.767 ms split, so the canonical `vec4` binding remains. | Accepted | +| D-230 | A session prewarms exactly one reusable paragraph state; batch glyph capacity sizes shared request/result transport and plan storage, not every paragraph's Unicode-through-positioning arenas. Additional paragraphs grow from their actual content. Recycling clears every committed and pending semantic arena, identity counter, fingerprint, and preparation flag while retaining allocated vectors; a new paragraph therefore begins semantically empty even when it receives a removed paragraph's storage. Frame admission separately covers the larger of final paragraph count and lifecycle-record count, plus the larger of text-derived cluster capacity and actual text/style mutation records. This supersedes D-211's multiplication of one session capacity across every child, which made a 2,926-unit, 476-paragraph icon grid request 4,096 units in every paragraph and fail allocation. A Rust capacity-identity regression, public Three double-replacement fixture, all 201 package tests, all 131 Rust tests, and one complete 27-cell Bitmap/MTSDF/Slug WebGPU workload-transition sweep pass. Optimized Wasm is 1,090,859 raw / 411,106 gzip / 325,149 Brotli bytes. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 8dc436f7..3b75bee3 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -13,7 +13,7 @@ tags: - abi generated: by: openai-codex/gpt-5.6 - at: '2026-08-08T07:00:00Z' + at: '2026-08-09T10:04:42Z' sources: - id: layout-benchmark resource: ../../packages/text/scripts/benchmark-paragraph-layout.mts @@ -1284,6 +1284,18 @@ the required one crossing. A three-run settled WebGPU comparison also rejected a origin and size into separate `vec2` buffers: CPU submission was unchanged, while the additional binding slightly worsened median GPU time, so the existing packed `vec4` remains. +The first multi-workload retained-session sweep then exposed two independent paragraph-lifecycle bugs. Host limits had +treated `maxParagraphs` as only the final live count even though the wire table contains removals plus insertions, and +Rust recycled one removed paragraph's allocation without clearing its semantic contents. Both are now exact: admission +covers actual lifecycle and semantic mutation record counts, while a recycled state clears every committed and pending +semantic arena but retains its vector capacities. The same sweep exposed that the 4,096-glyph group capacity had been +multiplied into a 4,096-unit reserve for each of 476 icon-grid paragraphs. A session now prewarms one reusable paragraph; +additional children allocate from their actual content rather than multiplying a batch budget. A Rust regression proves +semantic reset plus capacity identity, a public Three fixture performs two atomic child-set replacements, and a complete +27-cell Bitmap/MTSDF/Slug WebGPU workload-transition sweep exits cleanly. The optimized shaper is 1,090,859 raw / 411,106 +gzip / 325,149 Brotli bytes. Benchmark draw/glyph telemetry for grouped command-buffer draws is corrected separately and +is not part of this transition proof. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 93cf723b..5e67144e 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -17,13 +17,13 @@ use crate::engine::frame::{ SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, - SEMANTIC_U32_STABLE_GLYPH_ID, SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_LAYOUT_INSPECTION, SEMANTIC_VIEW_MASK, - SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, - STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, - STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, - STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, - STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, - STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + SEMANTIC_U32_STABLE_GLYPH_ID, SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_LAYOUT_INSPECTION, + SEMANTIC_VIEW_MASK, SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, SHAPE_RECTANGLE, + STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, + STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, + STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, + STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, + STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; diff --git a/packages/text/rust/shaper/src/bidi.rs b/packages/text/rust/shaper/src/bidi.rs index 7cd28d16..6f9b0610 100644 --- a/packages/text/rust/shaper/src/bidi.rs +++ b/packages/text/rust/shaper/src/bidi.rs @@ -115,7 +115,7 @@ impl BidiAnalysis { reserve(&mut self.paragraph_levels, paragraph_capacity) } - fn clear(&mut self) { + pub(crate) fn clear(&mut self) { self.levels.clear(); self.classes.clear(); self.paragraph_starts.clear(); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 8470cccf..397f02f5 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -89,7 +89,6 @@ struct EngineSession { pending_next_glyph_id: u32, next_content_revision: u32, pending_next_content_revision: u32, - text_capacity: usize, spare_paragraph: Option, paragraphs: Vec, ordered_paragraphs: Vec, @@ -396,10 +395,6 @@ impl TextEngine { if let Some(paragraph) = session.spare_paragraph.as_mut() { paragraph.reserve_text(capacity)?; } - for paragraph in &mut session.paragraphs { - paragraph.state.reserve_text(capacity)?; - } - session.text_capacity = session.text_capacity.max(capacity); Ok(()) } @@ -949,19 +944,15 @@ impl EngineSession { Ok(()) } Err(index) => { - let mut state = if let Some(spare) = self.spare_paragraph.take() { + let state = if let Some(spare) = self.spare_paragraph.take() { + let mut spare = spare; + spare.reset_for_reuse(); spare } else { let mut state = ParagraphState::default(); state.initialize()?; state }; - if let Err(error) = state.reserve_text(self.text_capacity) { - if self.spare_paragraph.is_none() { - self.spare_paragraph = Some(state); - } - return Err(error); - } self.paragraphs.insert( index, RetainedParagraph { @@ -1056,6 +1047,58 @@ impl EngineSession { } impl ParagraphState { + /// Clears paragraph identity and committed/pending semantics while retaining every allocation. + #[inline(never)] + fn reset_for_reuse(&mut self) { + self.text.clear(); + self.pending_text.clear(); + self.text_unit_ids.clear(); + self.pending_text_unit_ids.clear(); + self.next_text_unit_id = 0; + self.pending_next_text_unit_id = 0; + self.text_prepared = false; + self.styles.clear(); + self.pending_styles.clear(); + self.resolved_styles.clear(); + self.pending_resolved_styles.clear(); + self.unicode.clear(); + self.pending_unicode.clear(); + self.bidi.clear(); + self.pending_bidi.clear(); + self.shaping_runs.clear(); + self.pending_shaping_runs.clear(); + self.shape.clear(); + self.pending_shape.clear(); + self.clusters.clear(); + self.pending_clusters.clear(); + self.geometry.clear(); + self.pending_geometry.clear(); + self.flow_layout.clear(); + self.pending_flow_layout.clear(); + self.positioned.clear(); + self.pending_positioned.clear(); + self.fallback_spans.clear(); + self.pending_fallback_spans.clear(); + self.fallback_span_scratch.clear(); + self.fallback_cluster_scratch.clear(); + self.style_mutation_scratch.clear(); + self.style_order_scratch.clear(); + self.style_nesting_scratch.clear(); + self.style_resolution_scratch.clear(); + self.styles_prepared = false; + self.style_invalidation = StyleInvalidation::default(); + self.unicode_prepared = false; + self.bidi_prepared = false; + self.shaping_runs_prepared = false; + self.shape_prepared = false; + self.clusters_prepared = false; + self.geometry_fingerprint = 0; + self.pending_geometry_fingerprint = 0; + self.geometry_prepared = false; + self.flow_layout_prepared = false; + self.positioned_prepared = false; + } + #[allow(clippy::too_many_arguments)] fn prepare( &mut self, @@ -2733,6 +2776,30 @@ mod tests { assert!(session.paragraph(1).is_none()); assert_eq!(session.paragraph(2).unwrap().state.text, [0x63, 0x64]); assert!(session.spare_paragraph.is_some()); + + let spare_text_capacity = session.spare_paragraph.as_ref().unwrap().text.capacity(); + let replacement_lifecycle = paragraph_mutation_bytes(&[(PARAGRAPH_MUTATION_UPSERT, 3, 1)]); + let replacement_text = paragraph_text_mutation_bytes(&[(3, 0, 0, &[0x7a])]); + let mut replacement = update(3, 3, 3); + replacement.limits.max_paragraphs = 2; + replacement.paragraph_mutations = + parse_paragraph_mutations(&replacement_lifecycle, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1) + .unwrap(); + replacement.text_mutations = + parse_text_mutations(&replacement_text, ENGINE_UPDATE_REQUEST_HEADER_SIZE, 1).unwrap(); + let prepared = engine.prepare_update(replacement, 4).unwrap(); + engine.commit_update(prepared).unwrap(); + let recycled = &engine.sessions.get(&4).unwrap().paragraph(3).unwrap().state; + assert_eq!( + recycled.text, + [0x7a], + "a recycled paragraph must begin semantically empty" + ); + assert_eq!( + recycled.text.capacity(), + spare_text_capacity, + "paragraph recycling must retain its reserved text allocation" + ); } #[test] diff --git a/packages/text/rust/shaper/src/line_break.rs b/packages/text/rust/shaper/src/line_break.rs index af2916fd..98fe5e08 100644 --- a/packages/text/rust/shaper/src/line_break.rs +++ b/packages/text/rust/shaper/src/line_break.rs @@ -54,14 +54,18 @@ pub struct LineBreakAnalysis { } impl LineBreakAnalysis { + pub(crate) fn clear(&mut self) { + self.characters.clear(); + self.breaks.clear(); + } + pub fn reserve(&mut self, capacity: usize) -> Result<(), UnicodeError> { reserve(&mut self.characters, capacity)?; reserve(&mut self.breaks, capacity.saturating_add(1)) } pub fn analyze(&mut self, text: &[u16]) -> Result<(), UnicodeError> { - self.characters.clear(); - self.breaks.clear(); + self.clear(); self.reserve(text.len())?; let mut offset = 0usize; while offset < text.len() { diff --git a/packages/text/rust/shaper/src/unicode.rs b/packages/text/rust/shaper/src/unicode.rs index a4031c11..a129d77d 100644 --- a/packages/text/rust/shaper/src/unicode.rs +++ b/packages/text/rust/shaper/src/unicode.rs @@ -81,8 +81,9 @@ impl UnicodeAnalysis { self.line_breaks.breaks() } - fn clear(&mut self) { + pub(crate) fn clear(&mut self) { self.utf8.clear(); + self.line_breaks.clear(); self.grapheme_boundaries.clear(); self.grapheme_scripts.clear(); self.candidate_offsets.clear(); diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 2d5a27a5..d87aa1da 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -27,7 +27,11 @@ import { type TextEngineStyleValue, type TextEngineTextMutation, } from '../internal/engine-frame-wire.js'; -import type { TextEnginePublication, TextEngineSession } from '../internal/text-engine-host.js'; +import { + TextEngineStatusError, + type TextEnginePublication, + type TextEngineSession, +} from '../internal/text-engine-host.js'; import { readTextEngineLayouts, readTextEngineMeasurements } from '../internal/layout-query-view.js'; import type { ParagraphLayoutInspection, ParagraphLayoutSummary } from '../layout.js'; import { textShaperAbi } from '../generated/text-shaper-abi.js'; @@ -619,27 +623,39 @@ class ThreeTextBatchBinding { } const totalTextLength = [...this.#paragraphs.keys()].reduce((total, text) => total + text.text.length, 0); const limits = engineLimits( - this.#paragraphs.size, + Math.max(this.#paragraphs.size, paragraphMutations.length), totalTextLength, Math.max(regions.length, this.#paragraphs.size), MAX_TEXT_ENGINE_OUTPUT_BYTES, + Math.max(textMutations.length, styleMutations.length), ); - const publication = this.#session.update( - compileTextEngineFrameUpdate({ - sessionId: this.#session.handle, - policyHandle: this.#coordinator.policyHandle, - capabilitySet: 1, - expectedEngineRevision: this.#engineRevision, - consumedPlanRevision: this.#planRevision, - acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, - limits, - paragraphMutations, - textMutations, - styleMutations, - constraints, - regions, - }), - ); + const frame = compileTextEngineFrameUpdate({ + sessionId: this.#session.handle, + policyHandle: this.#coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: this.#engineRevision, + consumedPlanRevision: this.#planRevision, + acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + limits, + paragraphMutations, + textMutations, + styleMutations, + constraints, + regions, + }); + let publication: TextEnginePublication; + try { + publication = this.#session.update(frame); + } catch (error) { + if (error instanceof TextEngineStatusError) { + error.message += + ` (paragraphs=${this.#paragraphs.size}, paragraph mutations=${paragraphMutations.length},` + + ` text mutations=${textMutations.length}, style mutations=${styleMutations.length},` + + ` constraints=${constraints.length}, regions=${regions.length}, text units=${totalTextLength},` + + ` limits=${JSON.stringify(limits)})`; + } + throw error; + } this.#engineRevision = publication.engineRevision; this.#planRevision = publication.planRevision; for (const removed of this.#removed) releaseStackLeases(removed.stackLeases); @@ -948,10 +964,11 @@ function engineLimits( textLength: number, regionCount: number, maxOutputBytes: number, + mutationRecordCount = 0, ): TextEngineFrameLimits { return { maxParagraphs: Math.max(1, paragraphCount), - maxClusters: Math.max(1, textLength * 2), + maxClusters: Math.max(1, textLength * 2, mutationRecordCount), maxLines: Math.max(1, textLength), maxRegions: Math.max(1, regionCount), maxExclusions: 1, diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 9a83d84d..f8772739 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -191,6 +191,46 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn runtime.dispose(); }); +test('TextGroup atomically replaces child paragraphs without multiplying retained text capacity', async () => { + const registry = new FontRegistry(); + const shaper = await createRuntimeShaper({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const runtime = await createTextRuntime({ registry, shaper }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } }); + const first = [new Text({ font, text: 'A' }), new Text({ font, text: 'B' })]; + group.add(...first); + scene.add(group); + scene.updateMatrixWorld(); + + const second = ['C', 'D', 'E'].map((text) => new Text({ font, text })); + group.remove(...first); + group.add(...second); + scene.updateMatrixWorld(); + assert.equal(group.error, undefined); + assert.equal(group.children.filter((child) => child.isMesh).length, 1); + assert.equal(group.children.find((child) => child.isMesh).geometry.instanceCount, 3); + + const third = ['Y', 'Z'].map((text) => new Text({ font, text })); + group.remove(...second); + group.add(...third); + scene.updateMatrixWorld(); + assert.equal(group.error, undefined, 'a recycled Rust paragraph must not retain its previous semantic contents'); + assert.equal(group.children.filter((child) => child.isMesh).length, 1); + assert.equal(group.children.find((child) => child.isMesh).geometry.instanceCount, 2); + + group.dispose(); + for (const text of [...first, ...second, ...third]) text.dispose(); + font.dispose(); + runtime.dispose(); +}); + function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } From 7569164d7b09750bf95b3649efc29cddad736926 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 06:21:46 -0400 Subject: [PATCH 084/128] fix(benchmarks): verify retained workload transitions --- apps/benchmarks/scripts/workflow-output.mts | 6 +++ apps/benchmarks/scripts/workflows.mts | 25 +++++++-- apps/benchmarks/scripts/workflows.test.mts | 8 +++ .../comparison-workload-viewport.tsx | 3 +- .../benchmark/scenes/comparison-workload.ts | 53 ++++++++++++++----- .../presentation-framerate-sweep.probe.ts | 5 +- docs/log.md | 7 +++ docs/packages/benchmarks.md | 21 ++++++-- docs/planning/decision-register.md | 3 +- 9 files changed, 109 insertions(+), 22 deletions(-) create mode 100644 apps/benchmarks/scripts/workflow-output.mts diff --git a/apps/benchmarks/scripts/workflow-output.mts b/apps/benchmarks/scripts/workflow-output.mts new file mode 100644 index 00000000..e6bbea45 --- /dev/null +++ b/apps/benchmarks/scripts/workflow-output.mts @@ -0,0 +1,6 @@ +const VITEXEC_FAILURE = /(?:^|\n)\[(?:error|page error)\]/; + +/** Vitexec 0.1.17 reports injected-module failures as browser logs while exiting successfully. */ +export function hasVitexecFailure(output: string): boolean { + return VITEXEC_FAILURE.test(output); +} diff --git a/apps/benchmarks/scripts/workflows.mts b/apps/benchmarks/scripts/workflows.mts index 067cb8bd..bafc9720 100644 --- a/apps/benchmarks/scripts/workflows.mts +++ b/apps/benchmarks/scripts/workflows.mts @@ -3,6 +3,8 @@ import { readdir, readFile } from 'node:fs/promises'; import { dirname, extname, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { hasVitexecFailure } from './workflow-output.mts'; + interface Workflow { readonly args?: readonly string[]; readonly name: string; @@ -146,11 +148,28 @@ async function runWorkflow(workflow: IndexedWorkflow, extraArguments: readonly s ? [file, ...(workflow.args ?? []), ...extraArguments] : [...(workflow.args ?? []), file, ...extraArguments]; await new Promise((resolveRun, reject) => { - const child = spawn(executable, commandArguments, { cwd: workflow.cwd, stdio: 'inherit' }); + const captureVitexec = runner === 'vitexec'; + const child = spawn(executable, commandArguments, { + cwd: workflow.cwd, + stdio: captureVitexec ? ['inherit', 'pipe', 'pipe'] : 'inherit', + }); + let output = ''; + if (captureVitexec) { + child.stdout?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + process.stdout.write(chunk); + }); + child.stderr?.on('data', (chunk: Buffer) => { + output += chunk.toString(); + process.stderr.write(chunk); + }); + } child.once('error', reject); child.once('close', (code) => { - if (code === 0) resolveRun(); - else reject(new Error(`${workflow.name} exited with ${String(code)}`)); + if (code !== 0) reject(new Error(`${workflow.name} exited with ${String(code)}`)); + else if (captureVitexec && hasVitexecFailure(output)) + reject(new Error(`${workflow.name} reported a browser error`)); + else resolveRun(); }); }); } diff --git a/apps/benchmarks/scripts/workflows.test.mts b/apps/benchmarks/scripts/workflows.test.mts index f652f489..444bbdef 100644 --- a/apps/benchmarks/scripts/workflows.test.mts +++ b/apps/benchmarks/scripts/workflows.test.mts @@ -4,6 +4,8 @@ import { promisify } from 'node:util'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; +import { hasVitexecFailure } from './workflow-output.mts'; + const execute = promisify(execFile); const workflowScript = fileURLToPath(new URL('workflows.mts', import.meta.url)); @@ -24,3 +26,9 @@ test('describes requirements, writes, and source for one workflow', async () => assert.match(stdout, /Writes: Ignored browser caches only\./); assert.match(stdout, /Source: apps\/benchmarks\/scripts\/run-presentation-workload-matrix\.mts/); }); + +test('treats Vitexec browser and injected-module errors as workflow failures', () => { + assert.equal(hasVitexecFailure('logs:\n[log] presentation-ready'), false); + assert.equal(hasVitexecFailure('logs:\n[error] injected probe failed'), true); + assert.equal(hasVitexecFailure('logs:\n[page error] renderer failed'), true); +}); diff --git a/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx index 964aae6b..48ee350b 100644 --- a/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx +++ b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx @@ -218,11 +218,12 @@ export function ComparisonWorkloadViewport({ const publishStats = useEffectEvent((next: ComparisonWorkloadStats) => { finishBakeProgress(); onStats(next); - setError(undefined); + if (next.workload === workload && next.appliedFontFixture === fontFixture) setError(undefined); }); const publishError = useEffectEvent((caught: unknown) => { if (caught instanceof DOMException && caught.name === 'AbortError') return; finishBakeProgress(); + console.error('comparison workload update failed', caught); setError(caught instanceof Error ? caught.message : String(caught)); }); const currentConfiguration = useEffectEvent( diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index f5221f68..b89dac58 100644 --- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -593,6 +593,10 @@ async function createComparisonWorkloadRuntime( next.workload === 'icon-grid' && nextIconGridInstance !== undefined ? nextIconGridInstance.activate(next, { height, width }) : undefined; + const previous = entries; + const previousRoot = batchRoot; + const reuseBatchRoot = + previousRoot instanceof TextGroup && comparisonWorkloadDefinition(next.workload).batching !== 'standalone'; const nextEntries = createEntries( activeFont().loaded, technique, @@ -606,23 +610,28 @@ async function createComparisonWorkloadRuntime( initialIconWindow?.scrollX ?? (workloadChanged ? 0 : -scene.position.x), initialIconWindow?.scrollY ?? (workloadChanged ? 0 : scene.position.y), ); - const nextRoot = createBatchRoot(next.workload); + const nextRoot = reuseBatchRoot ? previousRoot : createBatchRoot(next.workload); const scheduledAt = performance.now(); try { - // The staged root is published off-scene: a TextGroup shapes, lays out, and packs its whole workload inside - // one `updateMatrixWorld`, so the committed layouts are readable before anything reaches the live scene. + // Compatible grouped workloads replace their children through one retained Rust session. Creating a second + // TextGroup would duplicate every reserved engine arena while the outgoing command buffer is still live. + if (reuseBatchRoot) for (const { node } of previous) previousRoot.remove(node); for (const { node } of nextEntries) nextRoot.add(node); publishWorkloadTexts(nextRoot, nextEntries); const readyAt = performance.now(); if (disposed || commitRevision !== revision) { - disposeEntries(nextEntries); - disposeBatchRoot(nextRoot); + entries = nextEntries; + batchRoot = nextRoot; + disposeEntries(previous); + if (!reuseBatchRoot) disposeBatchRoot(previousRoot); + if (iconGridInstanceChanged) { + iconGridInstance?.dispose(); + iconGridInstance = next.workload === 'icon-grid' ? nextIconGridInstance : undefined; + } return; } const sceneStartedAt = performance.now(); layoutEntries(nextEntries, next, width, height); - const previous = entries; - const previousRoot = batchRoot; entries = nextEntries; batchRoot = nextRoot; configuration = next; @@ -640,7 +649,7 @@ async function createComparisonWorkloadRuntime( scene.clear(); scene.add(nextRoot); disposeEntries(previous); - disposeBatchRoot(previousRoot); + if (!reuseBatchRoot) disposeBatchRoot(previousRoot); if (iconGridInstanceChanged) { iconGridInstance?.dispose(); iconGridInstance = next.workload === 'icon-grid' ? nextIconGridInstance : undefined; @@ -657,8 +666,25 @@ async function createComparisonWorkloadRuntime( iconGridInstance?.settle(next, { height, width }, scene); } } catch (error) { + if (reuseBatchRoot) { + for (const { node } of nextEntries) nextRoot.remove(node); + disposeBatchRoot(nextRoot); + const restoredRoot = createBatchRoot(configuration.workload); + try { + for (const { node } of previous) restoredRoot.add(node); + publishWorkloadTexts(restoredRoot, previous); + batchRoot = restoredRoot; + scene.clear(); + scene.add(restoredRoot); + } catch (recoveryError) { + disposeBatchRoot(restoredRoot); + disposeEntries(nextEntries); + if (iconGridInstanceChanged) nextIconGridInstance?.dispose(); + throw new Error(`comparison workload update also failed: ${String(error)}`, { cause: recoveryError }); + } + } disposeEntries(nextEntries); - disposeBatchRoot(nextRoot); + if (!reuseBatchRoot) disposeBatchRoot(nextRoot); if (iconGridInstanceChanged) nextIconGridInstance?.dispose(); throw error; } @@ -860,7 +886,7 @@ async function createComparisonWorkloadRuntime( const activeZoomEntry = configuration.workload === 'zoom-text' ? entries[zoomAnimationState.phraseIndex] : undefined; const zoomScale = activeZoomEntry?.node.scale.x ?? 1; - measureVisibleEntries(entries, zoomScale, visibleEntryMetrics, visibleGeometryScratch); + measureVisibleEntries(entries, batchRoot, zoomScale, visibleEntryMetrics, visibleGeometryScratch); const effectiveCssFontSize = configuration.workload === 'zoom-text' ? ZOOM_TEXT_BASE_CSS_PX * zoomScale : configuration.fontSize; const framebufferGpuBytes = rendererViewport.drawingBufferWidth * rendererViewport.drawingBufferHeight * 4; @@ -1537,6 +1563,7 @@ function disposeEntries(entries: readonly WorkloadEntry[]): void { function measureVisibleEntries( entries: readonly WorkloadEntry[], + batchRoot: THREE.Object3D, zoomScale: number, metrics: MutableVisibleEntryMetrics, geometries: Set, @@ -1548,10 +1575,12 @@ function measureVisibleEntries( metrics.lineCount = 0; metrics.missingGlyphCount = 0; metrics.sourceTextLength = 0; + geometries.clear(); + // Rust-planned TextGroup draws are siblings of the authored entry nodes. Traversing each entry therefore reports + // zero even though the shared command buffer submits one draw; traverse the realized batch root exactly once. + measureVisibleObject(batchRoot, metrics, geometries); for (const entry of entries) { if (!entry.node.visible) continue; - geometries.clear(); - measureVisibleObject(entry.node, metrics, geometries); measureVisibleLayout(committedTextMetrics(entry.text), zoomScale, metrics); if (entry.labelText !== undefined) measureVisibleLayout(committedTextMetrics(entry.labelText), zoomScale, metrics); metrics.sourceTextLength += entry.sourceText.length; diff --git a/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts b/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts index 9a151918..4bf2159e 100644 --- a/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts +++ b/apps/benchmarks/vitexec/presentation-framerate-sweep.probe.ts @@ -147,13 +147,14 @@ async function readyViewport(technique: Technique, workload: string): Promise Date: Sun, 9 Aug 2026 08:29:13 -0400 Subject: [PATCH 085/128] feat(text): specialize retained frame planning --- .../src/generated/package-sizes.json | 60 ++--- .../benchmark/scenes/comparison-workload.ts | 38 +++- .../vitexec/paragraph-stress-timing.probe.ts | 109 +++++++++ docs/log.md | 12 + docs/packages/benchmarks.md | 20 +- docs/packages/text.md | 20 +- docs/planning/decision-register.md | 2 + packages/text/rust/shaper/src/abi_contract.rs | 3 + packages/text/rust/shaper/src/engine/frame.rs | 3 + .../text/rust/shaper/src/engine/frame_wire.rs | 25 ++- .../rust/shaper/src/engine/ordered_plan.rs | 185 +++++++++++++++- .../text/rust/shaper/src/engine/plan_input.rs | 40 ++++ .../rust/shaper/src/engine/policy_gather.rs | 1 + .../shaper/src/engine/render_plan_compiler.rs | 1 + .../rust/shaper/src/engine/stable_plan.rs | 179 ++++++++++++++- packages/text/rust/shaper/src/engine/state.rs | 11 +- .../text/src/generated/text-shaper-abi.ts | 3 + .../text/src/internal/engine-frame-wire.ts | 6 + packages/text/src/r3f.ts | 3 +- packages/text/src/three.ts | 2 + packages/text/src/three/profiler.ts | 33 +++ packages/text/src/three/text.ts | 207 +++++++++++++----- .../integration/engine-frame-wire.test.mjs | 2 + .../text/tests/integration/three-v1.test.mjs | 48 +++- packages/text/tests/types/r3f-v1-api.test.ts | 2 +- .../text/tests/types/three-v1-api.test.ts | 16 +- 26 files changed, 911 insertions(+), 120 deletions(-) create mode 100644 apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts create mode 100644 packages/text/src/three/profiler.ts diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index eb293300..23d3d204 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,11 +10,11 @@ "label": "Browser core", "status": "measured", "format": "javascript", - "sha256": "d2b56a78ed8eae0c69cd536dcbdcfaae5d90702b526ef1864dd585be265c5378", - "rawBytes": 400792, - "minifiedBytes": 296674, - "gzipBytes": 85908, - "brotliBytes": 66586 + "sha256": "9b3d588ce97cfb32a339a3b1810b61b63d3b48b7a35b9d68ea61a147237b48c0", + "rawBytes": 400841, + "minifiedBytes": 296719, + "gzipBytes": 85926, + "brotliBytes": 66549 }, { "id": "font-validator-js", @@ -54,55 +54,55 @@ "label": "Text shaper JS", "status": "measured", "format": "javascript", - "sha256": "a50116f8dbb58e21a1bb408c653e5e6cfb183a0b2ab74110e09dc6eccc84722f", - "rawBytes": 70570, - "minifiedBytes": 52350, - "gzipBytes": 14242, - "brotliBytes": 12531 + "sha256": "056dffeb58c5f1636f9d340bc959de4a74bf9d580d2a9f6e990f9c5ec5c00c65", + "rawBytes": 70619, + "minifiedBytes": 52395, + "gzipBytes": 14262, + "brotliBytes": 12522 }, { "id": "text-shaper-wasm", "label": "Text shaper Wasm", "status": "measured", "format": "wasm", - "sha256": "f1cebddb33064296f7b3fc2e457869b44e5c11512bc40f9ff1578a3003ace433", - "rawBytes": 1089889, - "minifiedBytes": 1089889, - "gzipBytes": 414204, - "brotliBytes": 325805 + "sha256": "194dd880ca0d8fb4eda7797f7a68dc47cab0beb7f006f79187b87be92ae0e446", + "rawBytes": 1101079, + "minifiedBytes": 1101079, + "gzipBytes": 417984, + "brotliBytes": 328164 }, { "id": "bitmap-runtime-js", "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "ce8c8813854c5441323e1e75838433752cdd61403aa94fb2fd4c5a17b10342fc", - "rawBytes": 322911, - "minifiedBytes": 213656, - "gzipBytes": 54245, - "brotliBytes": 45644 + "sha256": "d6c8f3c7f2ba574d6b97b6bbffd38065cdfa14f819dc4cff49fe3da1979acc78", + "rawBytes": 327653, + "minifiedBytes": 216317, + "gzipBytes": 55015, + "brotliBytes": 46269 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "7118c509be608166fb7d22560a7c9616b9a93bd054fdbd3dfeb65a4a1726603b", - "rawBytes": 322907, - "minifiedBytes": 213721, - "gzipBytes": 54229, - "brotliBytes": 45635 + "sha256": "1a2a4748af7e89c030c14e14627f2125e19157a248762650655808f97c7299be", + "rawBytes": 327649, + "minifiedBytes": 216382, + "gzipBytes": 55010, + "brotliBytes": 46263 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "c2ad3d5577db8bd19689d5b358f41cc41f3adaf6bdce0e9cbee355ae27c5abd3", - "rawBytes": 322909, - "minifiedBytes": 213648, - "gzipBytes": 54203, - "brotliBytes": 45587 + "sha256": "8a2e93e57b098643d33fe2339f75224e73d7c25a4aa3aaefbf0e1de2745637e5", + "rawBytes": 327651, + "minifiedBytes": 216306, + "gzipBytes": 54979, + "brotliBytes": 46275 }, { "id": "bitmap-baker-wasm", diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index b89dac58..bbe1bae5 100644 --- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -1,5 +1,5 @@ import { FontRegistry, type ParagraphLayoutSummary, type RegisteredFont } from '@pmndrs/text'; -import { TextGroup } from '@pmndrs/text/three'; +import { setThreeTextProfiler, TextGroup, threeTextUserTimingProfiler } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; import { selectBitmapStrikePpem } from '@pmndrs/text/three/bitmap'; @@ -67,6 +67,18 @@ import { type WorkloadEntry = ComparisonWorkloadEntry; +const textTimingsEnabled = + typeof location !== 'undefined' && new URLSearchParams(location.search).get('textTimings') === '1'; +if (textTimingsEnabled) setThreeTextProfiler(threeTextUserTimingProfiler()); + +function timingBegin(): number { + return textTimingsEnabled ? performance.now() : 0; +} + +function timingEnd(name: string, startedMs: number): void { + if (textTimingsEnabled) performance.measure(`@pmndrs/benchmark ${name}`, { start: startedMs }); +} + export type { ComparisonWorkloadConfiguration, ComparisonWorkloadId, @@ -596,7 +608,9 @@ async function createComparisonWorkloadRuntime( const previous = entries; const previousRoot = batchRoot; const reuseBatchRoot = - previousRoot instanceof TextGroup && comparisonWorkloadDefinition(next.workload).batching !== 'standalone'; + previousRoot instanceof TextGroup && + comparisonWorkloadDefinition(next.workload).batching !== 'standalone' && + previousRoot.compositing === workloadCompositing(next.workload); const nextEntries = createEntries( activeFont().loaded, technique, @@ -734,6 +748,7 @@ async function createComparisonWorkloadRuntime( return; } if (contentWidthChanged || fontSizeChanged) { + const retainedUpdateStarted = timingBegin(); const readyStarted = performance.now(); const retainedWidths = contentWidthChanged ? next.workload === 'dynamic-layout' @@ -746,15 +761,23 @@ async function createComparisonWorkloadRuntime( for (const [index, entry] of entries.entries()) entry.lastWidth = retainedWidths[index]!; } const scheduledAt = performance.now(); + const stagingStarted = timingBegin(); applyRetainedTextLayout( entries.map(({ text }) => text), retainedWidths, fontSizeChanged ? next.fontSize : undefined, ); - publishWorkloadTexts(batchRoot, entries); - const readyAt = performance.now(); + timingEnd('text.stage', stagingStarted); const sceneStartedAt = performance.now(); + // Layout metrics are demanded before explicit publication so the query mask rides on the pending semantic + // update. The following scene publication sees clean Rust state and only synchronizes renderer transforms. + const layoutStarted = timingBegin(); layoutEntries(entries, next, width, height); + timingEnd('text.update-and-measure', layoutStarted); + const readyAt = performance.now(); + const publishStarted = timingBegin(); + publishWorkloadTexts(batchRoot, entries); + timingEnd('text.publish-clean', publishStarted); const finishedAt = performance.now(); textReadyMs = finishedAt - readyStarted; textUpdateTelemetry.record({ @@ -764,6 +787,7 @@ async function createComparisonWorkloadRuntime( totalMs: finishedAt - readyStarted, }); recordReflow(finishedAt - readyStarted); + timingEnd('text.retained-update', retainedUpdateStarted); } else if (viewportChanged) { layoutEntries(entries, next, width, height); } @@ -874,6 +898,7 @@ async function createComparisonWorkloadRuntime( const started = performance.now(); canvasSurface.render(scene, camera); const submitMs = performance.now() - started; + timingEnd('renderer.submit', started); if (firstDrawMs === 0) { firstDrawMs = submitMs; uploadFrameCompleteMs = submitMs; @@ -1117,9 +1142,14 @@ function createBatchRoot(workload: ComparisonWorkloadId): THREE.Object3D { // boundary and turn one draw into several, which would make the batched lanes look worse than the standalone one. return new TextGroup({ capacity: { size: 4_096, policy: 'grow' }, + compositing: workloadCompositing(workload), }); } +function workloadCompositing(workload: ComparisonWorkloadId): 'ordered' | 'independent' { + return workload === 'icon-grid' ? 'independent' : 'ordered'; +} + function disposeBatchRoot(root: THREE.Object3D): void { root.removeFromParent(); root.clear(); diff --git a/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts b/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts new file mode 100644 index 00000000..b7ddd31a --- /dev/null +++ b/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts @@ -0,0 +1,109 @@ +export {}; + +const viewport = await waitForViewport(); +performance.clearMeasures(); +const initialReflows = integerAttribute(viewport, 'data-reflow-count'); +const targetReflows = initialReflows + 16; +const frameDeltas: number[] = []; +let previousFrame = performance.now(); + +await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(`timed out at reflow ${viewport.dataset.reflowCount}`)), 30_000); + const frame = (timestamp: number): void => { + frameDeltas.push(timestamp - previousFrame); + previousFrame = timestamp; + if (integerAttribute(viewport, 'data-reflow-count') >= targetReflows) { + clearTimeout(timeout); + resolve(); + } else { + requestAnimationFrame(frame); + } + }; + requestAnimationFrame(frame); +}); + +const measures = performance.getEntriesByType('measure') as PerformanceMeasure[]; +const summaries = Object.fromEntries( + [...new Set(measures.map(({ name }) => name))].sort().map((name) => [ + name, + summarize(measures.filter((entry) => entry.name === name).map(({ duration }) => duration)), + ]), +); +const elapsed = frameDeltas.reduce((sum, duration) => sum + duration, 0); +console.log( + 'paragraph-stress-timing-ready', + JSON.stringify({ + draws: integerAttribute(viewport, 'data-draw-count'), + engineUpdates: measures.filter(({ name }) => name === '@pmndrs/text engine.update').length, + finalReflows: integerAttribute(viewport, 'data-reflow-count'), + glyphs: integerAttribute(viewport, 'data-glyph-count'), + measuredReflows: targetReflows - initialReflows, + rafFps: Number(((frameDeltas.length * 1_000) / elapsed).toFixed(1)), + rafMaxMs: Number(Math.max(...frameDeltas).toFixed(3)), + rafP95Ms: Number(percentile(frameDeltas, 0.95).toFixed(3)), + summaries, + }), +); + +function waitForViewport(): Promise { + const find = (): HTMLElement | undefined => { + const candidate = document.querySelector( + '[data-testid="comparison-live-viewport"][data-workload="paragraph-stress"]', + ); + return candidate !== null && Number(candidate.dataset.framesPerSecond) > 0 && Number(candidate.dataset.glyphCount) > 0 + ? candidate + : undefined; + }; + const current = find(); + if (current !== undefined) return Promise.resolve(current); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + observer.disconnect(); + reject(new Error('timed out waiting for Paragraph Stress')); + }, 60_000); + const observer = new MutationObserver(() => { + const candidate = find(); + if (candidate === undefined) return; + clearTimeout(timeout); + observer.disconnect(); + resolve(candidate); + }); + observer.observe(document.documentElement, { attributes: true, childList: true, subtree: true }); + }); +} + +function integerAttribute(element: HTMLElement, name: string): number { + const value = Number(element.getAttribute(name)); + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`${name} is not a nonnegative integer`); + return value; +} + +function summarize(values: readonly number[]): Record { + return { + count: values.length, + maxMs: Number(Math.max(...values).toFixed(3)), + medianMs: Number(percentile(values, 0.5).toFixed(3)), + p95Ms: Number(percentile(values, 0.95).toFixed(3)), + totalMs: Number(values.reduce((sum, value) => sum + value, 0).toFixed(3)), + }; +} + +function percentile(values: readonly number[], ratio: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * ratio) - 1)]!; +} + +/* @workflow +{ + "name": "benchmark:paragraph-stress-timing", + "summary": "Attribute Paragraph Stress reflow and frame time through User Timing spans.", + "requirements": "GPU-enabled Chromium and Vitexec.", + "writes": "Standard output; optional caller-owned CPU and performance traces.", + "args": [ + "--gpu", + "--path", + "/presentation?mode=benchmark&technique=mtsdf&backend=webgpu&delivery=baked&dpr=2&font=inter&workload=paragraph-stress&textTimings=1" + ] +} +*/ diff --git a/docs/log.md b/docs/log.md index 93270df0..5b84f550 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,18 @@ ## 2026-08-09 +- **Made semantic queries share the retained update that invalidated them** — Three now sends only changed text, style, + or geometry sections; an empty update and cached query make no Rust call, while pending measurement or inspection rides + on the same `text_update`. A two-paragraph compiled-Wasm regression proves all-paragraph semantic retention and exact + command-buffer output. Controlled old-Rust Paragraph Stress runs isolate 14.295 ms baseline, 13.615 ms + measurement-only, and 7.450 ms semantic-tier medians; the complete candidate measures 6.885 ms at 11,510 glyphs and + one draw. Optional User Timing markers preserve phase evidence without claiming finer inlined Rust attribution. + +- **Made compositing freedom an explicit Rust planning input** — `TextGroup` and R3F now expose the same `ordered` or + `independent` construction policy. Ordered remains the prose-safe default; independent permits the Rust ordered-direct + and stable-indirect planners to coalesce compatible interleaved resources. Icon Grid selects independent mode. The + optimized shaper is 1,101,079 raw / 417,984 gzip / 328,164 Brotli bytes. + - **Made Presentation workflow failures and command-buffer work observable** — The workflow runner now rejects Vitexec browser/page errors even when its process exits zero. Stale stats cannot erase a retained-scene update failure, grouped draw/glyph telemetry reads the realized batch root, and the 27-cell sweep requires positive counts. Every diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index a2419374..fcd343a0 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:6e7b1addabb158330122eadf0355d70bc5abb4889926be3a903dfc4c33f97ebd' +source_digest: 'sha256:805069ef81fb411215ec4ac945aebe4573faccb6fc0a77fdc7e0923fed407895' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -205,7 +205,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5 - at: '2026-08-09T10:13:02Z' + at: '2026-08-09T12:24:43Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -425,6 +425,22 @@ Icon Grid gap explicit: 2,926–3,021 glyphs currently produce 476 draws and 30. 0.27–0.76 ms and median GPU work is 0.57–2.02 ms. That is batching-policy evidence, not a shaping-performance result or an accepted release cost.[^presentation-framerate-sweep] +Paragraph Stress can opt into Chrome User Timing with `?textTimings=1`. Its retained update is split into authored +property staging, Rust update plus demanded measurement, clean publication, renderer submission, and the package's +internal Three phases. The production path performs no timing calls while the option is absent. The workload now asks +for layout metrics before explicit scene publication, so the semantic mask shares the pending mutation and the later +matrix traversal sees clean Rust state. Icon Grid constructs its batch with independent compositing; other workloads +retain ordered semantics. + +An identical direct Chromium 149/WebGPU/DPR-2 Paragraph Stress comparison retained 11,510 glyphs, one draw, and 64 +authored reflows in every case. The committed baseline measured 14.295 ms median reflow; measurement piggyback alone on +that baseline measured 13.615 ms; adding semantic dirty tiers while retaining the same old Rust measured 7.450 ms; the +complete candidate measured 6.885 ms. These telemetry histories contain 13–16 settled samples and establish direction +and isolation, not a portable frame-time gate. A normal phase capture attributes most changed-frame CPU time to the +single Rust `text_update`; TypeScript preparation, semantic readback, plan application, and renderer submit are smaller. +A symbol-preserving diagnostic did not provide honest finer Rust attribution because LTO inlines most warm work into the +export, so internal phase timers are required before claiming a particular Rust loop is dominant. + Paint & Effects is one live paragraph whose per-word hue advances continuously; opacity is shared, bounded white outline and hard shadow are MTSDF-only, and Bitmap plus Slug disable both controls. Paragraph Stress treats text volume as a topology change: moving its volume control immediately rebuilds the repeated corpus, while controls that only alter retained animation or paint state avoid replacement layouts. Dynamic Layout derives its initial three phase-offset widths from the same elapsed animation clock as subsequent frames, awaits every paragraph layout, and publishes the trio atomically; the first visible frame therefore continues directly into animation instead of flashing a uniform-width staging layout. One benchmark-owned interaction component gives navigable live canvases mouse drag and two-finger touch pan; Off-axis additionally enables pinch and wheel zoom. It translates gestures into renderer-neutral view commands and does not put DOM listeners in `@pmndrs/text`. Workloads are deliberately not React Activities. They are framework-neutral retained scenes behind the route-owned render host, so a swap releases the old scene's text and font/raster residency without replacing the host canvas, renderer, timestamp timer, or telemetry history. Only the Benchmark and Conformance modes retain React state as Activities. The shared multi-technique workload implementation remains a dynamic chunk; Benchmark schedules a cancellable no-timeout idle import, while pointer hover or keyboard focus warms it immediately. Unsupported idle-callback hosts simply retain interaction warming, so the chunk never enters the initial graph and ordinary Benchmark startup never waits for it. The host serializes scene activation and retains the current scene until its replacement is ready, preventing an asynchronously initialized workload from publishing partial text or inheriting the prior workload's configuration. Renderer-published configuration revisions make product probes causal rather than reflections of React props. Dynamic layout separately reports one completed three-paragraph reflow cost and count instead of hiding reshape work inside the CPU-submit graph. The MTSDF base-level scene and sampling paths require deterministic pixels within each renderer invocation, authenticated artifacts and resource counts, and bounded error against the independent scalar reconstruction. Hardware Apple Metal and headless SwiftShader framebuffer hashes remain labeled observations because filtered analytic coverage is not byte-portable across drivers. The current SwiftShader comparison reports `0.0957/255` mean absolute error, maximum error `10`, and 3,233 pixels above its threshold, all inside the reviewed `0.25/255`, `48`, and 2% envelopes. The current direct WebGPU observation reports framebuffer hash `4da56d…`, 14,400 changed pixels, 2,420 colors, and a 6,798,412-byte compressed artifact; its scalar comparison reports mean absolute error `0.0184937`, maximum error `1`, and zero threshold error pixels. The gate names base-level behavior rather than the removed generated-mip path. Cross-technique fidelity is the final conformance workload. It renders the selected Bitmap, MSDF, or Slug candidate through the real public `Text` pipeline, then independently rasterizes the same pinned source TTF/OTF, authored lines, physical size, direction, and paragraph baselines through browser Canvas2D. The synchronized candidate/reference/difference panels share pan and zoom. Chromium 149 WebGPU records canonical Inter at `7.393` mean error and `5,852` pixels over `2/255` for the native 16-device-pixel Bitmap strike, and `6.884` / `19,326` at the MTSDF 64-device-pixel base level. The retained Slug matrix covers all seven font sources at DPR 1 and 2 through both renderer backends: its independent analytic CPU comparison records mean error from `0.0032` to `0.0838/255`, zero severe pixels for every ordinary outline, and 27–54 severe pixels for DotGothic16's grid-aligned pixel outlines. Ordinary Canvas comparisons retain the `12/255` mean gate; DotGothic16 has a separate `24/255` pinned-browser envelope because Canvas applies a materially different hinted pixel-style raster, while its analytic comparison remains bounded to at most 64 severe pixels. Every source retains the common 20% over-tolerance-pixel gate, and unit negative controls prove the ordinary and pixel-style envelopes fail independently. Canvas2D is a pinned-browser source-outline reference, not a claim of cross-browser bit identity, while the separate pipeline-accuracy cases continue to own exact Bitmap reconstruction and scalar MTSDF/Slug sampling. diff --git a/docs/packages/text.md b/docs/packages/text.md index d3793940..b8c5860f 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:372d0c9c9a307344c22020acf9a7f8ce295070b17b77e5ae5f13c8cdaf49475a' +source_digest: 'sha256:8af3dbc1edd2172625a7c0bca81eef92524e0f7c499d1c9a227a1cc0307fa4ab' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -190,7 +190,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5 - at: '2026-08-09T10:04:42Z' + at: '2026-08-09T12:24:43Z' --- # Package reference: `@pmndrs/text` @@ -999,6 +999,22 @@ transition. Draw compatibility is range-independent: reorder retains exact meshe updating `recordIndex`, count, and render order; coalescing retains the one compatible draw and removes only the other. Live WebGPU/WebGL2 submission still owns the final native-fence proof before public cutover. +`TextGroup.compositing` now makes the batch's ordering contract explicit. The default `ordered` mode preserves authored +draw order. `independent` declares that compatible descendants may be reordered, allowing both Rust plan strategies to +coalesce interleaved technique/resource groups without moving that decision into Three. The value is carried as one +frame flag, is fixed when a group is constructed, and is exposed through the R3F wrapper. Rust tests cover interleaved +resources in ordered-direct and stable-indirect plans. The optimized shaper at this checkpoint is 1,101,079 raw, +417,984 gzip, and 328,164 Brotli bytes. + +Three now sends only the semantic sections a property update can invalidate. Text replacement sends text, style, and +geometry; font/span/style/paint/raster/material changes send style; content-box changes send geometry; an empty update +does nothing. A demanded measurement or inspection mask rides on the same pending frame instead of issuing a second +`text_update`, and one returned semantic publication populates every paragraph in the session. Cached committed queries +remain crossing-free. The focused two-paragraph compiled-Wasm lifecycle proves empty-update no-op behavior, one-call +geometry and text mutation plus measurement, all-paragraph measurement retention, and exact five-instance command-buffer +output after a text replacement. Optional Three phase profiling is inactive by default and can emit User Timing spans +for frame preparation, Rust update, plan application, semantic readback, transform synchronization, and total time. + The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index c89cc5e7..8a2d0125 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -316,6 +316,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-229 | The frame request's 64 MiB `maxOutputBytes` safety limit is independent of the currently reserved A/B result arenas. When a cold publication exceeds an arena, Rust aborts the prepared update and reports the exact required result watermark without advancing engine, plan, or publication revisions. `TextEngineSession` may reserve once and retry that same request; it re-resolves the request pointer and recopies the bytes after the reserve because Wasm growth can detach every prior view. Warm updates remain one crossing. A compiled-Wasm Three fixture starts with only a result-header-sized arena and must still publish a nonempty plan. The live MTSDF paragraph-stress scene now publishes 11,510 glyphs in one draw instead of failing at 1,382,592 required bytes. An adjacent three-run hardware-WebGPU comparison rejected splitting MTSDF origin and size into two `vec2` bindings: packed and split median CPU submit were both approximately 0.50–0.52 ms, while packed median GPU time averaged 0.748 ms versus 0.767 ms split, so the canonical `vec4` binding remains. | Accepted | | D-230 | A session prewarms exactly one reusable paragraph state; batch glyph capacity sizes shared request/result transport and plan storage, not every paragraph's Unicode-through-positioning arenas. Additional paragraphs grow from their actual content. Recycling clears every committed and pending semantic arena, identity counter, fingerprint, and preparation flag while retaining allocated vectors; a new paragraph therefore begins semantically empty even when it receives a removed paragraph's storage. Frame admission separately covers the larger of final paragraph count and lifecycle-record count, plus the larger of text-derived cluster capacity and actual text/style mutation records. This supersedes D-211's multiplication of one session capacity across every child, which made a 2,926-unit, 476-paragraph icon grid request 4,096 units in every paragraph and fail allocation. A Rust capacity-identity regression, public Three double-replacement fixture, all 201 package tests, all 131 Rust tests, and one complete 27-cell Bitmap/MTSDF/Slug WebGPU workload-transition sweep pass. Optimized Wasm is 1,090,859 raw / 411,106 gzip / 325,149 Brotli bytes. | Accepted | | D-231 | Browser workflows are successful only when both the Vitexec process and its injected page/module execution are successful. The root runner forwards captured output but rejects `[error]` and `[page error]` even when Vitexec exits zero. Presentation readiness is causal and may wait up to 60 seconds for a cold product-scale workload, but every cell must report positive glyph and draw counts plus zero missing glyphs. Grouped workload telemetry traverses the realized command-buffer root once because executor meshes are siblings of authored entry nodes. The complete 27-cell WebGPU sweep now reports nonzero work and exposes rather than hides Icon Grid's 476-draw, 30.8–47.0 FPS batching-policy gap. | Accepted | +| D-232 | A renderer batch declares its compositing freedom once through `ordered` or `independent`; it does not provide a hot callback. `ordered` is the default and preserves authored draw order. `independent` permits the Rust planner to coalesce compatible interleaved technique/resource groups, and both ordered-direct and stable-indirect strategies implement that policy before emitting the renderer-neutral command buffer. Three and R3F expose the same `compositing` name on `TextGroup`; the benchmark icon grid selects independent mode while prose workloads remain ordered. The optimized shaper is 1,101,079 raw / 417,984 gzip / 328,164 Brotli bytes. | Accepted | +| D-233 | Three classifies desired-state changes by the retained Rust semantic section they can invalidate: text replacement sends text/style/geometry, style-family changes send style, content-box changes send geometry, and an empty update is a no-op. A requested semantic view mask rides on a pending mutation frame so layout measurement/inspection and render-plan publication use one `text_update`; one returned sidecar populates all retained paragraphs, while cached committed queries remain crossing-free. On identical old Rust, Chromium 149/WebGPU/DPR-2 Paragraph Stress changes from 14.295 ms committed-baseline median to 13.615 ms with measurement piggyback alone and 7.450 ms after semantic dirty tiers, at 11,510 glyphs and one draw. The full candidate measures 6.885 ms; these 13–16-sample telemetry histories support isolation and direction, not a portable threshold. Focused integration proves zero calls for empty updates, exactly one call for mutation plus measurement, and exact command-buffer output. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 5e67144e..c9f28c21 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -2632,6 +2632,9 @@ pub fn json() -> String { }, "engine": { "defaultSessionTextCapacity": DEFAULT_SESSION_TEXT_CAPACITY, + "frameFlags": { + "compositingIndependent": crate::engine::frame::FRAME_FLAG_COMPOSITING_INDEPENDENT + }, "semanticF32Fields": { "inlineStart": SEMANTIC_F32_INLINE_START, "blockStart": SEMANTIC_F32_BLOCK_START, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index f42446fc..93f9afcf 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -1,4 +1,6 @@ pub(crate) const RESULT_FLAG_CHECKPOINT: u32 = 1; +pub(crate) const FRAME_FLAG_COMPOSITING_INDEPENDENT: u32 = 1; +pub(crate) const FRAME_FLAGS: u32 = FRAME_FLAG_COMPOSITING_INDEPENDENT; pub(crate) const SEMANTIC_VIEW_MEASUREMENT: u32 = 1 << 0; pub(crate) const SEMANTIC_VIEW_LAYOUT_INSPECTION: u32 = 1 << 1; pub(crate) const SEMANTIC_VIEW_MASK: u32 = @@ -96,6 +98,7 @@ pub(crate) struct UpdateRequest<'a> { pub policy_handle: u32, pub capability_set: u32, pub semantic_view_mask: u32, + pub compositing_independent: bool, pub limits: UpdateLimits, pub paragraph_mutations: super::semantic_wire::ParagraphMutationBatch<'a>, pub text_mutations: super::semantic_wire::TextMutationBatch<'a>, diff --git a/packages/text/rust/shaper/src/engine/frame_wire.rs b/packages/text/rust/shaper/src/engine/frame_wire.rs index 0edb1edb..27754af7 100644 --- a/packages/text/rust/shaper/src/engine/frame_wire.rs +++ b/packages/text/rust/shaper/src/engine/frame_wire.rs @@ -43,10 +43,13 @@ pub(crate) fn parse_update_request( || read_u32(bytes, ENGINE_UPDATE_SESSION_ID)? != session_id || read_u32(bytes, ENGINE_UPDATE_BYTE_LENGTH)? != u32::try_from(bytes.len()).map_err(|_| STATUS_INVALID_REQUEST)? - || read_u32(bytes, ENGINE_UPDATE_FLAGS)? != 0 { return Err(STATUS_INVALID_REQUEST); } + let flags = read_u32(bytes, ENGINE_UPDATE_FLAGS)?; + if flags & !super::frame::FRAME_FLAGS != 0 { + return Err(STATUS_INVALID_REQUEST); + } let semantic_view_mask = read_u32(bytes, ENGINE_UPDATE_SEMANTIC_VIEW_MASK)?; if semantic_view_mask & !super::frame::SEMANTIC_VIEW_MASK != 0 { return Err(STATUS_INVALID_REQUEST); @@ -144,6 +147,7 @@ pub(crate) fn parse_update_request( policy_handle: read_u32(bytes, ENGINE_UPDATE_POLICY_HANDLE)?, capability_set: positive(bytes, ENGINE_UPDATE_CAPABILITY_SET)?, semantic_view_mask, + compositing_independent: flags & super::frame::FRAME_FLAG_COMPOSITING_INDEPENDENT != 0, limits, paragraph_mutations, text_mutations, @@ -174,6 +178,25 @@ mod tests { assert_eq!(parsed.session_id, 4); assert_eq!(parsed.policy_handle, 9); + let mut independent = bytes.clone(); + write_u32( + &mut independent, + ENGINE_UPDATE_FLAGS, + super::super::frame::FRAME_FLAG_COMPOSITING_INDEPENDENT, + ); + assert!( + parse_update_request(&independent, 4) + .unwrap() + .compositing_independent + ); + + let mut unknown_flag = bytes.clone(); + write_u32(&mut unknown_flag, ENGINE_UPDATE_FLAGS, 1 << 31); + assert_eq!( + parse_update_request(&unknown_flag, 4), + Err(STATUS_INVALID_REQUEST) + ); + let mut measurement = bytes.clone(); write_u32( &mut measurement, diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index dba8b3fc..9c20c8d1 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -8,7 +8,10 @@ use alloc::vec::Vec; use core::mem; use super::{ - plan_input::{PlanInputError, span_bounds, validate_glyph, validate_input}, + plan_input::{ + PlanInputError, draw_fields_compatible, indexed_span_bounds, span_bounds, validate_glyph, + validate_input, + }, plan_packing::{ MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, align_record_range, align_up, apply_writes, coalesce_ranges, execute_run, grown_capacity, @@ -950,6 +953,23 @@ impl OrderedPlanCompiler { if context.capability.max_resources_per_draw < 1 { return Err(OrderedPlanError::CapacityExceeded); } + if context.input.order_independent { + self.compile_independent_draws(context)?; + } else { + self.compile_ordered_draws(context)?; + } + self.publish_bindings = context.checkpoint + || !self.patches.is_empty() + || !self.retirements.is_empty() + || self.primitives != self.live_primitives + || self.draws != self.live_draws; + Ok(()) + } + + fn compile_ordered_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), OrderedPlanError> { let mut input_index = 0_usize; while input_index < context.input.glyphs.len() { if self.input_batches[input_index] == NONE { @@ -1054,11 +1074,115 @@ impl OrderedPlanCompiler { }); input_index = end; } - self.publish_bindings = context.checkpoint - || !self.patches.is_empty() - || !self.retirements.is_empty() - || self.primitives != self.live_primitives - || self.draws != self.live_draws; + Ok(()) + } + + fn compile_independent_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), OrderedPlanError> { + for batch_index in 0..self.pending_batches.len() { + let batch = self.pending_batches[batch_index]; + let program = context + .policy + .program( + context.capability_set, + batch.state.key.technique, + batch.state.key.program_variant, + ) + .ok_or(OrderedPlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; + let instances = &self.pending_instances + [range(batch.state.instance_start, batch.state.instance_count)?]; + let mut start = 0_usize; + while start < instances.len() { + let first_input = instances[start].input_index as usize; + let first = context.input.glyphs[first_input]; + let mut end = start + 1; + while end < instances.len() && end - start < usize::from(u16::MAX) { + let glyph = context.input.glyphs[instances[end].input_index as usize]; + if draw_fields_compatible(first, glyph, split_material, split_transform) { + end += 1; + } else { + break; + } + } + let count = u16::try_from(end - start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = indexed_span_bounds( + context.input.glyphs, + instances[start..end] + .iter() + .map(|instance| instance.input_index as usize), + )?; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(OrderedPlanError::InvalidResource)?; + let semantic_id = if instances[start..end].iter().all(|instance| { + context.input.glyphs[instance.input_index as usize].semantic_id + == first.semantic_id + }) { + first.semantic_id + } else { + 0 + }; + let primitive_start = self.primitives.len(); + reserve(&mut self.primitives, 1)?; + self.primitives.push(PrimitiveRecord { + id: first.stable_id, + kind: PRIMITIVE_GLYPH, + technique_id: first.technique.0, + resource_id: first.resource_id, + resource_generation: first.resource_generation, + program_id: batch.state.key.program_id, + program_variant: first.program_variant, + record_count: count, + buffer_id: batch.buffer_ids[0], + record_index: u32::try_from(start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + logical_order: instances[start].input_index, + clip_id: first.clip_id, + semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + ..PrimitiveRecord::default() + }); + reserve(&mut self.draws, 1)?; + self.draws.push(DrawRecord { + id: first.stable_id, + program_id: batch.state.key.program_id, + program_variant: first.program_variant, + material_id: if split_material { first.material_id } else { 0 }, + clip_id: first.clip_id, + depth_key: first.depth_key, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + primitive_start: u32::try_from(primitive_start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + primitive_count: 1, + buffer_start: batch.state.buffer_start, + buffer_count: u32::from(batch.state.buffer_count), + resource_start: u32::try_from(resource_start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + resource_count: 1, + order_token: instances[start].input_index, + ..DrawRecord::default() + }); + start = end; + } + } + self.draws.sort_unstable_by_key(|draw| draw.order_token); Ok(()) } @@ -1080,10 +1204,7 @@ impl OrderedPlanCompiler { && glyph.program_variant == first.program_variant && glyph.resource_id == first.resource_id && glyph.resource_generation == first.resource_generation - && (!split_material || glyph.material_id == first.material_id) - && glyph.clip_id == first.clip_id - && glyph.depth_key == first.depth_key - && (!split_transform || glyph.transform_id == first.transform_id) + && draw_fields_compatible(first, glyph, split_material, split_transform) } fn prepare_removed_batches( @@ -1317,6 +1438,7 @@ mod tests { semantic_change_masks: &[1 << 1], f32_fields: &[&[1.0]], u32_fields: &[], + order_independent: false, }, false, 1, @@ -1340,6 +1462,7 @@ mod tests { semantic_change_masks: &[1], f32_fields: &[&[2.0]], u32_fields: &[], + order_independent: false, }, false, 1, @@ -1446,6 +1569,47 @@ mod tests { assert!(plan_layout(plan).is_ok()); } + #[test] + fn independent_compositing_batches_interleaved_resources() { + let policy = policy(); + let mut compiler = OrderedPlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + compiler + .prepare( + &policy, + CAPABILITY, + OrderedPlanInput { + glyphs: &glyphs, + semantic_change_masks: &[], + f32_fields: &[&[1.0, 2.0, 3.0, 4.0]], + u32_fields: &[], + order_independent: true, + }, + true, + 1, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.primitives[0].resource_id, 11); + assert_eq!(plan.primitives[0].record_count, 3); + assert_eq!(plan.primitives[1].resource_id, 12); + assert_eq!(plan.primitives[1].record_count, 1); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert!(plan_layout(plan).is_ok()); + } + #[test] fn material_identity_splits_draws_without_splitting_physical_storage() { let policy = policy(); @@ -1587,6 +1751,7 @@ mod tests { semantic_change_masks: &[], f32_fields: &[x], u32_fields: &[], + order_independent: false, }, checkpoint, 1, diff --git a/packages/text/rust/shaper/src/engine/plan_input.rs b/packages/text/rust/shaper/src/engine/plan_input.rs index f72b5b68..e98f8376 100644 --- a/packages/text/rust/shaper/src/engine/plan_input.rs +++ b/packages/text/rust/shaper/src/engine/plan_input.rs @@ -29,6 +29,8 @@ pub struct PlanInput<'a> { pub semantic_change_masks: &'a [u16], pub f32_fields: &'a [&'a [f32]], pub u32_fields: &'a [&'a [u32]], + /// The caller guarantees that reordering compatible draws cannot change compositing. + pub order_independent: bool, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -99,3 +101,41 @@ pub fn span_bounds(glyphs: &[PlanGlyph]) -> Result<(f32, f32, f32, f32), PlanInp } Ok((inline_start, block_start, inline_extent, block_extent)) } + +pub fn indexed_span_bounds( + glyphs: &[PlanGlyph], + mut indices: impl Iterator, +) -> Result<(f32, f32, f32, f32), PlanInputError> { + let first = glyphs + .get(indices.next().ok_or(PlanInputError::InvalidShape)?) + .ok_or(PlanInputError::InvalidShape)?; + let mut inline_start = first.inline_start; + let mut block_start = first.block_start; + let mut inline_end = first.inline_start + first.inline_extent; + let mut block_end = first.block_start + first.block_extent; + for index in indices { + let glyph = glyphs.get(index).ok_or(PlanInputError::InvalidShape)?; + inline_start = inline_start.min(glyph.inline_start); + block_start = block_start.min(glyph.block_start); + inline_end = inline_end.max(glyph.inline_start + glyph.inline_extent); + block_end = block_end.max(glyph.block_start + glyph.block_extent); + } + let inline_extent = inline_end - inline_start; + let block_extent = block_end - block_start; + if !inline_extent.is_finite() || !block_extent.is_finite() { + return Err(PlanInputError::InvalidShape); + } + Ok((inline_start, block_start, inline_extent, block_extent)) +} + +pub fn draw_fields_compatible( + first: PlanGlyph, + next: PlanGlyph, + split_material: bool, + split_transform: bool, +) -> bool { + (!split_material || next.material_id == first.material_id) + && next.clip_id == first.clip_id + && next.depth_key == first.depth_key + && (!split_transform || next.transform_id == first.transform_id) +} diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index 71dd2a7a..8e3ff962 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -404,6 +404,7 @@ impl GatheredPlanInput<'_> { semantic_change_masks: self.semantic_change_masks, f32_fields: &self.f32_fields[..self.f32_field_count], u32_fields: &self.u32_fields[..self.u32_field_count], + order_independent: false, } } } diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index 8ec58690..0839d464 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -861,6 +861,7 @@ mod tests { semantic_change_masks: &[], f32_fields: &[x], u32_fields: &[], + order_independent: false, }, checkpoint, publication_generation, diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index ea4c0433..6b0ce329 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -9,7 +9,10 @@ use alloc::vec::Vec; use core::mem; use super::{ - plan_input::{PlanInputError, span_bounds, validate_glyph, validate_input}, + plan_input::{ + PlanInputError, draw_fields_compatible, indexed_span_bounds, span_bounds, validate_glyph, + validate_input, + }, plan_packing::{ MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, align_record_range, align_up, apply_writes, coalesce_ranges, execute_run, grown_capacity, @@ -1276,6 +1279,17 @@ impl StablePlanCompiler { if context.capability.max_resources_per_draw < 1 { return Err(StablePlanError::CapacityExceeded); } + if context.input.order_independent { + self.compile_independent_draws(context) + } else { + self.compile_ordered_draws(context) + } + } + + fn compile_ordered_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), StablePlanError> { let mut input_index = 0_usize; while input_index < context.input.glyphs.len() { if self.input_batches[input_index] == NONE { @@ -1388,6 +1402,119 @@ impl StablePlanCompiler { Ok(()) } + fn compile_independent_draws( + &mut self, + context: PrepareContext<'_>, + ) -> Result<(), StablePlanError> { + for pending_index in 0..self.pending_batches.len() { + let pending = self.pending_batches[pending_index]; + let key = self.batches[pending.batch_index as usize].key; + let program = context + .policy + .program(context.capability_set, key.technique, key.program_variant) + .ok_or(StablePlanError::ProgramMissing)?; + let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; + let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; + let input_indices = &self.batch_input_indices + [range(pending.item_start, pending.item_count)?]; + let mut start = 0_usize; + while start < input_indices.len() { + let first_input = input_indices[start] as usize; + let first = context.input.glyphs[first_input]; + let first_record = self.input_order_records[first_input]; + let mut end = start + 1; + while end < input_indices.len() && end - start < usize::from(u16::MAX) { + let input_index = input_indices[end] as usize; + let glyph = context.input.glyphs[input_index]; + if self.input_order_records[input_index] + == first_record + (end - start) as u32 + && draw_fields_compatible(first, glyph, split_material, split_transform) + { + end += 1; + } else { + break; + } + } + let count = u16::try_from(end - start) + .map_err(|_| StablePlanError::ArithmeticOverflow)?; + let (inline_start, block_start, inline_extent, block_extent) = indexed_span_bounds( + context.input.glyphs, + input_indices[start..end] + .iter() + .map(|input_index| *input_index as usize), + )?; + let resource_start = self + .resources + .iter() + .position(|resource| { + resource.id == first.resource_id + && resource.generation == first.resource_generation + }) + .ok_or(StablePlanError::InvalidResource)?; + let semantic_id = if input_indices[start..end].iter().all(|input_index| { + context.input.glyphs[*input_index as usize].semantic_id == first.semantic_id + }) { + first.semantic_id + } else { + 0 + }; + let primitive_start = self.primitives.len(); + reserve(&mut self.primitives, 1)?; + self.primitives.push(PrimitiveRecord { + id: first.stable_id, + kind: PRIMITIVE_GLYPH, + technique_id: first.technique.0, + resource_id: first.resource_id, + resource_generation: first.resource_generation, + program_id: key.program_id, + program_variant: first.program_variant, + record_count: count, + buffer_id: pending.order_buffer_id, + record_index: first_record, + logical_order: input_indices[start], + clip_id: first.clip_id, + semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + ..PrimitiveRecord::default() + }); + reserve(&mut self.draws, 1)?; + self.draws.push(DrawRecord { + id: first.stable_id, + program_id: key.program_id, + program_variant: first.program_variant, + material_id: if split_material { first.material_id } else { 0 }, + clip_id: first.clip_id, + depth_key: first.depth_key, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + primitive_start: u32::try_from(primitive_start) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + primitive_count: 1, + buffer_start: pending.buffer_start, + buffer_count: u32::from(pending.buffer_count), + resource_start: u32::try_from(resource_start) + .map_err(|_| StablePlanError::ArithmeticOverflow)?, + resource_count: 1, + order_token: input_indices[start], + indirect_buffer_id: pending.order_buffer_id, + indirect_offset: first_record + .checked_mul(4) + .ok_or(StablePlanError::ArithmeticOverflow)?, + ..DrawRecord::default() + }); + start = end; + } + } + self.draws.sort_unstable_by_key(|draw| draw.order_token); + Ok(()) + } + fn same_draw_span( &self, glyphs: &[StableGlyph], @@ -1406,10 +1533,7 @@ impl StablePlanCompiler { && glyph.program_variant == first.program_variant && glyph.resource_id == first.resource_id && glyph.resource_generation == first.resource_generation - && (!split_material || glyph.material_id == first.material_id) - && glyph.clip_id == first.clip_id - && glyph.depth_key == first.depth_key - && (!split_transform || glyph.transform_id == first.transform_id) + && draw_fields_compatible(first, glyph, split_material, split_transform) } fn next_buffer_identity( @@ -1716,6 +1840,7 @@ mod tests { semantic_change_masks: &[1 << 1], f32_fields: &[&[1.0]], u32_fields: &[], + order_independent: false, }, false, 2, @@ -1740,6 +1865,7 @@ mod tests { semantic_change_masks: &[1], f32_fields: &[&[2.0]], u32_fields: &[], + order_independent: false, }, false, 3, @@ -1919,6 +2045,48 @@ mod tests { assert!(plan_layout(plan).is_ok(), "{:?}", plan_layout(plan)); } + #[test] + fn independent_compositing_batches_interleaved_stable_resources() { + let policy = policy(false); + let mut compiler = StablePlanCompiler::default(); + let a1 = glyph(1, 1); + let a2 = glyph(2, 1); + let mut b = glyph(3, 1); + b.resource_id = 12; + b.resource_reference = 100; + let a3 = glyph(4, 1); + let glyphs = [a1, a2, b, a3]; + compiler + .prepare( + &policy, + CAPABILITY, + StablePlanInput { + glyphs: &glyphs, + semantic_change_masks: &[], + f32_fields: &[&[1.0, 2.0, 3.0, 4.0]], + u32_fields: &[], + order_independent: true, + }, + true, + 1, + 0, + ) + .unwrap(); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + + assert_eq!(plan.draws.len(), 2); + assert_eq!(plan.primitives.len(), 2); + assert_eq!(plan.primitives[0].resource_id, 11); + assert_eq!(plan.primitives[0].record_count, 3); + assert_eq!(plan.primitives[1].resource_id, 12); + assert_eq!(plan.primitives[1].record_count, 1); + assert_eq!(plan.draws[0].order_token, 0); + assert_eq!(plan.draws[1].order_token, 2); + assert!(plan_layout(plan).is_ok(), "{:?}", plan_layout(plan)); + } + #[test] fn material_splits_draws_without_splitting_stable_storage() { let policy = policy(false); @@ -2052,6 +2220,7 @@ mod tests { semantic_change_masks: &[], f32_fields: &[x], u32_fields: &[], + order_independent: false, }, checkpoint, publication_generation, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 397f02f5..f2fae923 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -95,6 +95,8 @@ struct EngineSession { pending_ordered_paragraphs: Vec, lifecycle_prepared: bool, lifecycle_changed: bool, + compositing_independent: bool, + pending_compositing_independent: bool, } struct RetainedParagraph { @@ -585,6 +587,7 @@ impl TextEngine { .any(|paragraph| paragraph.positioned_changed); let reuse_ordered_plan = !checkpoint && !positioned_changed + && request.compositing_independent == session.compositing_independent && policy .programs() .iter() @@ -644,12 +647,14 @@ impl TextEngine { .map_err(gather_error)?; } let gathered = gather.view(); + let mut plan_input = gathered.plan_input(); + plan_input.order_independent = request.compositing_independent; session .plan .prepare( policy, CapabilitySetId(request.capability_set), - gathered.plan_input(), + plan_input, checkpoint, publication_generation, request.acknowledged_publication_generation, @@ -718,6 +723,7 @@ impl TextEngine { } session.pending_next_glyph_id = next_glyph_id; session.pending_next_content_revision = next_content_revision; + session.pending_compositing_independent = request.compositing_independent; Ok(()) })(); if let Err(error) = preparation { @@ -800,6 +806,7 @@ impl TextEngine { session.next_content_revision = session.pending_next_content_revision; session.pending_next_glyph_id = 0; session.pending_next_content_revision = 0; + session.compositing_independent = session.pending_compositing_independent; session.policy_binding = Some(PolicyBinding { handle: prepared.policy_handle, fingerprint: prepared.policy_fingerprint, @@ -988,6 +995,7 @@ impl EngineSession { self.abort_lifecycle(); self.pending_next_glyph_id = 0; self.pending_next_content_revision = 0; + self.pending_compositing_independent = self.compositing_independent; } fn abort_lifecycle(&mut self) { @@ -3099,6 +3107,7 @@ mod tests { policy_handle: 9, capability_set: 1, semantic_view_mask: 0, + compositing_independent: false, limits: super::super::frame::UpdateLimits { max_paragraphs: 1, max_clusters: 1, diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 66c8b982..ac6e562f 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -74,6 +74,9 @@ export const textShaperAbi = { "polygon": 2, "rectangle": 1 }, + "frameFlags": { + "compositingIndependent": 1 + }, "inlineAlignments": { "center": 2, "end": 3, diff --git a/packages/text/src/internal/engine-frame-wire.ts b/packages/text/src/internal/engine-frame-wire.ts index 7f322620..acb5e238 100644 --- a/packages/text/src/internal/engine-frame-wire.ts +++ b/packages/text/src/internal/engine-frame-wire.ts @@ -161,6 +161,7 @@ export interface TextEngineFrameUpdate { readonly consumedPlanRevision: number; readonly acknowledgedPublicationGeneration: number; readonly semanticViewMask?: number; + readonly compositingIndependent?: boolean; readonly limits: TextEngineFrameLimits; readonly paragraphMutations?: readonly TextEngineParagraphMutation[]; readonly textMutations?: readonly TextEngineTextMutation[]; @@ -263,6 +264,11 @@ interface HeaderOffsets { function writeHeader(view: DataView, frame: TextEngineFrameUpdate, byteLength: number, offsets: HeaderOffsets): void { const layout = textShaperAbi.layouts.engineUpdateRequest; const limits = frame.limits; + view.setUint32( + layout.flags, + frame.compositingIndependent === true ? textShaperAbi.engine.frameFlags.compositingIndependent : 0, + true, + ); for (const [field, value] of [ ['abiVersion', textShaperAbi.version], ['byteLength', byteLength], diff --git a/packages/text/src/r3f.ts b/packages/text/src/r3f.ts index 44e2ef88..42e8b785 100644 --- a/packages/text/src/r3f.ts +++ b/packages/text/src/r3f.ts @@ -144,6 +144,7 @@ export function TextGroup(input: R3fTextGroupProps): ReactElement | null { const createObject = useEffectEvent(() => { const created = new ThreeTextGroup({ ...(properties.capacity === undefined ? {} : { capacity: properties.capacity }), + ...(properties.compositing === undefined ? {} : { compositing: properties.compositing }), ...(properties.renderOrder === undefined ? {} : { renderOrder: properties.renderOrder }), ...(properties.material === undefined ? {} : { material: properties.material }), }); @@ -342,7 +343,7 @@ function objectProperties(properties: R3fT function groupObjectProperties(properties: R3fTextGroupProps): Object3DProps { const object = { ...properties } as Record; - for (const key of ['capacity', 'material', 'children', 'onError', 'ref']) delete object[key]; + for (const key of ['capacity', 'compositing', 'material', 'children', 'onError', 'ref']) delete object[key]; return object as Object3DProps; } diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 2f5134ae..87e6f6b5 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -27,6 +27,8 @@ export type { ThreePlanProgramMaterialContext, ThreeRasterPlanProgram, } from './three/plan-program-registry.js'; +export { setThreeTextProfiler, threeTextUserTimingProfiler } from './three/profiler.js'; +export type { ThreeTextProfiler, ThreeTextProfilePhase } from './three/profiler.js'; export { msdfShader } from './three/msdf-shader.js'; export type { ThreeMsdfInstanceNodes, ThreeMsdfShaderOutput, ThreeMsdfShaderResources } from './three/msdf-shader.js'; export type { diff --git a/packages/text/src/three/profiler.ts b/packages/text/src/three/profiler.ts new file mode 100644 index 00000000..013dc541 --- /dev/null +++ b/packages/text/src/three/profiler.ts @@ -0,0 +1,33 @@ +/** Synchronous host phases surrounding one retained Rust text update. */ +export type ThreeTextProfilePhase = + | 'frame.total' + | 'frame.prepare' + | 'engine.update' + | 'plan.apply' + | 'semantic.read' + | 'transforms.sync'; + +/** Receives one completed phase whose timestamps share the `performance.now()` origin. */ +export type ThreeTextProfiler = (phase: ThreeTextProfilePhase, startedMs: number, endedMs: number) => void; + +let activeProfiler: ThreeTextProfiler | undefined; + +/** Installs optional process-wide diagnostics. Passing `undefined` restores the allocation-free inactive path. */ +export function setThreeTextProfiler(profiler: ThreeTextProfiler | undefined): void { + activeProfiler = profiler; +} + +/** Creates Chrome/Node User Timing entries without requiring paired named marks. */ +export function threeTextUserTimingProfiler(prefix = '@pmndrs/text'): ThreeTextProfiler { + return (phase, startedMs, endedMs) => { + performance.measure(`${prefix} ${phase}`, { start: startedMs, duration: endedMs - startedMs }); + }; +} + +export function textProfileBegin(): number { + return activeProfiler === undefined ? 0 : performance.now(); +} + +export function textProfileEnd(phase: ThreeTextProfilePhase, startedMs: number): void { + if (activeProfiler !== undefined) activeProfiler(phase, startedMs, performance.now()); +} diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index d87aa1da..e7095a77 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -43,8 +43,13 @@ import { type ThreeTextEngineStackLease, } from './engine-runtime.js'; import type { ThreeTextMaterial } from './material.js'; +import { textProfileBegin, textProfileEnd } from './profiler.js'; const MAX_TEXT_ENGINE_OUTPUT_BYTES = 64 * 1024 * 1024; +const TEXT_CHANGE = 1 << 0; +const STYLE_CHANGE = 1 << 1; +const GEOMETRY_CHANGE = 1 << 2; +const ALL_SEMANTIC_CHANGES = TEXT_CHANGE | STYLE_CHANGE | GEOMETRY_CHANGE; export type TextSpan = Omit, 'renderVariant'> & Readonly<{ material?: ThreeTextMaterial }>; @@ -73,6 +78,8 @@ export type TextUpdate = export interface TextGroupOptions { readonly capacity?: GlyphBufferCapacity; + /** Allows Rust to reorder compatible draws when descendants do not require compositing order. */ + readonly compositing?: 'ordered' | 'independent'; readonly renderOrder?: number; readonly material?: ThreeTextMaterial; } @@ -111,6 +118,7 @@ export class Text extends THREE.Object3D { #textGroup: TextGroup | undefined; #desiredRevision = 0; #appliedRevision = -1; + #semanticChanges = ALL_SEMANTIC_CHANGES; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; @@ -192,13 +200,17 @@ export class Text extends THREE.Object3D { set(update: TextUpdate): void { this.#assertActive(); - const next = normalizeDesired({ ...this.#desired, ...replacedContent(update) } as TextProperties); + const normalizedUpdate = replacedContent(update); + const changes = classifySemanticChanges(normalizedUpdate); + if (changes === 0) return; + const next = normalizeDesired({ ...this.#desired, ...normalizedUpdate } as TextProperties); const fonts = selectedFonts(next); acquireFonts(fonts, this.#runtime); releaseFonts(this.#leasedFonts); this.#leasedFonts = fonts; this.#desired = next; this.#desiredRevision += 1; + this.#semanticChanges |= changes; } setSpan(index: number, span: TextSpan): void { @@ -297,8 +309,12 @@ export class Text extends THREE.Object3D { needsApply(): boolean { return this.#desiredRevision !== this.#appliedRevision; } + semanticChanges(): number { + return this.#semanticChanges; + } markApplied(): void { this.#appliedRevision = this.#desiredRevision; + this.#semanticChanges = 0; } bind(binding: ThreeTextBatchBinding, group: TextGroup | undefined): void { if (this.#binding !== binding) this.#unbind(); @@ -329,6 +345,7 @@ export class Text extends THREE.Object3D { export class TextGroup extends THREE.Object3D { #capacity: GlyphBufferCapacity; + readonly #compositing: 'ordered' | 'independent'; #material: ThreeTextMaterial | undefined; #binding: ThreeTextBatchBinding | undefined; #disposed = false; @@ -338,12 +355,16 @@ export class TextGroup extends THREE.Object3D { constructor(options: TextGroupOptions = {}) { super(); this.#capacity = normalizeCapacity(options.capacity ?? { size: 4_096, policy: 'chunk' }); + this.#compositing = normalizeCompositing(options.compositing); this.#material = options.material; if (options.renderOrder !== undefined) this.renderOrder = options.renderOrder; } get capacity(): GlyphBufferCapacity { return this.#capacity; } + get compositing(): 'ordered' | 'independent' { + return this.#compositing; + } get textCount(): number { return this.#binding?.textCount ?? 0; } @@ -506,33 +527,12 @@ class ThreeTextBatchBinding { } measurement(text: Text): ParagraphLayoutSummary | undefined { if (!this.#paragraphs.has(text)) return undefined; - this.synchronize(); - const cached = this.#measurements.get(text); - if (cached !== undefined) return cached; - const publication = this.#querySemanticViews(textShaperAbi.engine.semanticViewMasks.measurement); - const measurements = readTextEngineMeasurements(publication); - this.#acknowledgedPublicationGeneration = publication.publicationGeneration; - for (const [paragraphId, measurement] of measurements) { - const measuredText = this.#textsByParagraph.get(paragraphId); - if (measuredText === undefined) throw new Error(`text engine measured unknown paragraph ${paragraphId}`); - this.#measurements.set(measuredText, measurement); - } + this.synchronize(textShaperAbi.engine.semanticViewMasks.measurement); return this.#measurements.get(text); } layoutInspection(text: Text): ParagraphLayoutInspection | undefined { if (!this.#paragraphs.has(text)) return undefined; - this.synchronize(); - const cached = this.#layoutInspections.get(text); - if (cached !== undefined) return cached; - const publication = this.#querySemanticViews(textShaperAbi.engine.semanticViewMasks.layoutInspection); - const layouts = readTextEngineLayouts(publication); - this.#acknowledgedPublicationGeneration = publication.publicationGeneration; - for (const [paragraphId, layout] of layouts) { - const inspectedText = this.#textsByParagraph.get(paragraphId); - if (inspectedText === undefined) throw new Error(`text engine inspected unknown paragraph ${paragraphId}`); - this.#measurements.set(inspectedText, layout); - this.#layoutInspections.set(inspectedText, layout); - } + this.synchronize(textShaperAbi.engine.semanticViewMasks.layoutInspection); return this.#layoutInspections.get(text); } glyphOriginSnapshot(text: Text): TextGlyphOriginSnapshot | undefined { @@ -562,20 +562,31 @@ class ThreeTextBatchBinding { reconcileStandalone(text: Text): void { this.#ensureText(text, undefined); } - synchronize(): void { + synchronize(semanticViewMask = 0): void { if (this.#disposed) return; + const frameStarted = textProfileBegin(); const ordered = [...this.#paragraphs.entries()].sort( ([leftText, left], [rightText, right]) => leftText.renderOrder - rightText.renderOrder || left.id - right.id, ); - const changed = ordered.flatMap(([text, paragraph], order) => - paragraph.created || this.#materialInvalidated || text.needsApply() || paragraph.order !== order - ? [{ text, paragraph, order }] - : [], - ); + const changed = ordered.flatMap(([text, paragraph], order) => { + const semanticChanges = + (paragraph.created ? ALL_SEMANTIC_CHANGES : text.semanticChanges()) | + (this.#materialInvalidated ? STYLE_CHANGE : 0); + return semanticChanges !== 0 || text.needsApply() || paragraph.order !== order + ? [{ text, paragraph, order, semanticChanges }] + : []; + }); if (changed.length === 0 && this.#removed.length === 0) { + const transformsStarted = textProfileBegin(); this.#target.syncTransforms(); + textProfileEnd('transforms.sync', transformsStarted); + if (semanticViewMask !== 0 && !this.#hasSemanticViews(semanticViewMask)) { + this.#retainSemanticViews(this.#querySemanticViews(semanticViewMask), semanticViewMask); + } + textProfileEnd('frame.total', frameStarted); return; } + const preparingStarted = textProfileBegin(); const paragraphMutations = [ ...this.#removed.map((paragraph) => ({ opcode: 'remove' as const, paragraphId: paragraph.id })), ...changed.map(({ paragraph, order }) => ({ @@ -590,36 +601,44 @@ class ThreeTextBatchBinding { const regions: TextEngineRegion[] = []; const pendingLeases = new Map(); const pendingMaterials = new Map(); + const pendingChanges = new Map(); let committed = false; try { - for (const { text, paragraph } of changed) { + for (const { text, paragraph, semanticChanges } of changed) { + pendingChanges.set(paragraph, semanticChanges); const properties = text.coreProperties(); const content = properties.text as string; - textMutations.push({ - paragraphId: paragraph.id, - start: 0, - deleteCount: paragraph.textLength, - insert: content, - }); - const leases: ThreeTextEngineStackLease[] = []; - const materials: ThreeTextMaterialLease[] = []; - pendingLeases.set(paragraph, leases); - pendingMaterials.set(paragraph, materials); - const styles = compileEngineStyles( - this.#coordinator, - paragraph.id, - properties, - this.#group?.material, - leases, - materials, - ); - styleMutations.push(...styles); - for (let styleId = styles.length + 1; styleId <= paragraph.styleCount; styleId += 1) { - styleMutations.push({ opcode: 'remove', paragraphId: paragraph.id, styleId }); + if (semanticChanges & TEXT_CHANGE) { + textMutations.push({ + paragraphId: paragraph.id, + start: 0, + deleteCount: paragraph.textLength, + insert: content, + }); + } + if (semanticChanges & STYLE_CHANGE) { + const leases: ThreeTextEngineStackLease[] = []; + const materials: ThreeTextMaterialLease[] = []; + pendingLeases.set(paragraph, leases); + pendingMaterials.set(paragraph, materials); + const styles = compileEngineStyles( + this.#coordinator, + paragraph.id, + properties, + this.#group?.material, + leases, + materials, + ); + styleMutations.push(...styles); + for (let styleId = styles.length + 1; styleId <= paragraph.styleCount; styleId += 1) { + styleMutations.push({ opcode: 'remove', paragraphId: paragraph.id, styleId }); + } + } + if (semanticChanges & GEOMETRY_CHANGE) { + const geometry = compileEngineGeometry(paragraph, properties.contentBox, regions.length, content.length); + constraints.push(geometry.constraint); + regions.push(geometry.region); } - const geometry = compileEngineGeometry(paragraph, properties.contentBox, regions.length, content.length); - constraints.push(geometry.constraint); - regions.push(geometry.region); } const totalTextLength = [...this.#paragraphs.keys()].reduce((total, text) => total + text.text.length, 0); const limits = engineLimits( @@ -636,6 +655,8 @@ class ThreeTextBatchBinding { expectedEngineRevision: this.#engineRevision, consumedPlanRevision: this.#planRevision, acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + compositingIndependent: this.#group?.compositing === 'independent', + semanticViewMask, limits, paragraphMutations, textMutations, @@ -643,9 +664,12 @@ class ThreeTextBatchBinding { constraints, regions, }); + textProfileEnd('frame.prepare', preparingStarted); let publication: TextEnginePublication; try { + const updateStarted = textProfileBegin(); publication = this.#session.update(frame); + textProfileEnd('engine.update', updateStarted); } catch (error) { if (error instanceof TextEngineStatusError) { error.message += @@ -662,19 +686,20 @@ class ThreeTextBatchBinding { for (const removed of this.#removed) releaseMaterialLeases(removed.materialLeases); this.#removed.length = 0; for (const [order, [text, paragraph]] of ordered.entries()) { - if (!pendingLeases.has(paragraph) && paragraph.order === order) continue; + const semanticChanges = pendingChanges.get(paragraph) ?? 0; + if (semanticChanges === 0 && paragraph.order === order) continue; const nextLeases = pendingLeases.get(paragraph); if (nextLeases !== undefined) { releaseStackLeases(paragraph.stackLeases); releaseMaterialLeases(paragraph.materialLeases); paragraph.stackLeases = nextLeases; paragraph.materialLeases = pendingMaterials.get(paragraph) ?? []; - paragraph.textLength = text.text.length; paragraph.styleCount = 1 + text.spans.length; - paragraph.geometryRevision += 1; - paragraph.created = false; - text.markApplied(); } + if (semanticChanges & TEXT_CHANGE) paragraph.textLength = text.text.length; + if (semanticChanges & GEOMETRY_CHANGE) paragraph.geometryRevision += 1; + paragraph.created = false; + text.markApplied(); paragraph.order = order; } this.#materialInvalidated = false; @@ -682,13 +707,17 @@ class ThreeTextBatchBinding { this.#layoutInspections.clear(); committed = true; try { + const applyStarted = textProfileBegin(); this.#target.apply(publication); + textProfileEnd('plan.apply', applyStarted); this.#lastPublication = undefined; } catch (error) { this.#lastPublication = ownPublication(publication); throw error; } this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + this.#retainSemanticViews(publication, semanticViewMask); + textProfileEnd('frame.total', frameStarted); } catch (error) { if (!committed) { for (const leases of pendingLeases.values()) releaseStackLeases(leases); @@ -772,6 +801,7 @@ class ThreeTextBatchBinding { #querySemanticViews(semanticViewMask: number): TextEnginePublication { const totalTextLength = [...this.#paragraphs.keys()].reduce((total, entry) => total + entry.text.length, 0); + const updateStarted = textProfileBegin(); const publication = this.#session.update( compileTextEngineFrameUpdate({ sessionId: this.#session.handle, @@ -780,6 +810,7 @@ class ThreeTextBatchBinding { expectedEngineRevision: this.#engineRevision, consumedPlanRevision: this.#planRevision, acknowledgedPublicationGeneration: this.#acknowledgedPublicationGeneration, + compositingIndependent: this.#group?.compositing === 'independent', semanticViewMask, limits: engineLimits( this.#paragraphs.size, @@ -789,10 +820,44 @@ class ThreeTextBatchBinding { ), }), ); + textProfileEnd('engine.update', updateStarted); this.#engineRevision = publication.engineRevision; this.#planRevision = publication.planRevision; return publication; } + + #hasSemanticViews(semanticViewMask: number): boolean { + if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.measurement) { + return this.#measurements.size === this.#paragraphs.size; + } + if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.layoutInspection) { + return this.#layoutInspections.size === this.#paragraphs.size; + } + return false; + } + + #retainSemanticViews(publication: TextEnginePublication, semanticViewMask: number): void { + if (semanticViewMask === 0) return; + const readingStarted = textProfileBegin(); + if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.measurement) { + for (const [paragraphId, measurement] of readTextEngineMeasurements(publication)) { + const measuredText = this.#textsByParagraph.get(paragraphId); + if (measuredText === undefined) throw new Error(`text engine measured unknown paragraph ${paragraphId}`); + this.#measurements.set(measuredText, measurement); + } + } else if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.layoutInspection) { + for (const [paragraphId, layout] of readTextEngineLayouts(publication)) { + const inspectedText = this.#textsByParagraph.get(paragraphId); + if (inspectedText === undefined) throw new Error(`text engine inspected unknown paragraph ${paragraphId}`); + this.#measurements.set(inspectedText, layout); + this.#layoutInspections.set(inspectedText, layout); + } + } else { + throw new RangeError(`unsupported semantic view mask ${semanticViewMask}`); + } + this.#acknowledgedPublicationGeneration = publication.publicationGeneration; + textProfileEnd('semantic.read', readingStarted); + } } function compileEngineStyles( @@ -1026,6 +1091,23 @@ function replacedContent(update: TextUpdat return { ...update, spans: [] } as TextUpdate; } +function classifySemanticChanges(update: TextUpdate): number { + let changes = 0; + if (Object.hasOwn(update, 'text')) changes |= TEXT_CHANGE | STYLE_CHANGE | GEOMETRY_CHANGE; + if ( + Object.hasOwn(update, 'font') || + Object.hasOwn(update, 'spans') || + Object.hasOwn(update, 'style') || + Object.hasOwn(update, 'paint') || + Object.hasOwn(update, 'rasterPixelRatio') || + Object.hasOwn(update, 'material') + ) { + changes |= STYLE_CHANGE; + } + if (Object.hasOwn(update, 'contentBox')) changes |= GEOMETRY_CHANGE; + return changes; +} + function normalizeDesired( properties: TextProperties, ): DesiredTextState { @@ -1075,6 +1157,11 @@ function normalizeCapacity(value: GlyphBufferCapacity): GlyphBufferCapacity { throw new TypeError('glyph capacity policy is invalid'); return Object.freeze({ size: value.size, policy: value.policy }); } +function normalizeCompositing(value: TextGroupOptions['compositing']): 'ordered' | 'independent' { + if (value === undefined || value === 'ordered') return 'ordered'; + if (value === 'independent') return value; + throw new TypeError('text group compositing mode is invalid'); +} function nearestTextGroup(object: THREE.Object3D): TextGroup | undefined { let parent = object.parent; while (parent !== null) { diff --git a/packages/text/tests/integration/engine-frame-wire.test.mjs b/packages/text/tests/integration/engine-frame-wire.test.mjs index b441508f..3c071837 100644 --- a/packages/text/tests/integration/engine-frame-wire.test.mjs +++ b/packages/text/tests/integration/engine-frame-wire.test.mjs @@ -106,6 +106,7 @@ test('production frame compiler carries full style, polygon, exclusion, and inli consumedPlanRevision: 4, acknowledgedPublicationGeneration: 5, semanticViewMask: 6, + compositingIndependent: true, limits: { maxParagraphs: 4, maxClusters: 32, @@ -218,6 +219,7 @@ test('production frame compiler carries full style, polygon, exclusion, and inli }); const request = abi.layouts.engineUpdateRequest; const header = new DataView(bytes.buffer, bytes.byteOffset, request.size); + assert.equal(header.getUint32(request.flags, true), abi.engine.frameFlags.compositingIndependent); assert.equal(header.getUint32(request.byteLength, true), bytes.byteLength); assert.equal(header.getUint32(request.styleMutationCount, true), 1); assert.equal(header.getUint32(request.regionCount, true), 1); diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index f8772739..aa6bfc0e 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import { createRuntimeShaper, createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; -import { Text, TextGroup } from '@pmndrs/text/three'; +import { setThreeTextProfiler, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; const fontUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); @@ -152,6 +152,35 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn assert.equal(transforms.array[1 * 16 + 12], 2); assert.equal(transforms.array[2 * 16 + 12], 5); + const initialLeftMeasurement = left.measureLayout(); + const initialRightMeasurement = right.measureLayout(); + assert.ok(initialLeftMeasurement); + assert.ok(initialRightMeasurement); + let updateCrossings = 0; + setThreeTextProfiler((phase) => { + if (phase === 'engine.update') updateCrossings += 1; + }); + try { + left.set({}); + assert.equal(left.measureLayout(), initialLeftMeasurement, 'an empty update must preserve the cached measurement'); + scene.updateMatrixWorld(); + assert.equal(updateCrossings, 0, 'an empty update and cached measurement must not cross into Rust'); + + left.contentBox = { width: { mode: 'exact', size: 100 }, wrap: 'word' }; + const resizedMeasurement = left.measureLayout(); + assert.ok(resizedMeasurement, 'a pending mutation must produce its requested measurement'); + assert.notEqual(resizedMeasurement, initialLeftMeasurement); + assert.deepEqual( + right.measureLayout(), + initialRightMeasurement, + 'one requested semantic publication must populate every retained paragraph', + ); + scene.updateMatrixWorld(); + } finally { + setThreeTextProfiler(undefined); + } + assert.equal(updateCrossings, 1, 'mutation, render plan, and demanded measurement must share one text_update'); + const leftOrigins = left.snapshotGlyphOrigins(); const rightOrigins = right.snapshotGlyphOrigins(); assert.equal(leftOrigins.shapedX.length, 2); @@ -184,6 +213,23 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn 'an authoritative command-buffer update must retire the previous presentation override', ); + updateCrossings = 0; + setThreeTextProfiler((phase) => { + if (phase === 'engine.update') updateCrossings += 1; + }); + try { + left.text = 'ABC'; + const replacedMeasurement = left.measureLayout(); + assert.equal(replacedMeasurement?.glyphCount, 3); + scene.updateMatrixWorld(); + } finally { + setThreeTextProfiler(undefined); + } + assert.equal(updateCrossings, 1, 'text replacement and demanded measurement must share one text_update'); + const replacedDraws = group.children.filter((child) => child.isMesh); + assert.equal(replacedDraws.length, 1); + assert.equal(replacedDraws[0].geometry.instanceCount, 5, 'the published command buffer must include the new glyph'); + group.dispose(); left.dispose(); right.dispose(); diff --git a/packages/text/tests/types/r3f-v1-api.test.ts b/packages/text/tests/types/r3f-v1-api.test.ts index 889cd05f..1e8c04e1 100644 --- a/packages/text/tests/types/r3f-v1-api.test.ts +++ b/packages/text/tests/types/r3f-v1-api.test.ts @@ -12,7 +12,7 @@ declare const material: ThreeTextMaterial; const inline = createElement(Text, { paint: { color: '#ff00ff' } }, 'span'); const label = createElement(Text, { font: bitmapFont, material }, 'Typed ', inline); -const labels = createElement(TextGroup, { material }, label); +const labels = createElement(TextGroup, { compositing: 'independent', material }, label); function FontConsumer(): null { const loaded: LoadedFont = useFont({ diff --git a/packages/text/tests/types/three-v1-api.test.ts b/packages/text/tests/types/three-v1-api.test.ts index 7c3bb069..ec145a7b 100644 --- a/packages/text/tests/types/three-v1-api.test.ts +++ b/packages/text/tests/types/three-v1-api.test.ts @@ -1,14 +1,25 @@ import type { LoadedFont } from '../../src/index.js'; import { bitmap } from '../../src/raster/bitmap-technique.js'; import { msdf } from '../../src/raster/msdf.js'; -import { FontLoader, span, Text, TextGroup, txt } from '../../src/three.js'; +import { + FontLoader, + setThreeTextProfiler, + span, + Text, + TextGroup, + threeTextUserTimingProfiler, + txt, +} from '../../src/three.js'; declare const bitmapFont: LoadedFont; declare const mtsdfFont: LoadedFont; const emphasis = span(bitmapFont, { color: '#ff00ff' }); const label = new Text({ font: bitmapFont, text: txt`Typed ${emphasis`span`}` }); -const labels = new TextGroup(); +const labels = new TextGroup({ compositing: 'independent' }); +const compositing: 'ordered' | 'independent' = labels.compositing; +setThreeTextProfiler(threeTextUserTimingProfiler('test')); +setThreeTextProfiler(undefined); labels.add(label); label.text = 'Updated'; label.setCapacity({ size: 64, policy: 'grow' }); @@ -25,3 +36,4 @@ const loaded = loader.loadAsync({ }); void loaded; void labels; +void compositing; From f10c22187e1dbcb97e7bc43578e235d583a74c67 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 08:37:04 -0400 Subject: [PATCH 086/128] test(text): prove narrowed shaping boundaries --- .../paragraph-bidi-policy.test.mjs | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/packages/text/tests/integration/paragraph-bidi-policy.test.mjs b/packages/text/tests/integration/paragraph-bidi-policy.test.mjs index 5730c2e3..174e3edd 100644 --- a/packages/text/tests/integration/paragraph-bidi-policy.test.mjs +++ b/packages/text/tests/integration/paragraph-bidi-policy.test.mjs @@ -70,6 +70,55 @@ test('lays out exact mixed-direction Amiri goldens through retained GLB shaping font.dispose(); }); +test('reuses broad Arabic shaping at safe line boundaries and narrows only unsafe context', async () => { + const { font, shaper } = await runtime('amiri-1.002/Amiri-Regular.ttf'); + shaper.registerFont(font); + const text = 'مرحبا بالعالم'; + const textUtf16 = utf16(text); + const request = { + textUtf16, + features: [], + runs: [ + { + font: font.handle, + textStart: 0, + textEnd: textUtf16.length, + direction: 'rtl', + script: 'Arab', + language: 'ar', + clusterLevel: 0, + flags: 0x40, + featureStart: 0, + featureCount: 0, + }, + ], + }; + const broad = ownShape(shaper.shapeBatch(request)); + const safeBoundary = 6; + const unsafeBoundary = 7; + + assert.equal(glyphFlagsAtCluster(broad, safeBoundary) & 1, 0, 'word boundary is safe to break'); + assert.equal(glyphFlagsAtCluster(broad, unsafeBoundary) & 1, 1, 'joining boundary is unsafe to break'); + assert.deepEqual( + shapeRangeSignature(reshapeLine(shaper, request, 0, safeBoundary), 0, safeBoundary), + shapeRangeSignature(broad, 0, safeBoundary), + 'safe first line is byte-identical to the retained broad shape', + ); + assert.deepEqual( + shapeRangeSignature(reshapeLine(shaper, request, safeBoundary, textUtf16.length), safeBoundary, textUtf16.length), + shapeRangeSignature(broad, safeBoundary, textUtf16.length), + 'safe next line is byte-identical to the retained broad shape', + ); + assert.notDeepEqual( + shapeRangeSignature(reshapeLine(shaper, request, 0, unsafeBoundary), 0, unsafeBoundary), + shapeRangeSignature(broad, 0, unsafeBoundary), + 'forcing an unsafe Arabic boundary changes the line shape', + ); + + shaper.dispose(); + font.dispose(); +}); + test('applies exact alignment, clipping, max-lines, and ellipsis policies without hidden calls', async () => { const { font, shaper } = await runtime('inter-v4.1/Inter-Regular.ttf'); const calls = { shape: 0, reshape: 0 }; @@ -180,6 +229,63 @@ function observeShaper(shaper, calls, requests) { }; } +function reshapeLine(shaper, request, start, end) { + return ownShape( + shaper.reshapeRanges({ + ...request, + ranges: [ + { + run: 0, + itemStart: start, + itemEnd: end, + contextStart: start, + contextEnd: end, + flags: 0x43, + }, + ], + }), + ); +} + +function ownShape(shape) { + return { + glyphIds: [...shape.glyphIds], + clusters: [...shape.clusters], + xAdvances: [...shape.xAdvances], + yAdvances: [...shape.yAdvances], + xOffsets: [...shape.xOffsets], + yOffsets: [...shape.yOffsets], + glyphFlags: [...shape.glyphFlags], + }; +} + +function glyphFlagsAtCluster(shape, cluster) { + const index = shape.clusters.indexOf(cluster); + assert.notEqual(index, -1, `expected a glyph at cluster ${cluster}`); + return shape.glyphFlags[index]; +} + +function shapeRangeSignature(shape, start, end) { + return shape.glyphIds.flatMap((glyphId, index) => + shape.clusters[index] >= start && shape.clusters[index] < end + ? [ + [ + glyphId, + shape.clusters[index], + shape.xAdvances[index], + shape.yAdvances[index], + shape.xOffsets[index], + shape.yOffsets[index], + ], + ] + : [], + ); +} + +function utf16(value) { + return Uint16Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); +} + function assertGoldenLayout(layout, golden, full) { assert.deepEqual( { From 51580ac2d72893e9ddb8fd44ea88cc6c3e7cc50e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 09:46:32 -0400 Subject: [PATCH 087/128] feat(text): shape narrowed ellipsis boundaries --- docs/log.md | 19 + docs/packages/text.md | 32 +- docs/planning/dirty-range-upload-research.md | 270 +++++++++++ docs/planning/index.md | 2 + docs/planning/rust-layout-engine.md | 29 +- .../shaper/src/engine/flow_composition.rs | 255 ++++++++++- .../rust/shaper/src/engine/layout_query.rs | 6 +- .../rust/shaper/src/engine/ordered_plan.rs | 4 +- .../rust/shaper/src/engine/policy_gather.rs | 41 +- .../rust/shaper/src/engine/positioning.rs | 261 ++++++++++- .../rust/shaper/src/engine/shaping_state.rs | 112 +++++ .../rust/shaper/src/engine/stable_plan.rs | 11 +- packages/text/rust/shaper/src/engine/state.rs | 422 +++++++++++++++++- packages/text/rust/shaper/src/lib.rs | 43 +- packages/text/src/three/engine-plan-target.ts | 12 +- packages/text/src/three/profiler.ts | 1 + .../text/tests/integration/three-v1.test.mjs | 153 +++++++ 17 files changed, 1628 insertions(+), 45 deletions(-) create mode 100644 docs/planning/dirty-range-upload-research.md diff --git a/docs/log.md b/docs/log.md index 5b84f550..fbcb9769 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,25 @@ ## 2026-08-09 +- **Moved ellipsis and its real boundary reshape into the Rust frame transaction** — Only truncated flow threads build a + retained boundary arena; ordinary reflow retains zero boundary reshapes. Font-stack ellipsis selection, complete + no-wrap overflow, narrowed final-tail context, spacing, stable glyph identity, positioning, semantic inspection, and + render-plan publication now share the one Rust update. A public Amiri/Three regression proves the result differs from + incorrect whole-run reuse and matches the narrowed shaping oracle. All 136 Rust library tests and 204 package tests + pass. Same-machine detached-baseline comparison finds column-resize medians within 0.15 ms and mixed cold results, so + the checkpoint is recorded as performance-adjacent rather than assigned a speedup. Its aggregate optimized Wasm delta, + including adjacent renderer-integration fixes, is +13,639 raw / +5,797 gzip / +5,579 Brotli bytes. + +- **Recorded the Paragraph Stress integration defects without changing shaping invalidation** — Origin lookup indexing is + now lazy and Bitmap strike replacement initializes every required input stream. The observed 11,510-glyph MTSDF probe + moved `plan.apply` from about 1.02 ms to 0.14 ms and total retained update from about 6.89 ms to 4.63 ms, with differing + sample histories explicitly preventing a universal speedup claim. Focused public Three fixtures cover both defects. + +- **Queued adaptive dirty-range upload refinement from three-flatland evidence** — The research finds that Rust already + generalizes Flatland's dirty buckets through exact spans, gap costs, fragmentation limits, and a full-live cutover. + Follow-on work will calibrate per-physical-buffer costing and stable-order coalescing; TypeScript will not duplicate the + planner. Renderer-local transform/origin edits remain the only candidate for a Flatland-style retained tracker. + - **Made semantic queries share the retained update that invalidated them** — Three now sends only changed text, style, or geometry sections; an empty update and cached query make no Rust call, while pending measurement or inspection rides on the same `text_update`. A two-paragraph compiled-Wasm regression proves all-paragraph semantic retention and exact diff --git a/docs/packages/text.md b/docs/packages/text.md index b8c5860f..12bdc1a9 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:8af3dbc1edd2172625a7c0bca81eef92524e0f7c499d1c9a227a1cc0307fa4ab' +source_digest: 'sha256:dcec3be38cc1cc058965b8e7593cedea1683c5594b4465a221100fe2cec724ce' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -189,8 +189,8 @@ sources: resource: ../../packages/text/src/internal/unicode.ts title: Unicode analysis implementation generated: - by: openai-codex/gpt-5 - at: '2026-08-09T12:24:43Z' + by: openai-codex/gpt-5.6 + at: '2026-08-09T13:43:37Z' --- # Package reference: `@pmndrs/text` @@ -1015,6 +1015,32 @@ geometry and text mutation plus measurement, all-paragraph measurement retention output after a text replacement. Optional Three phase profiling is inactive by default and can emit User Timing spans for frame preparation, Rust update, plan application, semantic readback, transform synchronization, and total time. +Rust now owns ellipsis shaping and positioning rather than treating truncation as a TypeScript-only layout artifact. +Only a flow thread that leaves text unconsumed or whose complete no-wrap line exceeds its final slot enters this path. +The engine selects the ellipsis through the authored font stack, trims the final slot, and reshapes only the final +same-font source tail with context ending at the truncation boundary; ordinary reflow still performs zero boundary +reshapes. A retained boundary arena carries the replacement source and ellipsis glyphs directly into positioning, +preserves glyph identities across warm truncation updates, includes letter and word spacing, and commits or aborts with +the paragraph transaction. The public Three regression uses Amiri at a joining boundary where whole-run and narrowed +glyph IDs are provably different, then requires the Rust inspection output to match the narrowed result. The complete +package gate passes 204 tests and the Rust library passes 136 tests. Against detached commit `4adbbebc` on the same +machine, two 22,000-target-glyph Bitmap runs measured 4.041/4.056 ms median for baseline column resize and +4.102/4.189 ms for this checkpoint; cold medians were 15.490/15.569 ms and 15.212/15.358 ms respectively. The overlap, +run noise, and sub-0.15 ms differences support only a performance-adjacent claim. The complete checkpoint, including +the adjacent lazy-origin-index and Bitmap resource-selection fixes, changes optimized Wasm from +1,101,079 / 414,917 / 328,164 to 1,114,718 / 420,714 / 333,743 raw/gzip/Brotli bytes; that aggregate delta is not +attributed to ellipsis alone. + +Live Paragraph Stress profiling also found two renderer-integration defects independent of shaping invalidation. The +Three executor rebuilt a per-glyph origin lookup object graph after every plan application even though only presentation +queries use it; the index is now lazy and invalidated by a new plan. In the observed 11,510-glyph MTSDF run, +`plan.apply` moved from roughly 1.02 ms to 0.14 ms and the retained update from roughly 6.89 ms to 4.63 ms, but the sample +histories were not identical and this remains a scoped diagnostic rather than a universal speedup claim. Separately, +font-size or raster-density changes can select a different Bitmap strike and therefore a replacement physical batch. +Policy gather now retains every input lane for those selection changes so unchanged transform data initializes the new +batch; dependency-directed buffer writes remain selective when the resource does not change. A 16-to-32 ppem public +Three fixture proves the replacement transform stream is initialized before a following width-only reflow. + The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. diff --git a/docs/planning/dirty-range-upload-research.md b/docs/planning/dirty-range-upload-research.md new file mode 100644 index 00000000..6f1a8ca1 --- /dev/null +++ b/docs/planning/dirty-range-upload-research.md @@ -0,0 +1,270 @@ +--- +type: Engineering Research +title: Adaptive dirty-range uploads for retained text plans +description: Compares three-flatland's bucketed sprite uploads with the Rust text render-plan compiler and defines the evidence needed to tune partial versus full GPU updates. +status: draft +tags: [render-plan, gpu, wasm, rust, three, performance, dirty-ranges] +sources: + - id: flatland-tracker + resource: https://github.com/thejustinwalsh/three-flatland/blob/2935a89fcd9999e8a8b3d3b733f7f7302285cd60/packages/three-flatland/src/pipeline/BucketedDirtyTracker.ts + title: three-flatland BucketedDirtyTracker + - id: flatland-batch + resource: https://github.com/thejustinwalsh/three-flatland/blob/2935a89fcd9999e8a8b3d3b733f7f7302285cd60/packages/three-flatland/src/pipeline/SpriteBatch.ts + title: three-flatland SpriteBatch thresholds and buffer ownership + - id: flatland-flush + resource: https://github.com/thejustinwalsh/three-flatland/blob/2935a89fcd9999e8a8b3d3b733f7f7302285cd60/packages/three-flatland/src/ecs/systems/flushDirtyRangesSystem.ts + title: three-flatland end-of-frame dirty-range flush + - id: text-packing + resource: ../../packages/text/rust/shaper/src/engine/plan_packing.rs + title: Rust render-plan range coalescing and upload cost model + - id: text-ordered-plan + resource: ../../packages/text/rust/shaper/src/engine/ordered_plan.rs + title: Rust ordered-direct changed-range planning + - id: text-stable-plan + resource: ../../packages/text/rust/shaper/src/engine/stable_plan.rs + title: Rust stable-indirect physical and order-buffer planning + - id: text-three-target + resource: ../../packages/text/src/three/engine-plan-target.ts + title: Three render-plan executor and update-range forwarding + - id: three-webgpu + resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgpu/utils/WebGPUAttributeUtils.js + title: Three r185 WebGPU attribute uploads + - id: three-webgl-fallback + resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgl-fallback/utils/WebGLAttributeUtils.js + title: Three r185 WebGL fallback attribute uploads + - id: three-webgl + resource: https://github.com/mrdoob/three.js/blob/r185/src/renderers/webgl/WebGLAttributes.js + title: Three r185 legacy WebGL attribute uploads +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-09T16:40:00Z' +--- + +# Adaptive dirty-range uploads for retained text plans + +## Conclusion + +The Flatland technique applies, but its core decision already exists in the Rust text render-plan compiler. The text +engine should not add `BucketedDirtyTracker` to the Three command-buffer executor or move dirty-range policy back to +TypeScript. Rust already receives the complete mutation transaction, derives exact changed record ranges, coalesces +small gaps, bounds fragmentation, and promotes expensive partial updates to one full live-record range before publishing +`PATCH_WRITE` commands.[^text-packing] + +The useful work is therefore narrower: + +1. calibrate the existing Rust integer cost model against the installed Three WebGPU and WebGL backends; +2. make the cost decision per physical buffer, including the stable-indirect order buffer, rather than treating every + program stream as if it had the same changed-range economics; +3. use a Flatland-style reusable tracker only for renderer-local matrix and presentation-origin edits, which never cross + `text_update`; and +4. remove avoidable host allocations while forwarding already-coalesced Rust patches to Three. + +No new policy-program opcode is required. The renderer declares integer capabilities; the renderer-neutral Rust plan +compiler owns the decision; the adapter translates the resulting byte ranges into the backend API. + +## What Flatland actually does + +Flatland accumulates unordered per-slot writes over a frame. Each physical buffer owns a `BucketedDirtyTracker` with two +fixed `Int32Array`s: one first-dirty slot and one last-dirty slot per bucket. A clean-to-dirty bucket transition increments +one counter; later writes in the same bucket only widen its local span. The 16,384-instance default batch uses 256-slot +buckets, so each tracker scans 64 bucket entries at flush.[^flatland-tracker][^flatland-batch] + +At the single end-of-frame flush, each buffer chooses one of two Three update shapes: + +- fewer than the threshold: one `addUpdateRange` for the first-to-last dirty span in each dirty bucket; +- threshold reached: clear all ranges and set `needsUpdate`, requesting one full-buffer update. + +The thresholds are buffer-specific: five dirty buckets for 16-float matrices and three for the 16-float interleaved core +and custom effect buffers.[^flatland-batch] These are call-count cutovers, not percentage thresholds. At maximum bucket +occupancy they correspond to 1,280 matrix instances or 768 interleaved instances, but even one changed slot in each of +three far-apart buckets selects the interleaved full upload. The ECS flush reads dirtiness before clearing the trackers so +the shadow pipeline can reuse the same signal without rescanning sprite data.[^flatland-flush] + +Because Flatland allocates each typed array at `maxSize`, its empty-range full path uploads capacity, not only the active +`mesh.count`. The text planner's explicit promoted range is materially different: it targets the live record span plus +required initialized alignment padding. Fixed buckets also upload clean slots between the first and last mutation inside +each bucket. Flatland's source describes the design as strictly dominating one global min/max range, but that is a design +claim contingent on correctly tuned thresholds; it is not true for every sparse distribution or low-occupancy batch. + +The implementation has exact unit tests for empty, one-slot, same-bucket, multi-bucket, threshold, reset, non-aligned +capacity, and different-stride cases. Its source comments claim approximately 5 ns per `markDirty`, a sub-microsecond +64-bucket walk, and mobile-WebGPU tuning. Those numbers are not accompanied by a retained microbenchmark artifact in the +surveyed commit, so they are design annotations rather than transferable evidence. The introducing commit reports a +holistic M2 result of approximately 27,000 effect-enabled sprites at 60 FPS, but it also interleaved buffers and removed a +per-frame ECS pass; it does not isolate dirty bucketing. + +The source's claim that full updates use `bufferData` is stale for Three r185 updates. Once a buffer exists, the surveyed +backends use full-span `bufferSubData` or `GPUQueue.writeBuffer`; allocation is a separate path. That does not invalidate +the range-versus-full decision, but it changes the mechanism attributed to the win. + +## What the Rust text path already does + +The text planner has more information than Flatland's mutation-time tracker. Ordered-direct storage scans stable IDs and +content revisions in physical order to produce exact contiguous record ranges. Stable-indirect storage sorts changed +physical slots, creates exact ranges, and separately writes changed 64-entry logical-order chunks. Both reuse retained +scratch vectors rather than allocating one tracker object per frame.[^text-ordered-plan][^text-stable-plan] + +`coalesce_ranges` then applies four renderer-declared controls:[^text-packing] + +| Capability | First-party Three value | Effect | +| --- | ---: | --- | +| update alignment | 4 bytes | expands record ranges to legal backend alignment | +| accepted gap | max(128, 256) bytes | merges neighboring ranges when uploading the gap is cheaper than another call | +| fragmentation budget | 8 ranges | collapses excess fragments to one first-to-last span | +| whole-buffer threshold | 7,500 basis points | selects `0..live_records` when modeled partial cost reaches 75% of live bytes; later alignment may include initialized capacity padding | + +This is the Flatland policy generalized from fixed buckets into exact ranges and an explicit byte/call cost model. It is +also already in the correct ownership layer: the renderer supplies capabilities as static data, while Rust makes one +deterministic decision over the whole transaction and publishes only the selected patches. + +The Three executor copies each patch payload into retained typed storage and forwards its scalar range with +`addUpdateRange`. In r185, WebGPU issues one `queue.writeBuffer` per range and performs no additional merge. WebGL fallback +issues one `bufferSubData` per range and performs no merge. Legacy `WebGLRenderer` sorts and merges adjacent or overlapping +ranges in place before `bufferSubData`.[^three-webgpu][^three-webgl-fallback][^three-webgl] Pre-coalescing in Rust therefore +matters most for WebGPU and TSL's WebGL fallback; relying on legacy WebGL's merge would not cover the shipped renderer. + +## Gaps in the current model + +### Costing is program-wide before buffer liveness is known + +The current coalescer computes `bytes_per_record` by summing every stream in the program, then selects one common range +shape. Semantic dependency masks are applied later, and each active physical buffer receives its own patch. This keeps +correct ranges and prevents inactive-buffer uploads, but the cost estimate can be wrong in both directions: + +- a position-only update may touch one narrow stream, so aggregate stride makes clean gaps look more expensive than they + are and can preserve too many calls; +- an update touching several streams pays one backend call per stream per range, while the model charges the range-call + penalty only once. + +Range selection should be evaluated per active physical buffer, or by an exactly equivalent active-buffer-weighted +model. This belongs in the Rust plan compiler after dependency liveness is known, not in policy bytecode and not in the +Three executor. + +### Stable order chunks bypass the adaptive decision + +Stable-indirect physical records use `coalesce_ranges`, but changed 64-entry order chunks currently become individual +patches. Sparse insertions benefit from that precision; broad edits can publish many order-buffer calls. The same +gap/call/full-live model should consume the changed order chunks before serialization, while preserving chunk retirement +and fence invariants. + +### Renderer-local writes need their own tracker + +Scene-transform synchronization and presentation-origin overrides intentionally do not call Wasm. They currently append +Three update ranges directly. Rust cannot coalesce changes it never sees. A small retained tracker in the Three adapter is +appropriate for these sidecars: + +- one tracker for the shared 16-float transform matrix buffer; +- one tracker per origin buffer touched by presentation overrides; +- reset exactly once after all renderer-local writes for the frame; +- backend-calibrated range/full cutover, with no glyph-layout or policy semantics. + +This is where Flatland's power-of-two buckets are directly reusable. It must not observe or reinterpret Rust +`PATCH_WRITE` commands. + +### Host range forwarding still allocates + +The executor constructs a new `Set` for touched buffer IDs on every plan and Three's `addUpdateRange` constructs one +`{start, count}` object per patch. Rust's fragmentation budget bounds this work but does not eliminate it. A retained +generation stamp can replace the per-apply `Set`; if Three's public contract permits it for the pinned version, retained +range objects can be mutated and the array length adjusted instead of appended. This is an allocation/GC hypothesis until +a Safari allocation trace and an end-to-end timing isolate it. + +### WebGL storage-buffer emulation is a distinct upload shape + +TSL's WebGL fallback represents storage attributes through a PBO-like `DataTexture` path. The builder can replace the +attribute array with power-of-two-padded storage, and the current executor only marks the retained PBO texture +`needsUpdate`; it does not forward the Rust ranges to texture update ranges. Consequently, attribute range reduction does +not by itself prove reduced WebGL texture traffic. WebGL fallback needs its own captured evidence and may require a +row-aware adapter translation or a deliberately conservative capability set. This backend detail must not change the +renderer-neutral patch ABI. + +## Proposed ownership + +| Concern | Owner | Reason | +| --- | --- | --- | +| semantic dependency and changed glyph records | Rust retained engine | only this layer knows what changed and why | +| exact range, gap, fragmentation, and full-live decision | Rust render-plan compiler | one deterministic decision before publication | +| cost constants and backend limits | registered renderer capability set | renderer knowledge expressed as validated data, not a callback | +| packing math | policy program executed by Rust | existing straight-line data transformation boundary | +| byte-range to Three update-range translation | Three executor | backend object and scalar-width knowledge | +| scene matrices and presentation-origin dirty tracking | Three executor | renderer-local data never seen by `text_update` | +| final GPU command submission | Three WebGPU/WebGL backend | outside the renderer-neutral plan | + +Adding bucket size or a fixed dirty-bucket count to the public policy now would overfit Flatland. The existing byte-cost +fields can express the decision more generally. A new capability field is justified only if the benchmark matrix shows +that exact ranges plus byte/call costing cannot reproduce a stable backend optimum. + +## Correctness invariants + +Any refinement must preserve these exact properties: + +1. Applying patches in order produces byte-identical retained buffers to applying one full canonical replacement. +2. No emitted range crosses its buffer generation or allocated byte length, and every range satisfies scalar/backend + alignment. Any aligned records beyond the live draw count retain initialized committed bytes. +3. Coalesced gaps contain committed bytes copied into the outgoing payload; clean bytes are never zeroed or left + uninitialized merely because a larger upload was selected. +4. A replacement allocation is fully initialized even when semantic dependency masks would omit unchanged fields. +5. A no-op frame emits no patch, changes no Three attribute version, and performs no GPU upload. +6. Every active buffer's update ranges are cleared exactly once before its first update and remain available until Three + consumes them. +7. An empty Three range list is legal only when the intended upload covers the entire allocated typed array; a promoted + live span otherwise remains explicit, including any required initialized alignment padding. +8. Stable-indirect order-buffer coalescing preserves logical order, chunk retirement, and fence-delayed slot reuse. +9. Renderer-local trackers never alter Rust buffer identity, command ordering, draw boundaries, or semantic state. +10. WebGPU, WebGL fallback, and any native consumer may choose different cost constants but must realize identical final + bytes and draws. + +## Benchmark and admission matrix + +The comparison must use the same 25,515-positioned-glyph workload and mutation definitions as the current layout evidence. +A smaller representative paragraph may be added, but it cannot replace the canonical worst case. + +Test these update distributions for Bitmap, MTSDF, and Slug: + +- no-op; +- one glyph, 32 adjacent glyphs, and one full 256-record bucket; +- 3, 5, and 9 far-apart single-glyph changes; +- every 64th and every 256th glyph; +- contiguous 1%, 5%, 10%, 25%, 50%, 75%, and 100% spans; +- width-only layout, font-size layout/resource selection, localized text edit, suffix edit, and paragraph reorder; +- stable-indirect insertion and broad order-buffer rewrite; and +- transform-only and presentation-origin-only renderer-local changes. + +Compare at least these planners: + +1. current exact-range cost model; +2. Flatland-equivalent 256-record buckets with 3/5-bucket cutovers; +3. exact per-buffer cost model with a sweep of range-call penalties and whole-live thresholds; +4. one min-to-max range; and +5. unconditional full-live upload. + +Capture per update: + +- `text_update`, range-planning, policy packing, publication, Three apply, render submit, and GPU time; +- patch count, update-range count, payload bytes, uploaded bytes, and full-live promotions per physical buffer; +- retained scratch high-water marks and warm allocations/GC; +- draw count and framebuffer/conformance hash; and +- p50, p95, maximum, and sample count on Chromium WebGPU, Safari WebGPU, and forced WebGL2 on the target computer. + +The selection criterion is end-to-end frame cost, not minimum uploaded bytes in isolation. Adopt a threshold only when an +adjacent A/B run improves median without worsening p95, preserves every correctness invariant, and repeats across the +three raster techniques. Backend-specific constants are acceptable; backend-specific render-plan semantics are not. + +## Not yet verified + +- Flatland's fixed thresholds have not been isolated against its full-upload control on this computer. +- The current text capability values have not been swept against actual `writeBuffer`, `bufferSubData`, or PBO texture + costs. +- Per-buffer coalescing has not been implemented or measured. +- Safari GC attributed to update-range objects has not been isolated from other known per-frame allocations. +- Partial WebGL fallback PBO texture upload has not been proven through Three r185. + +[^flatland-tracker]: The tracker stores first/last dirty slots in fixed typed arrays and scans the complete bucket table only at flush. +[^flatland-batch]: The audited snapshot fixes the bucket size at 256 and thresholds at 5 for matrices and 3 for interleaved/custom streams. +[^flatland-flush]: `flushDirtyRangesSystem` is scheduled after batch writes and reads `isDirty` before flushing. +[^text-packing]: `coalesce_ranges` implements gap merging, fragmentation collapse, and a basis-point whole-live threshold in `no_std + alloc` Rust. +[^text-ordered-plan]: Ordered-direct compilation derives changes from retained stable identity and content revision in physical order. +[^text-stable-plan]: Stable-indirect compilation retains physical slots and a separate 64-entry chunked logical-order buffer. +[^three-webgpu]: Three r185 WebGPU emits one `GPUQueue.writeBuffer` call for each declared update range. +[^three-webgl-fallback]: Three r185 WebGL fallback emits one `bufferSubData` call for each declared attribute range. +[^three-webgl]: Three r185 legacy WebGL merges overlapping or adjacent ranges in place before upload. diff --git a/docs/planning/index.md b/docs/planning/index.md index 0ec96d3e..5542cbb8 100644 --- a/docs/planning/index.md +++ b/docs/planning/index.md @@ -44,6 +44,8 @@ ## Rendering analysis +- [Adaptive dirty-range uploads](dirty-range-upload-research.md) — three-flatland comparison, existing Rust upload-cost + model, backend behavior, remaining per-buffer work, and measurement gate. - [MTSDF generation research](mtsdf-generation-research.md) — primary literature, implementation/license survey, owned Rust boundary, and data-oriented optimization gates. - [Grayscale bitmap hinting research](bitmap-hinting-research.md) — native pixel placement, hinted strikes, and four-phase grayscale packing gates. - [Renderer capabilities](renderer-capabilities.md) — feature matrix and developer guidance. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 3b75bee3..5a228e0b 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -13,7 +13,7 @@ tags: - abi generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T10:04:42Z' + at: '2026-08-09T13:43:37Z' sources: - id: layout-benchmark resource: ../../packages/text/scripts/benchmark-paragraph-layout.mts @@ -96,6 +96,9 @@ sources: - id: renderer-capabilities resource: renderer-capabilities.md title: Renderer capability matrix + - id: dirty-range-uploads + resource: dirty-range-upload-research.md + title: Adaptive dirty-range upload research - id: payload-budget resource: payload-budget.md title: Font and raster payload budget @@ -720,6 +723,13 @@ mask, and one of the two allocation strategies. Physical streams own explicit al capacity class. All reserved bits and fields are zero and unknown flags fail registration. +The upload model is informed by three-flatland's fixed 256-instance dirty buckets and per-buffer ranged/full cutovers, +but the retained text engine already has a more precise input: exact changed record spans for the complete transaction. +The [dirty-range upload research](dirty-range-upload-research.md) therefore keeps range selection in the Rust plan +compiler, identifies per-physical-buffer costing and stable-order coalescing as the remaining planner work, and reserves +Flatland-style bucket trackers for renderer-local transform and presentation-origin writes that never cross +`text_update`. Its thresholds remain a benchmark candidate, not evidence that three or five ranges are optimal for text. + V0 does not alias several logical stores into one mutable interleaved byte span. Augmentation instead combines semantic fields into independently bindable `vec2`/`vec4` or integer-vector records, including the existing MSDF and Slug WebGL-compatible packing. This keeps executor borrows disjoint, avoids another aliasing grammar in the native ABI, and @@ -1296,6 +1306,23 @@ semantic reset plus capacity identity, a public Three fixture performs two atomi gzip / 325,149 Brotli bytes. Benchmark draw/glyph telemetry for grouped command-buffer draws is corrected separately and is not part of this transition proof. +Ellipsis now closes the first deliberate narrowed-context shaping case. A flow thread requests it only when text remains +after its final visible region/line or a complete no-wrap line exceeds its final slot. Rust selects U+2026 through the +font stack, repeatedly evaluates the candidate width while trimming clusters, and reshapes only the final contiguous +same-font tail with pre-context clipped to the line and post-context clipped to the truncation boundary. The retained +whole-paragraph shape remains authoritative everywhere else, so normal reflow retains the zero-boundary-reshape +invariant. Source replacement and ellipsis glyphs live in a paragraph-transactional boundary arena and enter the same +positioning, identity, semantic, policy, and render-plan pipeline as ordinary glyphs. The Amiri public Three regression +first proves broad and narrowed glyph identities differ at the chosen unsafe joining boundary and then proves the Rust +output matches the narrowed result; a separate no-wrap regression covers complete-but-too-wide lines. The checkpoint +passes 136 Rust library tests and all 204 package tests. Same-machine detached `4adbbebc` comparison over two complete +22,000-target-glyph Bitmap runs measures baseline/current column-resize medians of 4.041/4.056 and 4.102/4.189 ms, while +cold medians are 15.490/15.569 and 15.212/15.358 ms. These small mixed-direction differences and the noisier current +samples establish performance adjacency, not a speedup or material regression. The complete checkpoint also contains +the lazy Three origin-index and Bitmap resource-selection fixes, so its optimized Wasm movement from +1,101,079 / 414,917 / 328,164 to 1,114,718 / 420,714 / 333,743 raw/gzip/Brotli bytes is an aggregate checkpoint cost, +not an ellipsis-only attribution. + ### Foundation stack — Wasm, policy, render plan, and complete current semantics ### Stage 0 — contracts and measurement diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index b8210a2b..abb431c5 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -6,7 +6,7 @@ use super::{ EngineError, cluster_state::{CLUSTER_HARD_BREAK, ClusterArena}, flow_geometry::{FlowGeometryArena, InlineSlotArena}, - frame::{OVERFLOW_VISIBLE, WRITING_HORIZONTAL_TB}, + frame::{OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, WRITING_HORIZONTAL_TB}, line_composition::{ComposedLine, LineCursor, layout_next_line}, style_state::StyleSegment, }; @@ -30,6 +30,24 @@ pub(crate) struct FlowFragment { pub line: ComposedLine, pub slot_start: f64, pub slot_end: f64, + pub boundary_index: u32, +} + +pub(crate) const NO_BOUNDARY: u32 = u32::MAX; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct EllipsisTarget { + pub fragment_index: u32, + pub line_cluster_start: u32, + pub boundary_cluster_start: u32, + pub cluster_end: u32, + pub text_end: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct EllipsisReplacement { + pub cluster_start: usize, + pub advance_adjustment: f64, } #[derive(Clone, Copy, Debug, Default, PartialEq)] @@ -53,6 +71,7 @@ impl LineExtents { pub(crate) struct FlowLayoutArena { pub lines: Vec, pub fragments: Vec, + pub(crate) ellipsis_threads: Vec, } impl FlowLayoutArena { @@ -65,7 +84,8 @@ impl FlowLayoutArena { reserve( &mut self.fragments, line_capacity.saturating_mul(max_slots_per_band), - ) + )?; + reserve(&mut self.ellipsis_threads, line_capacity) } #[allow(clippy::too_many_arguments)] @@ -166,6 +186,24 @@ impl FlowLayoutArena { } } } + if constraint.overflow == OVERFLOW_ELLIPSIS { + let final_fragment_overflows = self.lines.last().is_some_and(|line| { + line.flow_thread_id == constraint.flow_thread_id + && usize::try_from(line.fragment_start) + .ok() + .and_then(|start| start.checked_add(usize::from(line.fragment_count))) + .and_then(|end| end.checked_sub(1)) + .and_then(|index| self.fragments.get(index)) + .is_some_and(|fragment| { + fragment.line.advance > fragment.slot_end - fragment.slot_start + }) + }); + if (!cursor.is_complete(clusters.starts.len()) || final_fragment_overflows) + && self.lines.len() > thread_line_start + { + self.ellipsis_threads.push(constraint.flow_thread_id); + } + } } Ok(()) } @@ -233,6 +271,7 @@ impl FlowLayoutArena { line, slot_start: slot.start, slot_end: slot.end, + boundary_index: NO_BOUNDARY, }); composed = true; if line.hard_break || cursor.is_complete(clusters.starts.len()) { @@ -274,9 +313,106 @@ impl FlowLayoutArena { pub(crate) fn clear(&mut self) { self.lines.clear(); self.fragments.clear(); + self.ellipsis_threads.clear(); + } + + pub(crate) fn ellipsis_threads(&self) -> &[u32] { + &self.ellipsis_threads + } + + pub(crate) fn truncate_for_ellipsis( + &mut self, + flow_thread_id: u32, + clusters: &ClusterArena, + mut replacement_at: impl FnMut(usize, u32) -> Result, + ) -> Result, EngineError> { + let Some(line) = self + .lines + .iter() + .rev() + .find(|line| line.flow_thread_id == flow_thread_id) + .copied() + else { + return Ok(None); + }; + let fragment_start = + usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let fragment_end = fragment_start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + let fragment_index = fragment_end + .checked_sub(1) + .ok_or(EngineError::InvalidRequest)?; + let fragment = self + .fragments + .get_mut(fragment_index) + .ok_or(EngineError::InvalidRequest)?; + let cluster_count = clusters.starts.len(); + let consumed = + usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + let available = fragment.slot_end - fragment.slot_start; + if consumed >= cluster_count && fragment.line.advance <= available { + return Ok(None); + } + let cluster_start = usize::try_from(fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + let mut cluster_end = consumed.min(cluster_count); + let mut source_advance = fragment.line.advance; + while cluster_end > cluster_start + && clusters.flags[cluster_end - 1] & CLUSTER_HARD_BREAK != 0 + { + cluster_end -= 1; + source_advance -= clusters.advances[cluster_end]; + } + let mut text_end = cluster_text_end(clusters, cluster_end); + let mut replacement = replacement_at(cluster_end, text_end)?; + if replacement.cluster_start < cluster_start + || replacement.cluster_start > cluster_end + || !replacement.advance_adjustment.is_finite() + { + return Err(EngineError::InvalidRequest); + } + while cluster_end > cluster_start + && source_advance + replacement.advance_adjustment > available + { + cluster_end -= 1; + source_advance -= clusters.advances[cluster_end]; + text_end = cluster_text_end(clusters, cluster_end); + replacement = replacement_at(cluster_end, text_end)?; + if replacement.cluster_start < cluster_start + || replacement.cluster_start > cluster_end + || !replacement.advance_adjustment.is_finite() + { + return Err(EngineError::InvalidRequest); + } + } + fragment.line.cluster_end = + u32::try_from(cluster_end).map_err(|_| EngineError::ResultTooLarge)?; + fragment.line.text_end = text_end; + fragment.line.advance = (source_advance + replacement.advance_adjustment).max(0.0); + fragment.line.hard_break = false; + Ok(Some(EllipsisTarget { + fragment_index: u32::try_from(fragment_index) + .map_err(|_| EngineError::ResultTooLarge)?, + line_cluster_start: u32::try_from(cluster_start) + .map_err(|_| EngineError::ResultTooLarge)?, + boundary_cluster_start: u32::try_from(replacement.cluster_start) + .map_err(|_| EngineError::ResultTooLarge)?, + cluster_end: u32::try_from(cluster_end).map_err(|_| EngineError::ResultTooLarge)?, + text_end, + })) } } +fn cluster_text_end(clusters: &ClusterArena, cluster_end: usize) -> u32 { + clusters + .starts + .get(cluster_end) + .copied() + .or_else(|| clusters.ends.last().copied()) + .unwrap_or(0) +} + fn cluster_for_offset(clusters: &ClusterArena, offset: u32) -> Result { let offset = usize::try_from(offset).map_err(|_| EngineError::InvalidRequest)?; let index = usize::try_from( @@ -429,7 +565,7 @@ mod tests { flow_geometry::{RetainedExclusion, RetainedRegion}, frame::{ ALIGN_START, AXIS_EXACT, BLOCK_ALIGN_START, EXCLUSION_WRAP_BOTH, ORIENTATION_MIXED, - OVERFLOW_VISIBLE, SHAPE_RECTANGLE, WRAP_CHARACTER, + OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, SHAPE_RECTANGLE, WRAP_CHARACTER, WRAP_NONE, }, semantic_wire::{FlowConstraint, FlowExclusion, FlowRegion}, style_state::ResolvedStyle, @@ -583,6 +719,119 @@ mod tests { assert_eq!(layout.fragments.last().unwrap().line.cluster_end, 4); } + #[test] + fn ellipsis_truncation_reuses_the_final_slot_and_removes_only_required_clusters() { + let clusters = ClusterArena { + starts: vec![0, 1, 2, 3], + ends: vec![1, 2, 3, 4], + advances: vec![3.0; 4], + flags: vec![CLUSTER_SAFE_BEFORE; 4], + ..ClusterArena::default() + }; + let mut layout = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 7, + region_id: 1, + transform_index: 0, + clip_id: 1, + fragment_start: 0, + fragment_count: 1, + align: ALIGN_START, + block_start: 0.0, + baseline: 8.0, + height: 10.0, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 2, + text_start: 0, + text_end: 2, + advance: 6.0, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 10.0, + boundary_index: NO_BOUNDARY, + }], + ..FlowLayoutArena::default() + }; + + let target = layout + .truncate_for_ellipsis(7, &clusters, |cluster_end, _| { + Ok(EllipsisReplacement { + cluster_start: cluster_end, + advance_adjustment: 5.0, + }) + }) + .unwrap() + .unwrap(); + assert_eq!(target.line_cluster_start, 0); + assert_eq!(target.boundary_cluster_start, 1); + assert_eq!(target.cluster_end, 1); + assert_eq!(target.text_end, 1); + assert_eq!(layout.fragments[0].line.cluster_end, 1); + assert_eq!(layout.fragments[0].line.text_end, 1); + assert_eq!(layout.fragments[0].line.advance, 8.0); + } + + #[test] + fn complete_no_wrap_line_still_requests_ellipsis_when_its_slot_overflows() { + let clusters = ClusterArena { + starts: vec![0, 1, 2], + ends: vec![1, 2, 3], + advances: vec![3.0; 3], + flags: vec![CLUSTER_SAFE_BEFORE; 3], + style_indexes: vec![0; 3], + source_runs: vec![0; 3], + font_handles: vec![1; 3], + index_at: vec![0, 1, 2, 3], + ..ClusterArena::default() + }; + let styles = [StyleSegment { + text_start: 0, + text_end: 3, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut constraint = constraint(); + constraint.overflow = OVERFLOW_ELLIPSIS; + constraint.wrap = WRAP_NONE; + let mut constrained_region = region(); + constrained_region.inline_end = 4.0; + constrained_region.clip_inline_end = 4.0; + constrained_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint], + regions: vec![RetainedRegion { + record: constrained_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let mut layout = FlowLayoutArena::default(); + layout + .build( + &geometry, + &clusters, + &styles, + &mut InlineSlotArena::default(), + 4, + 1, + |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }, + |_| Some(1), + ) + .unwrap(); + assert_eq!(layout.fragments[0].line.cluster_end, 3); + assert_eq!(layout.ellipsis_threads(), [constraint.flow_thread_id]); + } + fn constraint() -> FlowConstraint { FlowConstraint { paragraph_id: 1, diff --git a/packages/text/rust/shaper/src/engine/layout_query.rs b/packages/text/rust/shaper/src/engine/layout_query.rs index b0b8102b..03dd1168 100644 --- a/packages/text/rust/shaper/src/engine/layout_query.rs +++ b/packages/text/rust/shaper/src/engine/layout_query.rs @@ -218,7 +218,7 @@ fn finite_nonnegative_f32(value: f64) -> Result { mod tests { use super::*; use crate::engine::{ - flow_composition::{FlowFragment, FlowLine}, + flow_composition::{FlowFragment, FlowLine, NO_BOUNDARY}, line_composition::ComposedLine, semantic_wire::FlowConstraint, }; @@ -253,7 +253,9 @@ mod tests { }, slot_start: 0.0, slot_end: 20.0, + boundary_index: NO_BOUNDARY, }], + ..FlowLayoutArena::default() }; let mut records = vec![]; let positioned = [layout_glyph(3), layout_glyph(0)]; @@ -341,7 +343,9 @@ mod tests { }, slot_start: 0.0, slot_end: 6.0, + boundary_index: NO_BOUNDARY, }], + ..FlowLayoutArena::default() }; let mut records = vec![]; append_measurement( diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index 9c20c8d1..e4a65ccd 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -1108,8 +1108,8 @@ impl OrderedPlanCompiler { break; } } - let count = u16::try_from(end - start) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let count = + u16::try_from(end - start).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; let (inline_start, block_start, inline_extent, block_extent) = indexed_span_bounds( context.input.glyphs, instances[start..end] diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index 8e3ff962..00b7882b 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -12,6 +12,11 @@ use super::{ policy::{CapabilitySetId, InputScope, MAX_REGISTERS, ProgramDescriptor, ValidatedPolicy}, }; +// `fontSize` and `rasterPixelRatio` can select a different baked resource. +// A replacement batch must therefore receive every input stream once even when +// the policy dependency mask would omit unchanged fields from an in-place patch. +const RESOURCE_SELECTION_CHANGES: u16 = (1 << 4) | (1 << 5); + pub const DEFAULT_GATHER_RECORD_CAPACITY: usize = 32_768; #[derive(Clone, Copy, Debug, PartialEq)] @@ -169,6 +174,7 @@ impl PolicyGatherWorkspace { } else { super::positioning::ALL_SEMANTIC_CHANGES }; + let selection_changed = semantic_changes & RESOURCE_SELECTION_CHANGES != 0; let mut cached_font_handle = None; let mut cached_binding = None; let mut cached_program = None; @@ -206,7 +212,7 @@ impl PolicyGatherWorkspace { technique, variant, semantic_changes, - force_all_inputs, + force_all_inputs || selection_changed, ) .ok_or(GatherError::ProgramMissing)?; cached_program = Some((technique, variant, program, f32_inputs, u32_inputs)); @@ -762,6 +768,39 @@ mod tests { assert!(input.u32_fields.iter().all(|field| *field == [0, 0])); } + #[test] + fn font_selection_changes_retain_inputs_needed_to_initialize_a_new_resource_batch() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let semantic_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_change_masks: &[ + RESOURCE_SELECTION_CHANGES, + RESOURCE_SELECTION_CHANGES, + ], + semantic_f32: &[&semantic_x], + semantic_u32: &[&semantic_kind], + }, + false, + |_| Some(&binding), + ) + .unwrap(); + + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.f32_fields[0], semantic_x); + assert_eq!(input.u32_fields[0], semantic_kind); + } + #[test] fn missing_program_binding_and_source_are_explicit() { let binding = binding(); diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index 6514f754..07a23bfe 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -11,7 +11,7 @@ use super::{ frame::{ALIGN_CENTER, ALIGN_END, ALIGN_JUSTIFY, ALIGN_START}, identity_index::{IdentityIndex, IdentityIndexError}, policy_gather::LayoutGlyph, - shaping_state::{ShapeArena, ShapingRun}, + shaping_state::{BoundaryShape, BoundaryShapeArena, ShapeArena, ShapingRun}, style_state::StyleSegment, }; @@ -85,6 +85,7 @@ impl PositionedGlyphArena { clusters: &ClusterArena, runs: &[ShapingRun], shape: &ShapeArena, + boundary_shape: &BoundaryShapeArena, styles: &[StyleSegment], bidi: &BidiAnalysis, identity_index: &mut IdentityIndex, @@ -130,6 +131,7 @@ impl PositionedGlyphArena { clusters, runs, shape, + boundary_shape, styles, bidi, visually_ltr, @@ -205,6 +207,7 @@ impl PositionedGlyphArena { clusters: &ClusterArena, runs: &[ShapingRun], shape: &ShapeArena, + boundary_shape: &BoundaryShapeArena, styles: &[StyleSegment], bidi: &BidiAnalysis, visually_ltr: bool, @@ -215,9 +218,24 @@ impl PositionedGlyphArena { .map_err(|_| EngineError::InvalidRequest)?; let cluster_end = usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + let boundary = if fragment.boundary_index == super::flow_composition::NO_BOUNDARY { + None + } else { + Some( + boundary_shape + .record(fragment.boundary_index) + .ok_or(EngineError::InvalidRequest)?, + ) + }; + let retained_cluster_end = boundary.map_or(cluster_end, |boundary| { + usize::try_from(boundary.cluster_start).unwrap_or(usize::MAX) + }); + if retained_cluster_end > cluster_end { + return Err(EngineError::InvalidRequest); + } let visual_start = self.visual_clusters.len(); if !visually_ltr { - for cluster in cluster_start..cluster_end { + for cluster in cluster_start..retained_cluster_end { if clusters.flags[cluster] & CLUSTER_HARD_BREAK != 0 { continue; } @@ -259,7 +277,7 @@ impl PositionedGlyphArena { let mut cursor = fragment.slot_start + offset; let baseline = line.block_start + line.baseline; let visual_count = if visually_ltr { - cluster_end.saturating_sub(cluster_start) + retained_cluster_end.saturating_sub(cluster_start) } else { self.visual_clusters.len().saturating_sub(visual_start) }; @@ -389,9 +407,237 @@ impl PositionedGlyphArena { cursor += per_space; } } + if let Some(boundary) = boundary { + let _ = self.position_boundary( + line, + boundary, + cursor, + baseline, + text, + clusters, + runs, + boundary_shape, + metrics_for, + extents_for, + )?; + } Ok(()) } + #[allow(clippy::too_many_arguments)] + fn position_boundary( + &mut self, + line: FlowLine, + boundary: BoundaryShape, + mut cursor: f64, + baseline: f64, + text: &[u16], + clusters: &ClusterArena, + runs: &[ShapingRun], + arena: &BoundaryShapeArena, + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result { + let run = *runs + .get(usize::try_from(boundary.source_run).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let style = run.style; + let source_cluster = + usize::try_from(boundary.cluster_start).map_err(|_| EngineError::InvalidRequest)?; + let ellipsis_cluster = usize::try_from(boundary.cluster_end) + .map_err(|_| EngineError::InvalidRequest)? + .saturating_sub(1) + .max(source_cluster) + .min(clusters.starts.len().saturating_sub(1)); + cursor = self.position_boundary_span( + line, + cursor, + baseline, + boundary.source_glyph_start, + boundary.source_glyph_count, + boundary.source_binding_handle, + boundary.source_font_handle, + None, + source_cluster, + style, + arena, + text, + clusters, + metrics_for, + extents_for, + )?; + self.position_boundary_span( + line, + cursor, + baseline, + boundary.ellipsis_glyph_start, + boundary.ellipsis_glyph_count, + boundary.ellipsis_binding_handle, + boundary.ellipsis_font_handle, + Some(boundary.text_end), + ellipsis_cluster, + style, + arena, + text, + clusters, + metrics_for, + extents_for, + ) + } + + #[allow(clippy::too_many_arguments)] + fn position_boundary_span( + &mut self, + line: FlowLine, + mut cursor: f64, + baseline: f64, + glyph_start: u32, + glyph_count: u32, + binding_handle: u32, + font_handle: u32, + cluster_override: Option, + fallback_cluster: usize, + style: super::style_state::ResolvedStyle, + arena: &BoundaryShapeArena, + text: &[u16], + clusters: &ClusterArena, + metrics_for: impl Fn(u32) -> Option + Copy, + extents_for: impl Fn(u32, u32) -> Option + Copy, + ) -> Result { + let metrics = metrics_for(font_handle).ok_or(EngineError::InvalidRequest)?; + if font_handle == 0 || metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); + let start = usize::try_from(glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::try_from(glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + for glyph in start..end { + let glyph_id = u32::from( + *arena + .shape + .glyph_ids + .get(glyph) + .ok_or(EngineError::InvalidRequest)?, + ); + let shaped_cluster = *arena + .shape + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let cluster = cluster_override.unwrap_or(shaped_cluster); + let cluster_index = if cluster_override.is_some() { + fallback_cluster + } else { + clusters + .starts + .binary_search(&shaped_cluster) + .unwrap_or(fallback_cluster) + }; + let semantic_id = *clusters + .stable_ids + .get(cluster_index) + .ok_or(EngineError::InvalidRequest)?; + let x_advance = f64::from( + arena + .shape + .x_advances + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) + .abs() + * scale; + let x_offset = f64::from( + arena + .shape + .x_offsets + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + let y_offset = f64::from( + arena + .shape + .y_offsets + .get(glyph) + .copied() + .ok_or(EngineError::InvalidRequest)?, + ) * scale; + let stable_id = *arena + .stable_ids + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let flags = *arena + .shape + .glyph_flags + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + let origin_inline = cursor + x_offset; + let origin_block = baseline - y_offset - f64::from(style.baseline_shift); + self.semantic_glyphs.push(SemanticGlyph { + stable_id, + font_handle, + cluster, + glyph_id: u16::try_from(glyph_id).map_err(|_| EngineError::ResultTooLarge)?, + flags, + font_size: style.font_size, + inline_origin: finite_f32(origin_inline)?, + block_origin: finite_f32(origin_block)?, + }); + if let Some(extents) = extents_for(font_handle, glyph_id) { + let inline_start = origin_inline + f64::from(extents.x_min) * scale; + let block_start = origin_block - f64::from(extents.y_max) * scale; + let inline_extent = f64::from(extents.x_max - extents.x_min) * scale; + let block_extent = f64::from(extents.y_max - extents.y_min) * scale; + self.push_glyph( + LayoutGlyph { + stable_id, + content_revision: 0, + binding_handle, + font_handle, + glyph_id, + semantic_id, + material_id: style.material_id, + clip_id: line.clip_id, + depth_key: 0, + font_size: style.font_size, + raster_pixel_ratio: style.raster_pixel_ratio, + inline_start: finite_f32(inline_start)?, + block_start: finite_f32(block_start)?, + inline_extent: nonnegative_f32(inline_extent)?, + block_extent: nonnegative_f32(block_extent)?, + }, + style.foreground_rgba, + semantic_id, + line.region_id, + line.flow_thread_id, + line.transform_index, + ); + } + cursor += x_advance; + if cluster_override.is_none() { + let next_cluster = (glyph + 1 < end) + .then(|| arena.shape.clusters.get(glyph + 1).copied()) + .flatten(); + if next_cluster != Some(shaped_cluster) { + cursor += f64::from(style.letter_spacing); + if clusters + .starts + .get(cluster_index) + .and_then(|start| usize::try_from(*start).ok()) + .and_then(|start| text.get(start)) + == Some(&0x20) + { + cursor += f64::from(style.word_spacing); + } + } + } + } + Ok(cursor) + } + fn push_glyph( &mut self, glyph: LayoutGlyph, @@ -722,8 +968,8 @@ fn reserve(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { mod tests { use super::*; use crate::engine::{ - cluster_state::CLUSTER_SAFE_BEFORE, line_composition::ComposedLine, - style_state::ResolvedStyle, + cluster_state::CLUSTER_SAFE_BEFORE, flow_composition::NO_BOUNDARY, + line_composition::ComposedLine, style_state::ResolvedStyle, }; use alloc::vec; @@ -838,7 +1084,9 @@ mod tests { }, slot_start: 0.0, slot_end: 20.0, + boundary_index: NO_BOUNDARY, }], + ..FlowLayoutArena::default() }; let metrics = |_| { Some(FontMetrics { @@ -867,6 +1115,7 @@ mod tests { &clusters, &runs, &shape, + &BoundaryShapeArena::default(), &styles, &bidi, &mut index, @@ -893,6 +1142,7 @@ mod tests { &clusters, &runs, &shape, + &BoundaryShapeArena::default(), &styles, &bidi, &mut index, @@ -915,6 +1165,7 @@ mod tests { &clusters, &runs, &shape, + &BoundaryShapeArena::default(), &styles, &bidi, &mut index, diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs index e61ca24b..4a25877f 100644 --- a/packages/text/rust/shaper/src/engine/shaping_state.rs +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -48,6 +48,30 @@ pub(crate) struct ShapeArena { pub glyph_flags: Vec, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct BoundaryShape { + pub flow_thread_id: u32, + pub source_run: u32, + pub cluster_start: u32, + pub cluster_end: u32, + pub text_end: u32, + pub source_binding_handle: u32, + pub source_font_handle: u32, + pub ellipsis_binding_handle: u32, + pub ellipsis_font_handle: u32, + pub source_glyph_start: u32, + pub source_glyph_count: u32, + pub ellipsis_glyph_start: u32, + pub ellipsis_glyph_count: u32, +} + +#[derive(Default)] +pub(crate) struct BoundaryShapeArena { + pub records: Vec, + pub shape: ShapeArena, + pub stable_ids: Vec, +} + impl ShapingRunArena { pub(crate) fn reserve(&mut self, capacity: usize) -> Result<(), EngineError> { if self.runs.capacity() < capacity { @@ -275,6 +299,94 @@ impl ShapeArena { } Ok(()) } + + pub(crate) fn append_from( + &mut self, + source: &Self, + run_index: usize, + ) -> Result<(u32, u32), EngineError> { + let run = *source + .runs + .get(run_index) + .ok_or(EngineError::InvalidRequest)?; + let source_start = + usize::try_from(run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let source_end = source_start + .checked_add(usize::try_from(run.glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let glyph_start = + u32::try_from(self.glyph_ids.len()).map_err(|_| EngineError::ResultTooLarge)?; + self.reserve( + self.glyph_ids + .len() + .saturating_add(source_end.saturating_sub(source_start)), + )?; + self.runs.push(ShapedRun { glyph_start, ..run }); + self.glyph_ids.extend_from_slice( + source + .glyph_ids + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.clusters.extend_from_slice( + source + .clusters + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.x_advances.extend_from_slice( + source + .x_advances + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.y_advances.extend_from_slice( + source + .y_advances + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.x_offsets.extend_from_slice( + source + .x_offsets + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.y_offsets.extend_from_slice( + source + .y_offsets + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + self.glyph_flags.extend_from_slice( + source + .glyph_flags + .get(source_start..source_end) + .ok_or(EngineError::InvalidRequest)?, + ); + Ok((glyph_start, run.glyph_count)) + } +} + +impl BoundaryShapeArena { + pub(crate) fn clear(&mut self) { + self.records.clear(); + self.shape.clear(); + self.stable_ids.clear(); + } + + pub(crate) fn reserve(&mut self, glyph_capacity: usize) -> Result<(), EngineError> { + reserve_vec(&mut self.records, glyph_capacity.min(16))?; + self.shape.reserve(glyph_capacity)?; + reserve_vec(&mut self.stable_ids, glyph_capacity) + } + + pub(crate) fn record(&self, index: u32) -> Option { + usize::try_from(index) + .ok() + .and_then(|index| self.records.get(index)) + .copied() + } } fn reserve_vec(values: &mut Vec, capacity: usize) -> Result<(), EngineError> { diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 6b0ce329..ae0ccaaf 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -1415,8 +1415,8 @@ impl StablePlanCompiler { .ok_or(StablePlanError::ProgramMissing)?; let split_material = program.draw_key_mask & BATCH_MATERIAL != 0; let split_transform = program.draw_key_mask & BATCH_TRANSFORM != 0; - let input_indices = &self.batch_input_indices - [range(pending.item_start, pending.item_count)?]; + let input_indices = + &self.batch_input_indices[range(pending.item_start, pending.item_count)?]; let mut start = 0_usize; while start < input_indices.len() { let first_input = input_indices[start] as usize; @@ -1426,8 +1426,7 @@ impl StablePlanCompiler { while end < input_indices.len() && end - start < usize::from(u16::MAX) { let input_index = input_indices[end] as usize; let glyph = context.input.glyphs[input_index]; - if self.input_order_records[input_index] - == first_record + (end - start) as u32 + if self.input_order_records[input_index] == first_record + (end - start) as u32 && draw_fields_compatible(first, glyph, split_material, split_transform) { end += 1; @@ -1435,8 +1434,8 @@ impl StablePlanCompiler { break; } } - let count = u16::try_from(end - start) - .map_err(|_| StablePlanError::ArithmeticOverflow)?; + let count = + u16::try_from(end - start).map_err(|_| StablePlanError::ArithmeticOverflow)?; let (inline_start, block_start, inline_extent, block_extent) = indexed_span_bounds( context.input.glyphs, input_indices[start..end] diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index f2fae923..8dfe6d83 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1,14 +1,14 @@ use alloc::{collections::BTreeMap, vec::Vec}; use crate::{ - STATUS_RESULT_TOO_LARGE, ShapeRunRef, ShaperRegistry, + STATUS_RESULT_TOO_LARGE, ShapeRangeRef, ShapeRunRef, ShaperRegistry, bidi::{BidiAnalysis, BidiError, DIRECTION_AUTO, analyze_into as analyze_bidi_into}, unicode::{UnicodeAnalysis, UnicodeError}, }; use super::{ cluster_state::{ClusterArena, ClusterBuildInput}, - flow_composition::FlowLayoutArena, + flow_composition::{EllipsisReplacement, FlowLayoutArena}, flow_geometry::FlowGeometryArena, font_binding::FontRenderBinding, frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, @@ -20,7 +20,7 @@ use super::{ positioning::PositionedGlyphArena, render_plan::RenderPlanView, render_plan_compiler::{RenderPlanCompiler, RenderPlanCompilerError}, - shaping_state::{ShapeArena, ShapingRunArena}, + shaping_state::{BoundaryShape, BoundaryShapeArena, ShapeArena, ShapingRun, ShapingRunArena}, style_state::{ DEFAULT_STYLE_CAPACITY, MutationKey, ResolutionScope, ResolvedStyleArena, StyleArena, StyleInvalidation, @@ -78,6 +78,18 @@ struct ClusterRecord { missing: bool, } +#[derive(Clone, Copy)] +struct BoundaryCandidate { + source_run: usize, + cluster_start: usize, + source_binding_handle: u32, + source_font_handle: u32, + ellipsis_binding_handle: u32, + ellipsis_font_handle: u32, + source_advance: f64, + ellipsis_advance: f64, +} + #[derive(Default)] struct EngineSession { revision: SessionRevision, @@ -143,6 +155,11 @@ struct ParagraphState { pending_geometry: FlowGeometryArena, flow_layout: FlowLayoutArena, pending_flow_layout: FlowLayoutArena, + boundary_shape: BoundaryShapeArena, + pending_boundary_shape: BoundaryShapeArena, + boundary_shape_scratch: ShapeArena, + ellipsis_shape_scratch: ShapeArena, + ellipsis_text_scratch: Vec, positioned: PositionedGlyphArena, pending_positioned: PositionedGlyphArena, flow_slot_scratch: super::flow_geometry::InlineSlotArena, @@ -1083,6 +1100,11 @@ impl ParagraphState { self.pending_geometry.clear(); self.flow_layout.clear(); self.pending_flow_layout.clear(); + self.boundary_shape.clear(); + self.pending_boundary_shape.clear(); + self.boundary_shape_scratch.clear(); + self.ellipsis_shape_scratch.clear(); + self.ellipsis_text_scratch.clear(); self.positioned.clear(); self.pending_positioned.clear(); self.fallback_spans.clear(); @@ -1145,6 +1167,7 @@ impl ParagraphState { font_bindings, limits.max_lines, limits.max_slots_per_band, + next_glyph_id, )?; } if positioned_changed { @@ -1211,6 +1234,15 @@ impl ParagraphState { self.pending_clusters.reserve(capacity)?; self.flow_layout.reserve(capacity, 1)?; self.pending_flow_layout.reserve(capacity, 1)?; + self.boundary_shape.reserve(capacity.min(64))?; + self.pending_boundary_shape.reserve(capacity.min(64))?; + self.boundary_shape_scratch.reserve(8)?; + self.ellipsis_shape_scratch.reserve(4)?; + if self.ellipsis_text_scratch.capacity() == 0 { + self.ellipsis_text_scratch + .try_reserve_exact(1) + .map_err(|_| EngineError::ResultTooLarge)?; + } self.positioned.reserve(glyph_capacity)?; self.pending_positioned.reserve(glyph_capacity)?; self.glyph_identity_index @@ -1776,13 +1808,15 @@ impl ParagraphState { fn prepare_flow_layout( &mut self, - shaper: &ShaperRegistry, + shaper: &mut ShaperRegistry, font_stacks: &[RegisteredFontStack], font_bindings: &[RegisteredFontBinding], max_lines: u32, max_slots_per_band: u32, + next_glyph_id: &mut u32, ) -> Result<(), EngineError> { self.abort_flow_layout(); + self.pending_boundary_shape.clear(); let clusters = if self.clusters_prepared { &self.pending_clusters } else { @@ -1793,6 +1827,21 @@ impl ParagraphState { } else { self.resolved_styles.segments() }; + let style_storage = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; let geometry = if self.geometry_prepared { &self.pending_geometry } else { @@ -1819,18 +1868,150 @@ impl ParagraphState { }) }, )?; + let mut ellipsis_index = 0usize; + while ellipsis_index < self.pending_flow_layout.ellipsis_threads().len() { + let flow_thread_id = self.pending_flow_layout.ellipsis_threads()[ellipsis_index]; + let line = self + .pending_flow_layout + .lines + .iter() + .rev() + .find(|line| line.flow_thread_id == flow_thread_id) + .copied() + .ok_or(EngineError::InvalidRequest)?; + let fragment_index = usize::try_from(line.fragment_start) + .map_err(|_| EngineError::InvalidRequest)? + .checked_add(usize::from(line.fragment_count)) + .and_then(|end| end.checked_sub(1)) + .ok_or(EngineError::InvalidRequest)?; + let fragment = *self + .pending_flow_layout + .fragments + .get(fragment_index) + .ok_or(EngineError::InvalidRequest)?; + let line_cluster_start = usize::try_from(fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + let line_text_start = fragment.line.text_start; + let source_shape = &mut self.boundary_shape_scratch; + let ellipsis_shape = &mut self.ellipsis_shape_scratch; + let ellipsis_text = &mut self.ellipsis_text_scratch; + let mut final_candidate = None; + let target = self + .pending_flow_layout + .truncate_for_ellipsis(flow_thread_id, clusters, |cluster_end, text_end| { + let candidate = prepare_boundary_candidate( + shaper, + text, + ellipsis_text, + source_shape, + ellipsis_shape, + clusters, + runs, + style_storage, + font_stacks, + font_bindings, + line_cluster_start, + line_text_start, + cluster_end, + text_end, + )?; + let retained_advance = clusters.advances[candidate.cluster_start..cluster_end] + .iter() + .copied() + .sum::(); + final_candidate = Some(candidate); + Ok(EllipsisReplacement { + cluster_start: candidate.cluster_start, + advance_adjustment: candidate.source_advance + candidate.ellipsis_advance + - retained_advance, + }) + })? + .ok_or(EngineError::InvalidRequest)?; + let candidate = final_candidate.ok_or(EngineError::InvalidRequest)?; + if usize::try_from(target.boundary_cluster_start).ok() != Some(candidate.cluster_start) + { + return Err(EngineError::InvalidRequest); + } + let source_span = if source_shape.runs.is_empty() { + ( + u32::try_from(self.pending_boundary_shape.shape.glyph_ids.len()) + .map_err(|_| EngineError::ResultTooLarge)?, + 0, + ) + } else { + self.pending_boundary_shape + .shape + .append_from(source_shape, 0)? + }; + let ellipsis_span = self + .pending_boundary_shape + .shape + .append_from(ellipsis_shape, 0)?; + append_boundary_source_ids( + &mut self.pending_boundary_shape.stable_ids, + source_shape, + clusters, + next_glyph_id, + )?; + let previous = self + .boundary_shape + .records + .iter() + .find(|record| record.flow_thread_id == flow_thread_id) + .copied(); + let previous_ellipsis_ids = previous + .and_then(|record| { + let start = usize::try_from(record.ellipsis_glyph_start).ok()?; + let end = + start.checked_add(usize::try_from(record.ellipsis_glyph_count).ok()?)?; + self.boundary_shape.stable_ids.get(start..end) + }) + .unwrap_or(&[]); + let ellipsis_count = + usize::try_from(ellipsis_span.1).map_err(|_| EngineError::InvalidRequest)?; + for ordinal in 0..ellipsis_count { + let stable_id = previous_ellipsis_ids + .get(ordinal) + .copied() + .filter(|id| *id != 0) + .map_or_else(|| allocate_glyph_id(next_glyph_id), Ok)?; + self.pending_boundary_shape.stable_ids.push(stable_id); + } + let boundary_index = u32::try_from(self.pending_boundary_shape.records.len()) + .map_err(|_| EngineError::ResultTooLarge)?; + self.pending_boundary_shape.records.push(BoundaryShape { + flow_thread_id, + source_run: u32::try_from(candidate.source_run) + .map_err(|_| EngineError::ResultTooLarge)?, + cluster_start: target.boundary_cluster_start, + cluster_end: target.cluster_end, + text_end: target.text_end, + source_binding_handle: candidate.source_binding_handle, + source_font_handle: candidate.source_font_handle, + ellipsis_binding_handle: candidate.ellipsis_binding_handle, + ellipsis_font_handle: candidate.ellipsis_font_handle, + source_glyph_start: source_span.0, + source_glyph_count: source_span.1, + ellipsis_glyph_start: ellipsis_span.0, + ellipsis_glyph_count: ellipsis_span.1, + }); + self.pending_flow_layout.fragments[fragment_index].boundary_index = boundary_index; + ellipsis_index += 1; + } self.flow_layout_prepared = true; Ok(()) } fn abort_flow_layout(&mut self) { self.pending_flow_layout.clear(); + self.pending_boundary_shape.clear(); self.flow_layout_prepared = false; } fn commit_flow_layout(&mut self) { if self.flow_layout_prepared { core::mem::swap(&mut self.flow_layout, &mut self.pending_flow_layout); + core::mem::swap(&mut self.boundary_shape, &mut self.pending_boundary_shape); } self.abort_flow_layout(); } @@ -1876,6 +2057,11 @@ impl ParagraphState { } else { &self.flow_layout }; + let boundary_shape = if self.flow_layout_prepared { + &self.pending_boundary_shape + } else { + &self.boundary_shape + }; self.pending_positioned.build( &self.positioned, flow, @@ -1883,6 +2069,7 @@ impl ParagraphState { clusters, runs, shape, + boundary_shape, styles, bidi, &mut self.glyph_identity_index, @@ -2040,6 +2227,233 @@ fn find_font_binding( .ok_or(EngineError::FontStackMissing) } +#[allow(clippy::too_many_arguments)] +fn prepare_boundary_candidate( + shaper: &mut ShaperRegistry, + text: &[u16], + ellipsis_text: &mut Vec, + source_shape: &mut ShapeArena, + ellipsis_shape: &mut ShapeArena, + clusters: &ClusterArena, + runs: &[ShapingRun], + styles: &StyleArena, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + line_cluster_start: usize, + line_text_start: u32, + cluster_end: usize, + text_end: u32, +) -> Result { + let anchor = cluster_end + .checked_sub(1) + .filter(|index| *index >= line_cluster_start) + .or_else(|| (cluster_end < clusters.starts.len()).then_some(cluster_end)) + .ok_or(EngineError::InvalidRequest)?; + let source_run = usize::try_from( + *clusters + .source_runs + .get(anchor) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let run = *runs.get(source_run).ok_or(EngineError::InvalidRequest)?; + let source_binding_handle = *clusters + .binding_handles + .get(anchor) + .ok_or(EngineError::InvalidRequest)?; + let source_font_handle = *clusters + .font_handles + .get(anchor) + .ok_or(EngineError::InvalidRequest)?; + if source_binding_handle == 0 || source_font_handle == 0 { + return Err(EngineError::InvalidRequest); + } + let mut cluster_start = cluster_end; + while cluster_start > line_cluster_start { + let previous = cluster_start - 1; + if clusters.source_runs.get(previous) != Some(&(source_run as u32)) + || clusters.binding_handles.get(previous) != Some(&source_binding_handle) + || clusters.font_handles.get(previous) != Some(&source_font_handle) + { + break; + } + cluster_start = previous; + } + + source_shape.clear(); + let mut source_advance = 0.0; + if cluster_start < cluster_end { + let item_start = *clusters + .starts + .get(cluster_start) + .ok_or(EngineError::InvalidRequest)?; + shaper + .with_shaped_range( + source_font_handle, + text, + ShapeRunRef { + text_start: run.text_start, + text_end: run.text_end, + script: run.script, + language: styles.resolved_language(run.style), + features: styles.resolved_features(run.style), + direction: run.direction, + cluster_level: 0, + flags: 0x40, + }, + ShapeRangeRef { + item_start, + item_end: text_end, + context_start: line_text_start.max(run.text_start).min(item_start), + context_end: text_end, + flags: 0x40 | 0x02 | u32::from(item_start == line_text_start), + }, + |shaped| { + source_shape.append( + source_run, + source_font_handle, + source_binding_handle, + item_start, + text_end, + shaped, + ) + }, + ) + .map_err(shaper_error)?; + let metrics = shaper + .font_metrics(source_font_handle) + .ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(run.style.font_size) / f64::from(metrics.units_per_em); + source_advance = source_shape + .x_advances + .iter() + .try_fold(0.0, |advance, value| { + let next = advance + f64::from(value.unsigned_abs()) * scale; + next.is_finite() + .then_some(next) + .ok_or(EngineError::InvalidRequest) + })?; + for cluster in cluster_start..cluster_end { + source_advance += f64::from(run.style.letter_spacing); + if clusters + .starts + .get(cluster) + .and_then(|start| usize::try_from(*start).ok()) + .and_then(|start| text.get(start)) + == Some(&0x20) + { + source_advance += f64::from(run.style.word_spacing); + } + } + } + + ellipsis_text.clear(); + ellipsis_text.push(0x2026); + let stack = find_font_stack(font_stacks, run.style.font_stack_handle)?; + let mut selected = None; + for (font_index, binding_handle) in stack.fonts.iter().copied().enumerate() { + let font_handle = find_font_binding(font_bindings, binding_handle)?.shaping_handle; + ellipsis_shape.clear(); + let missing = shaper + .with_shaped_run( + font_handle, + ellipsis_text, + ShapeRunRef { + text_start: 0, + text_end: 1, + script: run.script, + language: styles.resolved_language(run.style), + features: &[], + direction: run.direction, + cluster_level: 0, + flags: 0x40, + }, + |shaped| { + let missing = shaped.glyph_infos().iter().any(|info| info.glyph_id == 0); + ellipsis_shape.append(source_run, font_handle, binding_handle, 0, 1, shaped)?; + Ok(missing) + }, + ) + .map_err(shaper_error)?; + if !missing || font_index + 1 == stack.fonts.len() { + selected = Some((binding_handle, font_handle)); + break; + } + } + let (ellipsis_binding_handle, ellipsis_font_handle) = + selected.ok_or(EngineError::FontStackMissing)?; + let metrics = shaper + .font_metrics(ellipsis_font_handle) + .ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(run.style.font_size) / f64::from(metrics.units_per_em); + let ellipsis_advance = ellipsis_shape + .x_advances + .iter() + .try_fold(0.0, |advance, value| { + let next = advance + f64::from(value.unsigned_abs()) * scale; + next.is_finite() + .then_some(next) + .ok_or(EngineError::InvalidRequest) + })?; + Ok(BoundaryCandidate { + source_run, + cluster_start, + source_binding_handle, + source_font_handle, + ellipsis_binding_handle, + ellipsis_font_handle, + source_advance, + ellipsis_advance, + }) +} + +fn append_boundary_source_ids( + output: &mut Vec, + source: &ShapeArena, + clusters: &ClusterArena, + next_glyph_id: &mut u32, +) -> Result<(), EngineError> { + let mut previous_cluster = None; + let mut ordinal = 0usize; + for &text_cluster in &source.clusters { + if previous_cluster == Some(text_cluster) { + ordinal += 1; + } else { + previous_cluster = Some(text_cluster); + ordinal = 0; + } + let stable_id = clusters + .starts + .binary_search(&text_cluster) + .ok() + .and_then(|cluster| { + let start = usize::try_from(*clusters.glyph_starts.get(cluster)?).ok()?; + let count = usize::try_from(*clusters.glyph_counts.get(cluster)?).ok()?; + (ordinal < count) + .then(|| clusters.glyph_stable_ids.get(start + ordinal).copied()) + .flatten() + }) + .filter(|id| *id != 0) + .map_or_else(|| allocate_glyph_id(next_glyph_id), Ok)?; + output.push(stable_id); + } + Ok(()) +} + +fn allocate_glyph_id(next_glyph_id: &mut u32) -> Result { + let stable_id = (*next_glyph_id).max(1); + *next_glyph_id = stable_id + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + Ok(stable_id) +} + fn push_fallback_span( spans: &mut Vec, span: FallbackSpan, diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 92828619..edb3a395 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -144,12 +144,12 @@ pub struct ReshapeRange { } #[derive(Clone, Copy)] -struct SegmentRange { - item_start: u32, - item_end: u32, - context_start: u32, - context_end: u32, - flags: u32, +pub(crate) struct ShapeRangeRef { + pub item_start: u32, + pub item_end: u32, + pub context_start: u32, + pub context_end: u32, + pub flags: u32, } #[derive(Clone, Copy)] @@ -323,7 +323,7 @@ impl ShaperRegistry { let range = &ranges[segment]; ( usize::try_from(range.run).map_err(|_| STATUS_INVALID_REQUEST)?, - SegmentRange { + ShapeRangeRef { item_start: range.item_start, item_end: range.item_end, context_start: range.context_start, @@ -335,7 +335,7 @@ impl ShaperRegistry { let run = &request.runs[segment]; ( segment, - SegmentRange { + ShapeRangeRef { item_start: run.text_start, item_end: run.text_end, context_start: run.text_start, @@ -405,17 +405,28 @@ impl ShaperRegistry { run: ShapeRunRef<'_>, consume: impl FnOnce(&harfrust::GlyphBuffer) -> Result, ) -> Result { - let font = self - .fonts - .get_mut(&font_handle) - .ok_or(STATUS_FONT_MISSING)?; - let range = SegmentRange { + let range = ShapeRangeRef { item_start: run.text_start, item_end: run.text_end, context_start: run.text_start, context_end: run.text_end, flags: run.flags, }; + self.with_shaped_range(font_handle, text, run, range, consume) + } + + pub(crate) fn with_shaped_range( + &mut self, + font_handle: u32, + text: &[u16], + run: ShapeRunRef<'_>, + range: ShapeRangeRef, + consume: impl FnOnce(&harfrust::GlyphBuffer) -> Result, + ) -> Result { + let font = self + .fonts + .get_mut(&font_handle) + .ok_or(STATUS_FONT_MISSING)?; let shaped = shape_segment( font, text, @@ -584,7 +595,7 @@ fn shape_segment( font: &mut RegisteredFont, text: &[u16], run: ShapeRunRef<'_>, - range: SegmentRange, + range: ShapeRangeRef, buffer_slot: &mut Option, context_codepoints: &mut Vec, features: &mut Vec, @@ -635,7 +646,7 @@ fn shape_segment_inner( font: &mut RegisteredFont, text: &[u16], run: ShapeRunRef<'_>, - range: SegmentRange, + range: ShapeRangeRef, buffer: &mut UnicodeBuffer, context_codepoints: &mut Vec, features: &mut Vec, @@ -719,7 +730,7 @@ fn shape_segment_inner( fn shape_features( run: ShapeRunRef<'_>, - range: SegmentRange, + range: ShapeRangeRef, output: &mut Vec, ) -> Result<(), u32> { output.clear(); diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index c9c7e57a..2a140815 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -14,6 +14,7 @@ import { msdfShader } from './msdf-shader.js'; import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; import type { ThreeTextMaterialContext } from './material.js'; import type { ThreePlanProgramBuffer } from './plan-program-registry.js'; +import { textProfileBegin, textProfileEnd } from './profiler.js'; type ScalarArray = Float32Array | Uint32Array | Uint16Array; @@ -143,7 +144,7 @@ export class ThreeTextRenderPlanExecutor { } this.syncTransforms(); this.#applyRetirements(plan, retirements); - this.#captureOriginTargets(); + this.#originRecords.clear(); } snapshotGlyphOrigins( @@ -154,6 +155,7 @@ export class ThreeTextRenderPlanExecutor { if (stableIds.length !== fallbackX.length || stableIds.length !== fallbackY.length) { throw new RangeError('glyph origin snapshot arrays must be parallel'); } + this.#ensureOriginRecords(); const shapedX = fallbackX.slice(); const shapedY = fallbackY.slice(); const displayedX = fallbackX.slice(); @@ -174,6 +176,7 @@ export class ThreeTextRenderPlanExecutor { if (stableIds.length !== x.length || stableIds.length !== y.length) { throw new RangeError('glyph origin override arrays must be parallel'); } + this.#ensureOriginRecords(); const touched = new Map(); for (let index = 0; index < stableIds.length; index += 1) { const record = this.#originRecords.get(stableIds[index]!); @@ -191,6 +194,7 @@ export class ThreeTextRenderPlanExecutor { } clearGlyphOriginOverrides(stableIds: Uint32Array): void { + this.#ensureOriginRecords(); const touched = new Map(); for (const stableId of stableIds) { const record = this.#originRecords.get(stableId); @@ -509,8 +513,9 @@ export class ThreeTextRenderPlanExecutor { markOriginRanges(touched); } - #captureOriginTargets(): void { - this.#originRecords.clear(); + #ensureOriginRecords(): void { + if (this.#originRecords.size !== 0) return; + const started = textProfileBegin(); for (const segment of this.#originSegments) { if (!(segment.origins.array instanceof Float32Array) || !(segment.stableIds.array instanceof Uint32Array)) continue; @@ -528,6 +533,7 @@ export class ThreeTextRenderPlanExecutor { }); } } + textProfileEnd('origins.index', started); } #transformRealization(buffers: ReadonlyMap, transformId: number): TransformRealization { diff --git a/packages/text/src/three/profiler.ts b/packages/text/src/three/profiler.ts index 013dc541..cf866e01 100644 --- a/packages/text/src/three/profiler.ts +++ b/packages/text/src/three/profiler.ts @@ -4,6 +4,7 @@ export type ThreeTextProfilePhase = | 'frame.prepare' | 'engine.update' | 'plan.apply' + | 'origins.index' | 'semantic.read' | 'transforms.sync'; diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index aa6bfc0e..fdf485be 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -8,6 +8,14 @@ import { setThreeTextProfiler, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; const fontUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); +const densityFontUrl = new URL( + '../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16-32.font.glb', + import.meta.url, +); +const amiriFontUrl = new URL( + '../../../../apps/benchmarks/fixtures/rendering/amiri-bitmap-16.font.glb', + import.meta.url, +); test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose through the scene graph', async () => { const registry = new FontRegistry(); @@ -25,6 +33,10 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr const group = new TextGroup({ renderOrder: 12 }); const container = new THREE.Object3D(); const label = new Text({ font, text: 'First frame' }); + let originIndexBuilds = 0; + setThreeTextProfiler((phase) => { + if (phase === 'origins.index') originIndexBuilds += 1; + }); container.add(label); group.add(container); scene.add(group); @@ -36,6 +48,7 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(group.textCount, 1); assert.equal(label.layout, undefined, 'rendering must not materialize layout readback'); assert.equal(group.error, undefined); + assert.equal(originIndexBuilds, 0, 'rendering must not index glyph origins until the presentation API needs them'); const firstDraws = group.children.filter((child) => child.isMesh); assert.ok(firstDraws.length > 0); assert.equal(firstDraws[0].geometry.instanceCount, 10, 'the GPU plan omits the non-rendering space glyph'); @@ -61,6 +74,7 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(group.children.filter((child) => child.isMesh)[0], firstDraws[0]); const origins = label.snapshotGlyphOrigins(); + assert.equal(originIndexBuilds, 1, 'the first presentation query builds the retained origin index once'); assert.equal(origins.layout, inspection); assert.deepEqual(origins.displayedX, origins.shapedX); assert.deepEqual(origins.displayedY, origins.shapedY); @@ -72,6 +86,7 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(presented.displayedX[0], origins.shapedX[0] + 3); label.clearGlyphOriginOverrides(); assert.deepEqual(label.snapshotGlyphOrigins().displayedX, origins.shapedX); + setThreeTextProfiler(undefined); group.renderOrder = 20; scene.updateMatrixWorld(); @@ -237,6 +252,133 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn runtime.dispose(); }); +test('Bitmap strike changes fully initialize a replacement indexed batch', async () => { + const registry = new FontRegistry(); + const shaper = await createRuntimeShaper({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const runtime = await createTextRuntime({ registry, shaper }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(densityFontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16, 32] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup(); + const label = new Text({ + font, + rasterPixelRatio: 2, + text: 'AB', + style: { fontSize: 8 }, + contentBox: { width: { mode: 'exact', size: 80 }, wrap: 'word' }, + }); + group.add(label); + scene.add(group); + scene.updateMatrixWorld(); + assert.equal(group.error, undefined); + + label.style = { ...label.style, fontSize: 16 }; + scene.updateMatrixWorld(); + assert.equal(group.error, undefined, 'crossing from the 16 ppem strike to 32 ppem must publish successfully'); + const draw = group.children.find((child) => child.isMesh); + assert.ok(draw); + const start = draw.userData.pmndrsTextRunStart; + const transforms = draw.geometry.getAttribute('_pmndrsText_15').array; + assert.deepEqual(Array.from(transforms.subarray(start, start + draw.geometry.instanceCount)), [1, 1]); + + label.contentBox = { ...label.contentBox, width: { mode: 'exact', size: 40 } }; + scene.updateMatrixWorld(); + assert.equal(group.error, undefined, 'width-only reflow must retain the initialized transform stream'); + + group.dispose(); + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + +test('Rust ellipsis reshapes only the narrowed unsafe line boundary', async () => { + const registry = new FontRegistry(); + const shaper = await createRuntimeShaper({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const runtime = await createTextRuntime({ registry, shaper }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(amiriFontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const text = 'مرحبا بالعالم'; + const textUtf16 = Uint16Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)); + const shapingRequest = { + textUtf16, + features: [], + runs: [ + { + font: font.font.handle, + textStart: 0, + textEnd: textUtf16.length, + direction: 'rtl', + script: 'Arab', + language: 'ar', + clusterLevel: 0, + flags: 0x40, + featureStart: 0, + featureCount: 0, + }, + ], + }; + const broad = ownedShape(shaper.shapeBatch(shapingRequest)); + const narrowed = ownedShape( + shaper.reshapeRanges({ + ...shapingRequest, + ranges: [ + { + run: 0, + itemStart: 0, + itemEnd: 3, + contextStart: 0, + contextEnd: 3, + flags: 0x43, + }, + ], + }), + ); + assert.notDeepEqual( + shapeSignature(broad, 0, 3), + shapeSignature(narrowed, 0, 3), + 'the fixture must fail if the retained whole-run shape is reused at the unsafe boundary', + ); + + const scene = new THREE.Scene(); + const label = new Text({ + font, + text, + style: { fontSize: 16 }, + contentBox: { + width: { mode: 'exact', size: 37 }, + maxLines: 1, + wrap: 'none', + overflow: 'ellipsis', + }, + }); + scene.add(label); + scene.updateMatrixWorld(); + assert.equal(label.error, undefined); + const inspection = label.inspectLayout(); + assert.ok(inspection); + assert.equal(inspection.lineTextEnds[0], 3, 'the fixed width must preserve the unsafe-boundary fixture'); + assert.equal(inspection.clusters.at(-1), 3, 'the ellipsis is anchored at the truncation boundary'); + assert.deepEqual( + shapeSignature(inspection, 0, 3), + shapeSignature(narrowed, 0, 3), + 'Rust positioning must consume the narrowed boundary shape, not the retained whole-run glyphs', + ); + + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + test('TextGroup atomically replaces child paragraphs without multiplying retained text capacity', async () => { const registry = new FontRegistry(); const shaper = await createRuntimeShaper({ @@ -280,3 +422,14 @@ test('TextGroup atomically replaces child paragraphs without multiplying retaine function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } + +function ownedShape(shape) { + return { glyphIds: [...shape.glyphIds], clusters: [...shape.clusters] }; +} + +function shapeSignature(shape, start, end) { + return [...shape.glyphIds].flatMap((glyphId, index) => { + const cluster = shape.clusters[index]; + return cluster >= start && cluster < end ? [[glyphId, cluster]] : []; + }); +} From e10e37d582d5b2d110b16933f5c176a05eecc04b Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 09:52:32 -0400 Subject: [PATCH 088/128] refactor(text): cost one dirty buffer at a time --- docs/log.md | 5 + docs/packages/text.md | 10 +- docs/planning/dirty-range-upload-research.md | 6 +- .../rust/shaper/src/engine/plan_packing.rs | 98 ++++++++++++++++++- 4 files changed, 112 insertions(+), 7 deletions(-) diff --git a/docs/log.md b/docs/log.md index fbcb9769..f384b2a6 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,11 @@ ## 2026-08-09 +- **Started per-physical-buffer dirty-range costing without changing publication** — Added a stride-specific Rust + coalescer with exact tests for divergent narrow/wide gap decisions, fragmentation and 75% full-live promotion, zero + stride, and overflow. Existing ordered/stable callers remain on the compatibility wrapper until the next atomic + checkpoint, so no upload or frame-time gain is claimed. + - **Moved ellipsis and its real boundary reshape into the Rust frame transaction** — Only truncated flow threads build a retained boundary arena; ordinary reflow retains zero boundary reshapes. Font-stack ellipsis selection, complete no-wrap overflow, narrowed final-tail context, spacing, stable glyph identity, positioning, semantic inspection, and diff --git a/docs/packages/text.md b/docs/packages/text.md index 12bdc1a9..8efff087 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:dcec3be38cc1cc058965b8e7593cedea1683c5594b4465a221100fe2cec724ce' +source_digest: 'sha256:52739ee05c6056275b8899dc371771186cc210a99e265198567c0a3aaefec2e6' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -190,7 +190,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T13:43:37Z' + at: '2026-08-09T13:51:48Z' --- # Package reference: `@pmndrs/text` @@ -1031,6 +1031,12 @@ the adjacent lazy-origin-index and Bitmap resource-selection fixes, changes opti 1,101,079 / 414,917 / 328,164 to 1,114,718 / 420,714 / 333,743 raw/gzip/Brotli bytes; that aggregate delta is not attributed to ellipsis alone. +Dirty-range refinement begins with one stride-specific Rust coalescing primitive. It costs gaps, backend-call penalty, +fragmentation, and full-live promotion for one physical buffer and rejects zero-stride or overflowing arithmetic. +Focused tests prove that identical record ranges make different decisions for 16-byte and 64-byte streams. Existing +ordered and stable publishers still call the program-wide compatibility wrapper at this checkpoint, so this is a +tested implementation seam rather than a claimed upload or frame-time improvement. + Live Paragraph Stress profiling also found two renderer-integration defects independent of shaping invalidation. The Three executor rebuilt a per-glyph origin lookup object graph after every plan application even though only presentation queries use it; the index is now lazy and invalidated by a new plan. In the observed 11,510-glyph MTSDF run, diff --git a/docs/planning/dirty-range-upload-research.md b/docs/planning/dirty-range-upload-research.md index 6f1a8ca1..4487ba33 100644 --- a/docs/planning/dirty-range-upload-research.md +++ b/docs/planning/dirty-range-upload-research.md @@ -37,7 +37,7 @@ sources: title: Three r185 legacy WebGL attribute uploads generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T16:40:00Z' + at: '2026-08-09T13:51:48Z' --- # Adaptive dirty-range uploads for retained text plans @@ -255,7 +255,9 @@ three raster techniques. Backend-specific constants are acceptable; backend-spec - Flatland's fixed thresholds have not been isolated against its full-upload control on this computer. - The current text capability values have not been swept against actual `writeBuffer`, `bufferSubData`, or PBO texture costs. -- Per-buffer coalescing has not been implemented or measured. +- The stride-specific Rust coalescing primitive is implemented with focused gap, fragmentation, full-live, and overflow + tests. Ordered-direct, stable-indirect physical storage, and the order buffer still use the compatibility path, so + per-buffer publication has not yet been implemented or measured. - Safari GC attributed to update-range objects has not been isolated from other known per-frame allocations. - Partial WebGL fallback PBO texture upload has not been proven through Three r185. diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs index 78cb49cf..3c261f2e 100644 --- a/packages/text/rust/shaper/src/engine/plan_packing.rs +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -229,14 +229,26 @@ pub fn coalesce_ranges( capability: &super::policy::CapabilitySet, live_records: u32, ) -> Result<(), PackingError> { - if ranges.is_empty() { - return Ok(()); - } let bytes_per_record = program.buffers.iter().try_fold(0_u32, |total, schema| { total .checked_add(u32::from(schema.stride)) .ok_or(PackingError::ArithmeticOverflow) })?; + coalesce_buffer_ranges(ranges, bytes_per_record, capability, live_records) +} + +pub fn coalesce_buffer_ranges( + ranges: &mut alloc::vec::Vec, + bytes_per_record: u32, + capability: &super::policy::CapabilitySet, + live_records: u32, +) -> Result<(), PackingError> { + if ranges.is_empty() { + return Ok(()); + } + if bytes_per_record == 0 { + return Err(PackingError::InvalidIdentity); + } let accepted_gap = capability .coalesce_gap_bytes .max(capability.range_call_penalty_bytes); @@ -305,3 +317,83 @@ fn lcm(left: u32, right: u32) -> Result { .and_then(|value| value.checked_mul(right)) .ok_or(PackingError::ArithmeticOverflow) } + +#[cfg(test)] +mod tests { + use alloc::vec; + + use super::{PackingError, RecordRange, coalesce_buffer_ranges}; + use crate::engine::policy::{CapabilitySet, CapabilitySetId}; + + fn capability() -> CapabilitySet { + CapabilitySet { + id: CapabilitySetId(1), + flags: 0, + max_buffer_bytes: u32::MAX, + update_alignment: 4, + coalesce_gap_bytes: 128, + range_call_penalty_bytes: 256, + max_buffers_per_draw: 16, + max_resources_per_draw: 16, + max_indirect_draws: 1, + fragmentation_budget: 8, + whole_buffer_threshold_basis_points: 7_500, + } + } + + #[test] + fn physical_buffer_stride_controls_gap_coalescing() { + let mut narrow = vec![ + RecordRange { start: 0, end: 1 }, + RecordRange { start: 10, end: 11 }, + ]; + let mut wide = narrow.clone(); + + coalesce_buffer_ranges(&mut narrow, 16, &capability(), 100).unwrap(); + coalesce_buffer_ranges(&mut wide, 64, &capability(), 100).unwrap(); + + assert_eq!(narrow, [RecordRange { start: 0, end: 11 }]); + assert_eq!( + wide, + [ + RecordRange { start: 0, end: 1 }, + RecordRange { start: 10, end: 11 }, + ] + ); + } + + #[test] + fn physical_buffer_cost_promotes_fragmented_and_expensive_updates() { + let mut fragmented = (0..9) + .map(|index| RecordRange { + start: index * 10, + end: index * 10 + 1, + }) + .collect(); + let mut expensive = vec![RecordRange { start: 0, end: 80 }]; + + coalesce_buffer_ranges(&mut fragmented, 16, &capability(), 100).unwrap(); + coalesce_buffer_ranges(&mut expensive, 16, &capability(), 100).unwrap(); + + assert_eq!(fragmented, [RecordRange { start: 0, end: 100 }]); + assert_eq!(expensive, [RecordRange { start: 0, end: 100 }]); + } + + #[test] + fn physical_buffer_cost_rejects_zero_stride_and_arithmetic_overflow() { + let mut range = vec![RecordRange { start: 0, end: 1 }]; + assert_eq!( + coalesce_buffer_ranges(&mut range, 0, &capability(), 1), + Err(PackingError::InvalidIdentity) + ); + + let mut overflow = vec![RecordRange { + start: 0, + end: u32::MAX, + }]; + assert_eq!( + coalesce_buffer_ranges(&mut overflow, 2, &capability(), u32::MAX), + Err(PackingError::ArithmeticOverflow) + ); + } +} From a4465679e9b08e0a4aa6da75ff8c79d47f22f316 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 09:58:03 -0400 Subject: [PATCH 089/128] perf(text): plan dirty ranges per buffer --- docs/log.md | 5 + docs/packages/text.md | 12 +- docs/planning/dirty-range-upload-research.md | 7 +- .../rust/shaper/src/engine/ordered_plan.rs | 98 ++++++++------- .../rust/shaper/src/engine/plan_packing.rs | 22 +--- .../rust/shaper/src/engine/stable_plan.rs | 115 ++++++++++-------- 6 files changed, 141 insertions(+), 118 deletions(-) diff --git a/docs/log.md b/docs/log.md index f384b2a6..561f9ce1 100644 --- a/docs/log.md +++ b/docs/log.md @@ -7,6 +7,11 @@ stride, and overflow. Existing ordered/stable callers remain on the compatibility wrapper until the next atomic checkpoint, so no upload or frame-time gain is claimed. +- **Moved ordered and stable physical writes onto per-buffer range plans** — Each compiler retains fixed reusable scratch + for the policy buffer ceiling, applies semantic dependency liveness before range selection, aligns by the concrete + stream stride, and packs the independently chosen spans. The order buffer and end-to-end timing remain open, so this + checkpoint claims correct ownership and bounded allocation rather than a speedup. + - **Moved ellipsis and its real boundary reshape into the Rust frame transaction** — Only truncated flow threads build a retained boundary arena; ordinary reflow retains zero boundary reshapes. Font-stack ellipsis selection, complete no-wrap overflow, narrowed final-tail context, spacing, stable glyph identity, positioning, semantic inspection, and diff --git a/docs/packages/text.md b/docs/packages/text.md index 8efff087..803afe11 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:52739ee05c6056275b8899dc371771186cc210a99e265198567c0a3aaefec2e6' +source_digest: 'sha256:04c42b4aa41023fc4f7945ffcd76ac40b28848db5e0f1686e8a88b6d4e754281' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -190,7 +190,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T13:51:48Z' + at: '2026-08-09T13:57:12Z' --- # Package reference: `@pmndrs/text` @@ -1033,9 +1033,11 @@ attributed to ellipsis alone. Dirty-range refinement begins with one stride-specific Rust coalescing primitive. It costs gaps, backend-call penalty, fragmentation, and full-live promotion for one physical buffer and rejects zero-stride or overflowing arithmetic. -Focused tests prove that identical record ranges make different decisions for 16-byte and 64-byte streams. Existing -ordered and stable publishers still call the program-wide compatibility wrapper at this checkpoint, so this is a -tested implementation seam rather than a claimed upload or frame-time improvement. +Focused tests prove that identical record ranges make different decisions for 16-byte and 64-byte streams. Ordered and +stable physical publishers now retain fixed per-buffer range scratch, derive liveness through exact semantic dependency +masks, align each stream independently, and pack only the selected physical buffer for its chosen spans. The stable +logical-order buffer is not migrated at this checkpoint, and no upload or frame-time improvement is claimed before +complete-path measurement. Live Paragraph Stress profiling also found two renderer-integration defects independent of shaping invalidation. The Three executor rebuilt a per-glyph origin lookup object graph after every plan application even though only presentation diff --git a/docs/planning/dirty-range-upload-research.md b/docs/planning/dirty-range-upload-research.md index 4487ba33..091d1da9 100644 --- a/docs/planning/dirty-range-upload-research.md +++ b/docs/planning/dirty-range-upload-research.md @@ -37,7 +37,7 @@ sources: title: Three r185 legacy WebGL attribute uploads generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T13:51:48Z' + at: '2026-08-09T13:57:12Z' --- # Adaptive dirty-range uploads for retained text plans @@ -256,8 +256,9 @@ three raster techniques. Backend-specific constants are acceptable; backend-spec - The current text capability values have not been swept against actual `writeBuffer`, `bufferSubData`, or PBO texture costs. - The stride-specific Rust coalescing primitive is implemented with focused gap, fragmentation, full-live, and overflow - tests. Ordered-direct, stable-indirect physical storage, and the order buffer still use the compatibility path, so - per-buffer publication has not yet been implemented or measured. + tests. Ordered-direct and stable-indirect physical storage now retain one reusable range vector per possible physical + buffer, select ranges through exact semantic dependency masks, align for that buffer's stride, and cost it separately. + The stable order buffer still bypasses this path, and end-to-end publication has not yet been measured. - Safari GC attributed to update-range objects has not been isolated from other known per-frame allocations. - Partial WebGL fallback PBO texture upload has not been proven through Three r185. diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index e4a65ccd..d8d32f54 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -14,8 +14,8 @@ use super::{ }, plan_packing::{ MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, - align_record_range, align_up, apply_writes, coalesce_ranges, execute_run, grown_capacity, - record_alignment, take_allocation, + align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, execute_run, grown_capacity, record_alignment, take_allocation, }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BufferSchema, CapabilitySetId, @@ -140,6 +140,7 @@ pub struct OrderedPlanCompiler { identity_epoch: u32, batch_cursors: Vec, changed_ranges: Vec, + buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], resources: Vec, plan_buffers: Vec, primitives: Vec, @@ -629,7 +630,6 @@ impl OrderedPlanCompiler { next_instances, checkpoint || new_or_resized, )?; - coalesce_ranges(&mut self.changed_ranges, program, capability, required)?; for (schema_index, schema) in program.buffers.iter().copied().enumerate() { let previous = prior .and_then(|batch| self.buffers.get(batch.buffer_start as usize + schema_index)) @@ -733,7 +733,6 @@ impl OrderedPlanCompiler { pending: PendingBatch, replace: bool, ) -> Result<(), OrderedPlanError> { - let record_alignment = record_alignment(program, capability.update_alignment)?; let next_instances = &self.pending_instances [range(pending.state.instance_start, pending.state.instance_count)?]; let prior_instances = match prior { @@ -743,10 +742,10 @@ impl OrderedPlanCompiler { .ok_or(OrderedPlanError::InvalidIdentity)?, None => &[], }; - for range_index in 0..self.changed_ranges.len() { - let changed = self.changed_ranges[range_index]; - let aligned = align_record_range(changed, record_alignment)?; - let count = aligned.end - aligned.start; + for ranges in &mut self.buffer_ranges { + ranges.clear(); + } + for changed in self.changed_ranges.iter().copied() { let active_buffers = active_buffers_for_range( policy, capability_set, @@ -756,11 +755,32 @@ impl OrderedPlanCompiler { changed, replace, )?; - let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; for (schema_index, schema) in program.buffers.iter().enumerate() { if active_buffers & (1 << schema_index) == 0 { continue; } + reserve(&mut self.buffer_ranges[schema_index], 1)?; + self.buffer_ranges[schema_index].push(align_record_range( + changed, + buffer_record_alignment(schema, capability.update_alignment), + )?); + } + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + coalesce_buffer_ranges( + &mut self.buffer_ranges[schema_index], + u32::from(schema.stride), + capability, + pending.state.instance_count, + )?; + } + for schema_index in 0..program.buffers.len() { + let schema = program.buffers[schema_index]; + for range_index in 0..self.buffer_ranges[schema_index].len() { + let aligned = self.buffer_ranges[schema_index][range_index]; + let count = aligned.end - aligned.start; + let active_buffers = 1 << schema_index; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; let byte_count = usize::try_from(count) .ok() .and_then(|value| value.checked_mul(schema.stride())) @@ -781,42 +801,38 @@ impl OrderedPlanCompiler { .copy_from_slice(&old_buffer.bytes[source_start..source_end]); } } - } - let mut slot = aligned.start; - while slot < aligned.end.min(pending.state.instance_count) { - if instance_unchanged(prior_instances, next_instances, slot, replace) { - slot += 1; - continue; - } - let input_start = next_instances[slot as usize].input_index; - let run_start = slot; - slot += 1; - while slot < aligned.end.min(pending.state.instance_count) - && !instance_unchanged(prior_instances, next_instances, slot, replace) - && next_instances[slot as usize].input_index == input_start + (slot - run_start) - { + let mut slot = aligned.start; + while slot < aligned.end.min(pending.state.instance_count) { + if instance_unchanged(prior_instances, next_instances, slot, replace) { + slot += 1; + continue; + } + let input_start = next_instances[slot as usize].input_index; + let run_start = slot; slot += 1; + while slot < aligned.end.min(pending.state.instance_count) + && !instance_unchanged(prior_instances, next_instances, slot, replace) + && next_instances[slot as usize].input_index + == input_start + (slot - run_start) + { + slot += 1; + } + execute_run( + policy, + capability_set, + program, + input, + input_start as usize, + slot - run_start, + run_start - aligned.start, + &mut self.payload, + &payload_starts, + count, + active_buffers, + )?; } - execute_run( - policy, - capability_set, - program, - input, - input_start as usize, - slot - run_start, - run_start - aligned.start, - &mut self.payload, - &payload_starts, - count, - active_buffers, - )?; - } - for (schema_index, schema) in program.buffers.iter().enumerate() { - if active_buffers & (1 << schema_index) == 0 { - continue; - } let buffer_id = pending.buffer_ids[schema_index]; let buffer_generation = pending.buffer_generations[schema_index]; let byte_length = count diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs index 3c261f2e..cf4523f8 100644 --- a/packages/text/rust/shaper/src/engine/plan_packing.rs +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -200,12 +200,14 @@ pub fn record_alignment( byte_alignment: u32, ) -> Result { program.buffers.iter().try_fold(1_u32, |records, schema| { - let stride = u32::from(schema.stride); - let divisor = gcd(byte_alignment, stride); - lcm(records, byte_alignment / divisor) + lcm(records, buffer_record_alignment(schema, byte_alignment)) }) } +pub fn buffer_record_alignment(schema: &super::policy::BufferSchema, byte_alignment: u32) -> u32 { + byte_alignment / gcd(byte_alignment, u32::from(schema.stride)) +} + pub fn align_up(value: u32, alignment: u32) -> Result { value .checked_add(alignment - 1) @@ -223,20 +225,6 @@ pub fn align_record_range(range: RecordRange, alignment: u32) -> Result, - program: &super::policy::ProgramDescriptor, - capability: &super::policy::CapabilitySet, - live_records: u32, -) -> Result<(), PackingError> { - let bytes_per_record = program.buffers.iter().try_fold(0_u32, |total, schema| { - total - .checked_add(u32::from(schema.stride)) - .ok_or(PackingError::ArithmeticOverflow) - })?; - coalesce_buffer_ranges(ranges, bytes_per_record, capability, live_records) -} - pub fn coalesce_buffer_ranges( ranges: &mut alloc::vec::Vec, bytes_per_record: u32, diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index ae0ccaaf..a5f87267 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -15,8 +15,8 @@ use super::{ }, plan_packing::{ MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, - align_record_range, align_up, apply_writes, coalesce_ranges, execute_run, grown_capacity, - record_alignment, take_allocation, + align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, execute_run, grown_capacity, record_alignment, take_allocation, }, policy::{ ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BUFFER_USAGE_COPY_DST, @@ -243,6 +243,7 @@ pub struct StablePlanCompiler { order_chunk_scratch: Vec, slot_writes: Vec, changed_ranges: Vec, + buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], identity_keys: Vec, identity_epochs: Vec, identity_epoch: u32, @@ -863,16 +864,10 @@ impl StablePlanCompiler { } } let required_slots = batch.slots.required_slots()?; - coalesce_ranges( - &mut self.changed_ranges, - program, - context.capability, - required_slots, - )?; - let record_alignment = record_alignment(program, context.capability.update_alignment)?; - for range_index in 0..self.changed_ranges.len() { - let changed = align_record_range(self.changed_ranges[range_index], record_alignment)?; - let count = changed.end - changed.start; + for ranges in &mut self.buffer_ranges { + ranges.clear(); + } + for changed in self.changed_ranges.iter().copied() { let active_buffers = stable_active_buffers( context.policy, context.capability_set, @@ -882,11 +877,32 @@ impl StablePlanCompiler { changed, replace, )?; - let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; for (schema_index, schema) in program.buffers.iter().enumerate() { if active_buffers & (1 << schema_index) == 0 { continue; } + reserve(&mut self.buffer_ranges[schema_index], 1)?; + self.buffer_ranges[schema_index].push(align_record_range( + changed, + buffer_record_alignment(schema, context.capability.update_alignment), + )?); + } + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + coalesce_buffer_ranges( + &mut self.buffer_ranges[schema_index], + u32::from(schema.stride), + context.capability, + required_slots, + )?; + } + for schema_index in 0..program.buffers.len() { + let schema = program.buffers[schema_index]; + for range_index in 0..self.buffer_ranges[schema_index].len() { + let changed = self.buffer_ranges[schema_index][range_index]; + let count = changed.end - changed.start; + let active_buffers = 1 << schema_index; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; let byte_count = count as usize * schema.stride(); let payload_start = self.payload.len(); reserve(&mut self.payload, byte_count)?; @@ -905,46 +921,41 @@ impl StablePlanCompiler { .ok_or(StablePlanError::InvalidIdentity)?; self.payload[payload_start..payload_start + byte_count].copy_from_slice(source); } - } - let mut write_index = self - .slot_writes - .partition_point(|write| write.slot < changed.start); - while write_index < self.slot_writes.len() - && self.slot_writes[write_index].slot < changed.end - { - if !replace && !self.slot_writes[write_index].changed { - write_index += 1; - continue; - } - let first = self.slot_writes[write_index]; - let mut end = write_index + 1; - while end < self.slot_writes.len() - && self.slot_writes[end].slot == first.slot + (end - write_index) as u32 - && self.slot_writes[end].input_index - == first.input_index + (end - write_index) as u32 - && (replace || self.slot_writes[end].changed) - && self.slot_writes[end].slot < changed.end + let mut write_index = self + .slot_writes + .partition_point(|write| write.slot < changed.start); + while write_index < self.slot_writes.len() + && self.slot_writes[write_index].slot < changed.end { - end += 1; - } - execute_run( - context.policy, - context.capability_set, - program, - context.input, - first.input_index as usize, - (end - write_index) as u32, - first.slot - changed.start, - &mut self.payload, - &payload_starts, - count, - active_buffers, - )?; - write_index = end; - } - for (schema_index, schema) in program.buffers.iter().enumerate() { - if active_buffers & (1 << schema_index) == 0 { - continue; + if !replace && !self.slot_writes[write_index].changed { + write_index += 1; + continue; + } + let first = self.slot_writes[write_index]; + let mut end = write_index + 1; + while end < self.slot_writes.len() + && self.slot_writes[end].slot == first.slot + (end - write_index) as u32 + && self.slot_writes[end].input_index + == first.input_index + (end - write_index) as u32 + && (replace || self.slot_writes[end].changed) + && self.slot_writes[end].slot < changed.end + { + end += 1; + } + execute_run( + context.policy, + context.capability_set, + program, + context.input, + first.input_index as usize, + (end - write_index) as u32, + first.slot - changed.start, + &mut self.payload, + &payload_starts, + count, + active_buffers, + )?; + write_index = end; } reserve(&mut self.patches, 1)?; self.patches.push(PatchRecord { From 01cb67e4411325fa5c64940a9c5f863cc0f8a6b1 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 10:13:21 -0400 Subject: [PATCH 090/128] perf(text): coalesce stable order uploads --- docs/log.md | 6 + docs/packages/text.md | 13 +- docs/planning/dirty-range-upload-research.md | 10 +- .../rust/shaper/src/engine/ordered_plan.rs | 91 +++++---- .../rust/shaper/src/engine/plan_packing.rs | 67 ++++++- .../rust/shaper/src/engine/stable_plan.rs | 178 ++++++++++++------ 6 files changed, 267 insertions(+), 98 deletions(-) diff --git a/docs/log.md b/docs/log.md index 561f9ce1..bd613e84 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Completed adaptive Rust planning for physical and stable-order buffers without accepting repeated packing** — The + first per-buffer execution prototype regressed cold Bitmap/MTSDF/Slug by roughly 1.2/2.2/2.4 ms. Grouping identical + selected ranges back into one active-buffer job closes that regression while preserving independent costing and + committed gap bytes. Canonical before/after results are mixed and standard resize remains one unchanged-size patch, so + sparse browser upload evidence is still required before claiming a win. + - **Started per-physical-buffer dirty-range costing without changing publication** — Added a stride-specific Rust coalescer with exact tests for divergent narrow/wide gap decisions, fragmentation and 75% full-live promotion, zero stride, and overflow. Existing ordered/stable callers remain on the compatibility wrapper until the next atomic diff --git a/docs/packages/text.md b/docs/packages/text.md index 803afe11..13b78c9e 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements public font loading, shaping, paragraph measurement, sta resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:04c42b4aa41023fc4f7945ffcd76ac40b28848db5e0f1686e8a88b6d4e754281' +source_digest: 'sha256:31242b7ffc89ac76c6f4bf65ca6dc7a6b9339ea9e7ee2ed0fd371439878d4a02' tags: [package, public-api, typescript, contracts] sources: - id: manifest @@ -190,7 +190,7 @@ sources: title: Unicode analysis implementation generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T13:57:12Z' + at: '2026-08-09T14:10:06Z' --- # Package reference: `@pmndrs/text` @@ -1036,8 +1036,13 @@ fragmentation, and full-live promotion for one physical buffer and rejects zero- Focused tests prove that identical record ranges make different decisions for 16-byte and 64-byte streams. Ordered and stable physical publishers now retain fixed per-buffer range scratch, derive liveness through exact semantic dependency masks, align each stream independently, and pack only the selected physical buffer for its chosen spans. The stable -logical-order buffer is not migrated at this checkpoint, and no upload or frame-time improvement is claimed before -complete-path measurement. +logical-order buffer uses the same cost model and copies committed gap bytes before publishing widened spans. Identical +range shapes regroup into one active-buffer packing job: the first ungrouped implementation regressed cold +Bitmap/MTSDF/Slug by roughly 1.2/2.2/2.4 ms through repeated program execution, while the corrected canonical run +measures 15.208/15.940/16.114 ms cold and 3.946/4.370/5.284 ms resize against detached `bbd87d3e` baseline ranges of +15.061–15.867 ms cold and 4.101–5.027 ms resize. These mixed results establish that the regression is closed, not a +speedup. The standard resize lanes still publish one unchanged-size patch; sparse-distribution and browser upload +evidence remain required. Live Paragraph Stress profiling also found two renderer-integration defects independent of shaping invalidation. The Three executor rebuilt a per-glyph origin lookup object graph after every plan application even though only presentation diff --git a/docs/planning/dirty-range-upload-research.md b/docs/planning/dirty-range-upload-research.md index 091d1da9..ae73d682 100644 --- a/docs/planning/dirty-range-upload-research.md +++ b/docs/planning/dirty-range-upload-research.md @@ -37,7 +37,7 @@ sources: title: Three r185 legacy WebGL attribute uploads generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T13:57:12Z' + at: '2026-08-09T14:10:06Z' --- # Adaptive dirty-range uploads for retained text plans @@ -258,7 +258,13 @@ three raster techniques. Backend-specific constants are acceptable; backend-spec - The stride-specific Rust coalescing primitive is implemented with focused gap, fragmentation, full-live, and overflow tests. Ordered-direct and stable-indirect physical storage now retain one reusable range vector per possible physical buffer, select ranges through exact semantic dependency masks, align for that buffer's stride, and cost it separately. - The stable order buffer still bypasses this path, and end-to-end publication has not yet been measured. + Stable 64-entry order chunks now use the same cost model and preserve committed bytes inside widened gaps. Identical + per-buffer range shapes regroup into one multi-buffer packing job; the ungrouped prototype repeated cold policy + execution per stream and regressed Bitmap/MTSDF/Slug by roughly 1.2/2.2/2.4 ms before this correction. After grouping, + one canonical run measures 15.208/15.940/16.114 ms cold and 3.946/4.370/5.284 ms resize, versus two detached + `bbd87d3e` baselines of 15.061–15.867 ms cold and 4.101–5.027 ms resize across the three techniques. Standard resize + remains one patch with unchanged bytes and cannot prove the sparse-update benefit; the distribution matrix and browser + upload evidence remain open. - Safari GC attributed to update-range objects has not been isolated from other known per-frame allocations. - Partial WebGL fallback PBO texture upload has not been proven through Three r185. diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index d8d32f54..df8e1bef 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -13,9 +13,10 @@ use super::{ validate_input, }, plan_packing::{ - MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, - align_record_range, align_up, apply_writes, buffer_record_alignment, - coalesce_buffer_ranges, execute_run, grown_capacity, record_alignment, take_allocation, + MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RangeJob, + RecordRange, align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, record_alignment, + take_allocation, }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BufferSchema, CapabilitySetId, @@ -141,6 +142,7 @@ pub struct OrderedPlanCompiler { batch_cursors: Vec, changed_ranges: Vec, buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], + range_jobs: Vec, resources: Vec, plan_buffers: Vec, primitives: Vec, @@ -774,13 +776,22 @@ impl OrderedPlanCompiler { pending.state.instance_count, )?; } - for schema_index in 0..program.buffers.len() { - let schema = program.buffers[schema_index]; - for range_index in 0..self.buffer_ranges[schema_index].len() { - let aligned = self.buffer_ranges[schema_index][range_index]; - let count = aligned.end - aligned.start; - let active_buffers = 1 << schema_index; - let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + collect_range_jobs( + &mut self.range_jobs, + &self.buffer_ranges, + program.buffers.len(), + )?; + for job_index in 0..self.range_jobs.len() { + let RangeJob { + range: aligned, + active_buffers, + } = self.range_jobs[job_index]; + let count = aligned.end - aligned.start; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } let byte_count = usize::try_from(count) .ok() .and_then(|value| value.checked_mul(schema.stride())) @@ -801,38 +812,42 @@ impl OrderedPlanCompiler { .copy_from_slice(&old_buffer.bytes[source_start..source_end]); } } + } - let mut slot = aligned.start; - while slot < aligned.end.min(pending.state.instance_count) { - if instance_unchanged(prior_instances, next_instances, slot, replace) { - slot += 1; - continue; - } - let input_start = next_instances[slot as usize].input_index; - let run_start = slot; + let mut slot = aligned.start; + while slot < aligned.end.min(pending.state.instance_count) { + if instance_unchanged(prior_instances, next_instances, slot, replace) { slot += 1; - while slot < aligned.end.min(pending.state.instance_count) - && !instance_unchanged(prior_instances, next_instances, slot, replace) - && next_instances[slot as usize].input_index - == input_start + (slot - run_start) - { - slot += 1; - } - execute_run( - policy, - capability_set, - program, - input, - input_start as usize, - slot - run_start, - run_start - aligned.start, - &mut self.payload, - &payload_starts, - count, - active_buffers, - )?; + continue; } + let input_start = next_instances[slot as usize].input_index; + let run_start = slot; + slot += 1; + while slot < aligned.end.min(pending.state.instance_count) + && !instance_unchanged(prior_instances, next_instances, slot, replace) + && next_instances[slot as usize].input_index == input_start + (slot - run_start) + { + slot += 1; + } + execute_run( + policy, + capability_set, + program, + input, + input_start as usize, + slot - run_start, + run_start - aligned.start, + &mut self.payload, + &payload_starts, + count, + active_buffers, + )?; + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } let buffer_id = pending.buffer_ids[schema_index]; let buffer_generation = pending.buffer_generations[schema_index]; let byte_length = count diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs index cf4523f8..d4b139cd 100644 --- a/packages/text/rust/shaper/src/engine/plan_packing.rs +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -28,6 +28,12 @@ pub struct RecordRange { pub end: u32, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RangeJob { + pub range: RecordRange, + pub active_buffers: u32, +} + pub struct PhysicalBufferState { pub id: u32, pub generation: u32, @@ -293,6 +299,34 @@ pub fn coalesce_buffer_ranges( Ok(()) } +pub fn collect_range_jobs( + jobs: &mut alloc::vec::Vec, + buffer_ranges: &[alloc::vec::Vec; MAX_PHYSICAL_BUFFERS], + buffer_count: usize, +) -> Result<(), PackingError> { + jobs.clear(); + let range_count = buffer_ranges[..buffer_count] + .iter() + .try_fold(0_usize, |total, ranges| total.checked_add(ranges.len())) + .ok_or(PackingError::ArithmeticOverflow)?; + jobs.try_reserve(range_count) + .map_err(|_| PackingError::AllocationFailed)?; + for (buffer_index, ranges) in buffer_ranges[..buffer_count].iter().enumerate() { + for range in ranges.iter().copied() { + if let Some(job) = jobs.iter_mut().find(|job| job.range == range) { + job.active_buffers |= 1 << buffer_index; + } else { + jobs.push(RangeJob { + range, + active_buffers: 1 << buffer_index, + }); + } + } + } + jobs.sort_unstable_by_key(|job| (job.range.start, job.range.end)); + Ok(()) +} + fn gcd(mut left: u32, mut right: u32) -> u32 { while right != 0 { (left, right) = (right, left % right); @@ -310,7 +344,10 @@ fn lcm(left: u32, right: u32) -> Result { mod tests { use alloc::vec; - use super::{PackingError, RecordRange, coalesce_buffer_ranges}; + use super::{ + MAX_PHYSICAL_BUFFERS, PackingError, RangeJob, RecordRange, coalesce_buffer_ranges, + collect_range_jobs, + }; use crate::engine::policy::{CapabilitySet, CapabilitySetId}; fn capability() -> CapabilitySet { @@ -384,4 +421,32 @@ mod tests { Err(PackingError::ArithmeticOverflow) ); } + + #[test] + fn identical_per_buffer_ranges_share_one_packing_job() { + let mut ranges: [alloc::vec::Vec; MAX_PHYSICAL_BUFFERS] = + core::array::from_fn(|_| alloc::vec::Vec::new()); + ranges[0].extend([ + RecordRange { start: 0, end: 10 }, + RecordRange { start: 20, end: 21 }, + ]); + ranges[1].push(RecordRange { start: 0, end: 10 }); + let mut jobs = alloc::vec::Vec::new(); + + collect_range_jobs(&mut jobs, &ranges, 2).unwrap(); + + assert_eq!( + jobs, + [ + RangeJob { + range: RecordRange { start: 0, end: 10 }, + active_buffers: 0b11, + }, + RangeJob { + range: RecordRange { start: 20, end: 21 }, + active_buffers: 0b01, + }, + ] + ); + } } diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index a5f87267..7302dc9c 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -14,9 +14,10 @@ use super::{ validate_input, }, plan_packing::{ - MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RecordRange, - align_record_range, align_up, apply_writes, buffer_record_alignment, - coalesce_buffer_ranges, execute_run, grown_capacity, record_alignment, take_allocation, + MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RangeJob, + RecordRange, align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, record_alignment, + take_allocation, }, policy::{ ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BUFFER_USAGE_COPY_DST, @@ -244,6 +245,7 @@ pub struct StablePlanCompiler { slot_writes: Vec, changed_ranges: Vec, buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], + range_jobs: Vec, identity_keys: Vec, identity_epochs: Vec, identity_epoch: u32, @@ -896,13 +898,22 @@ impl StablePlanCompiler { required_slots, )?; } - for schema_index in 0..program.buffers.len() { - let schema = program.buffers[schema_index]; - for range_index in 0..self.buffer_ranges[schema_index].len() { - let changed = self.buffer_ranges[schema_index][range_index]; - let count = changed.end - changed.start; - let active_buffers = 1 << schema_index; - let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + collect_range_jobs( + &mut self.range_jobs, + &self.buffer_ranges, + program.buffers.len(), + )?; + for job_index in 0..self.range_jobs.len() { + let RangeJob { + range: changed, + active_buffers, + } = self.range_jobs[job_index]; + let count = changed.end - changed.start; + let mut payload_starts = [0_usize; MAX_PHYSICAL_BUFFERS]; + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; + } let byte_count = count as usize * schema.stride(); let payload_start = self.payload.len(); reserve(&mut self.payload, byte_count)?; @@ -921,41 +932,46 @@ impl StablePlanCompiler { .ok_or(StablePlanError::InvalidIdentity)?; self.payload[payload_start..payload_start + byte_count].copy_from_slice(source); } - let mut write_index = self - .slot_writes - .partition_point(|write| write.slot < changed.start); - while write_index < self.slot_writes.len() - && self.slot_writes[write_index].slot < changed.end + } + let mut write_index = self + .slot_writes + .partition_point(|write| write.slot < changed.start); + while write_index < self.slot_writes.len() + && self.slot_writes[write_index].slot < changed.end + { + if !replace && !self.slot_writes[write_index].changed { + write_index += 1; + continue; + } + let first = self.slot_writes[write_index]; + let mut end = write_index + 1; + while end < self.slot_writes.len() + && self.slot_writes[end].slot == first.slot + (end - write_index) as u32 + && self.slot_writes[end].input_index + == first.input_index + (end - write_index) as u32 + && (replace || self.slot_writes[end].changed) + && self.slot_writes[end].slot < changed.end { - if !replace && !self.slot_writes[write_index].changed { - write_index += 1; - continue; - } - let first = self.slot_writes[write_index]; - let mut end = write_index + 1; - while end < self.slot_writes.len() - && self.slot_writes[end].slot == first.slot + (end - write_index) as u32 - && self.slot_writes[end].input_index - == first.input_index + (end - write_index) as u32 - && (replace || self.slot_writes[end].changed) - && self.slot_writes[end].slot < changed.end - { - end += 1; - } - execute_run( - context.policy, - context.capability_set, - program, - context.input, - first.input_index as usize, - (end - write_index) as u32, - first.slot - changed.start, - &mut self.payload, - &payload_starts, - count, - active_buffers, - )?; - write_index = end; + end += 1; + } + execute_run( + context.policy, + context.capability_set, + program, + context.input, + first.input_index as usize, + (end - write_index) as u32, + first.slot - changed.start, + &mut self.payload, + &payload_starts, + count, + active_buffers, + )?; + write_index = end; + } + for (schema_index, schema) in program.buffers.iter().enumerate() { + if active_buffers & (1 << schema_index) == 0 { + continue; } reserve(&mut self.patches, 1)?; self.patches.push(PatchRecord { @@ -1053,23 +1069,79 @@ impl StablePlanCompiler { .copied() .filter(|chunk| replace || chunk.changed()), ); - for chunk_index in 0..self.order_chunk_scratch.len() { - let chunk = self.order_chunk_scratch[chunk_index]; - let entries = self.batches[batch_index].order.entries(chunk)?; + self.changed_ranges.clear(); + reserve(&mut self.changed_ranges, self.order_chunk_scratch.len())?; + let order_alignment = + buffer_record_alignment(&order_schema(), context.capability.update_alignment); + for chunk in self.order_chunk_scratch.iter().copied() { + self.changed_ranges.push(align_record_range( + RecordRange { + start: chunk.record_start(), + end: chunk + .record_start() + .checked_add(u32::from(chunk.len)) + .ok_or(StablePlanError::ArithmeticOverflow)?, + }, + order_alignment, + )?); + } + self.changed_ranges + .sort_unstable_by_key(|range| range.start); + coalesce_buffer_ranges( + &mut self.changed_ranges, + 4, + context.capability, + order_capacity, + )?; + for range_index in 0..self.changed_ranges.len() { + let range = self.changed_ranges[range_index]; let payload_start = self.payload.len(); - let byte_length = usize::from(chunk.len) * 4; + let byte_length = usize::try_from(range.end - range.start) + .ok() + .and_then(|count| count.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?; reserve(&mut self.payload, byte_length)?; - for entry in entries { - self.payload - .extend_from_slice(&entry.record_slot.to_le_bytes()); + self.payload.resize(payload_start + byte_length, 0); + if !replace { + let source_start = usize::try_from(range.start) + .ok() + .and_then(|start| start.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let source_end = source_start + .checked_add(byte_length) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let source = self.batches[batch_index] + .order_buffer + .as_ref() + .and_then(|buffer| buffer.bytes.get(source_start..source_end)) + .ok_or(StablePlanError::InvalidIdentity)?; + self.payload[payload_start..payload_start + byte_length].copy_from_slice(source); + } + for chunk_index in 0..self.order_chunk_scratch.len() { + let chunk = self.order_chunk_scratch[chunk_index]; + let chunk_start = chunk.record_start(); + if chunk_start < range.start || chunk_start >= range.end { + continue; + } + let destination = payload_start + + usize::try_from(chunk_start - range.start) + .ok() + .and_then(|start| start.checked_mul(4)) + .ok_or(StablePlanError::ArithmeticOverflow)?; + let entries = self.batches[batch_index].order.entries(chunk)?; + for (entry_index, entry) in entries.iter().enumerate() { + let start = destination + entry_index * 4; + self.payload[start..start + 4] + .copy_from_slice(&entry.record_slot.to_le_bytes()); + } } reserve(&mut self.patches, 1)?; self.patches.push(PatchRecord { opcode: PATCH_WRITE, buffer_id: id, buffer_generation: generation, - destination_offset: chunk - .record_start() + destination_offset: range + .start .checked_mul(4) .ok_or(StablePlanError::ArithmeticOverflow)?, byte_length: u32::try_from(byte_length) From d9b46443b9c6748563b19d770b1d6aa8ba1a5d5b Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 10:53:05 -0400 Subject: [PATCH 091/128] docs(text): plan paragraph-scoped queries --- docs/log.md | 10 + docs/planning/index.md | 2 + docs/planning/paragraph-query-preparation.md | 340 +++++++++++++++++++ docs/planning/rust-layout-engine.md | 10 +- docs/roadmap/roadmap.md | 10 +- 5 files changed, 368 insertions(+), 4 deletions(-) create mode 100644 docs/planning/paragraph-query-preparation.md diff --git a/docs/log.md b/docs/log.md index bd613e84..3f33e1d5 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-09 +- **Specified paragraph-scoped synchronous preparation without triple buffering** — Current `measureLayout()` either + returns committed cache or drives a complete session update and plan. The reviewed follow-up design retains one + speculative session transaction with paragraph-keyed pending states, linear identity reservation, explicit + prepare/adopt/leave-committed modes, inactive-slot copied query results, host lease retention, and new-paragraph + candidate ownership. Sequential paragraph queries extend the same transaction and the next frame adopts that exact + work before global plan compilation. Roadmap items 11.17 and 11.18 queue the query layer and promised realtime + publishing set as independent `feat/*` follow-up stacks after the Rust/Three cutover merges; neither is a hidden + prerequisite for consuming the cutover. Factoring preparation from plan commit is cohesive but not a safe flag-only + change. + - **Completed adaptive Rust planning for physical and stable-order buffers without accepting repeated packing** — The first per-buffer execution prototype regressed cold Bitmap/MTSDF/Slug by roughly 1.2/2.2/2.4 ms. Grouping identical selected ranges back into one active-buffer job closes that regression while preserving independent costing and diff --git a/docs/planning/index.md b/docs/planning/index.md index 5542cbb8..44467dde 100644 --- a/docs/planning/index.md +++ b/docs/planning/index.md @@ -41,6 +41,8 @@ - [Shaping compilation and execution research](shaping-compilation-research.md) — closed-corpus baking, semantic bytecode, per-font CPU/Wasm specialization, and WebGPU execution research. - [Language-aware font units and physical bitmap strikes](language-and-strike-bundles.md) — coverage-first language delivery, CJK units, DPR selection, and independent strike residency. - [Responsive editorial flow and mixed-raster composition](editorial-flow-layout.md) — post-v1 exclusion regions, responsive columns, and a bitmap/MTSDF/Slug benchmark. +- [Paragraph-scoped preparation and synchronous layout queries](paragraph-query-preparation.md) — one-paragraph + prepare/query, retained candidate adoption, and why it needs no third full buffer. ## Rendering analysis diff --git a/docs/planning/paragraph-query-preparation.md b/docs/planning/paragraph-query-preparation.md new file mode 100644 index 00000000..4e24aee6 --- /dev/null +++ b/docs/planning/paragraph-query-preparation.md @@ -0,0 +1,340 @@ +--- +type: Engineering Research +title: Paragraph-scoped preparation and synchronous layout queries +description: Defines how one paragraph can be shaped and laid out on demand, measured without compiling a render plan, and adopted by the next frame transaction without a third full buffer. +status: draft +tags: + - layout + - shaping + - measurement + - wasm + - performance + - transactions +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-09T14:25:40Z' +sources: + - id: rust-engine-state + resource: ../../packages/text/rust/shaper/src/engine/state.rs + title: Retained Rust paragraph and session transaction state + - id: rust-transport + resource: ../../packages/text/rust/shaper/src/engine/transport.rs + title: Wasm request and A/B publication transport + - id: wasm-entry + resource: ../../packages/text/rust/shaper/src/wasm.rs + title: Wasm frame entry point and publication transaction + - id: three-text + resource: ../../packages/text/src/three/text.ts + title: Three Text measurement and synchronization path + - id: rust-layout-engine + resource: rust-layout-engine.md + title: Rust text engine and retained render-plan ABI + - id: pretext + resource: https://github.com/chenglou/pretext + title: Pretext prepared paragraph and streaming line-layout API + - id: parley + resource: https://docs.rs/parley/latest/parley/ + title: Parley retained shaping, re-linebreaking, and reusable layout scratch + - id: harfbuzz-buffer + resource: https://harfbuzz.github.io/harfbuzz-hb-buffer.html + title: HarfBuzz paragraph-context shaping contract + - id: canvas-measure + resource: https://html.spec.whatwg.org/multipage/canvas.html#dom-context-2d-measuretext-dev + title: HTML Canvas measureText semantics +--- + +# Paragraph-scoped preparation and synchronous layout queries + +## Conclusion + +The engine should support a synchronous, paragraph-scoped prepare/query transaction whose result can be adopted by the +next full frame. It does **not** require a third Wasm result buffer or a third complete paragraph arena. + +The narrow design retains one speculative transaction per engine session. Each synchronous query targets one complete +paragraph; later queries may append other paragraph-keyed candidates to the same transaction and its single linear +identity reservation. A query runs only the target's invalid paragraph stages through positioning, returns the requested +measurement or inspection records, and leaves render-plan gathering, policy execution, packing, publication, GPU +patches, and retirement untouched. The next full update either adopts the exact candidate set or invalidates it and +reuses its allocations. + +This is a bounded follow-up on its own `feat/*` stack after the Rust/Three cutover merges, not an unreviewed change +inside that stack. The remaining realtime publishing features likewise start from the independently consumable merged +cutover; neither follow-up is a hidden merge prerequisite for it. +The current update function deliberately couples paragraph preparation, session-global identity allocation, plan +preparation, A/B publication, and commit. Splitting those phases safely requires a new transaction state and ABI tests; +a request flag that only suppresses output would leave committed layout and displayed render state on different +revisions. + +## What exists now + +Three's `Text.measureLayout()` asks its owning batch to synchronize. When properties are pending, the measurement mask +rides the same full frame update, which is correct and avoids shaping twice. That update nevertheless prepares the +session render plan and applies it before returning the measurement. When the batch is already committed, a cache miss +sends an otherwise empty update, and Rust emits measurement records for every paragraph in the session. + +Rust already retains the expensive products per paragraph: + +- text, resolved styles, Unicode analysis, bidi runs, shaping runs, shaped glyphs, clusters, geometry, flow layout, and + positioned glyphs each have committed and pending storage; +- unchanged stages are reused according to invalidation rather than rebuilt; +- session-global stable glyph IDs and content revisions have committed and pending counters; and +- a full update commits paragraph state, plan state, identities, and engine/plan revisions atomically only after the + inactive result slot has been staged successfully. + +The current architecture therefore has the right reusable products but the wrong transaction granularity for a +query-before-render workflow. + +## Three different kinds of buffering + +The word “buffer” currently covers three independent ownership problems: + +| Storage | Current purpose | Required change | +| ---------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------- | +| Paragraph committed/pending arenas | Compute a candidate without corrupting the last committed paragraph | Retain pending paragraph candidates across synchronous queries and adopt or abort them on the next frame | +| Wasm A/B result arenas | Keep one immutable publication readable while the next result is written | No third slot; return a copied synchronous query result without changing render-plan authority | +| Renderer/GPU staging | Keep submitted bytes alive until the renderer retires them | No query involvement; only a full frame publishes patches or retirements | + +Triple buffering the publication or GPU data would not solve candidate reuse. The useful “double buffer” is the +paragraph's committed/pending compute state, which already exists. + +```mermaid +flowchart LR + subgraph Compute["Rust paragraph compute state"] + C["Committed paragraph stages"] + P["Pending candidate stages"] + D["Small speculative-transaction descriptor"] + C -->|"prepare one complete paragraph"| P + D --- P + end + + subgraph Result["Wasm result lifetime"] + A["Plan slot A"] + B["Plan slot B"] + Q["Copied query response\nfrom inactive slot"] + end + + subgraph Render["Renderer-owned lifetime"] + G["Retained GPU buffers"] + S["Submission staging / fences"] + G --- S + end + + P -->|"query records only"| Q + P -->|"adopt during full frame"| PC["Policy gather + plan compiler"] + PC -->|"stage immutable plan"| A + PC -. "next generation" .-> B + A -->|"patches and draws"| G + B -->|"patches and draws"| G + Q -.-> X["Host-owned semantic copy only"] +``` + +The query branch terminates in host-owned semantic data: it does not become plan input or renderer state. + +## Proposed prepare/query transaction + +### Query request + +Add a paragraph-query operation to the existing frame ABI and keep one Wasm export. This is a versioned +`ENGINE_UPDATE_ABI_VERSION` change, not an interpretation of currently reserved paragraph-mutation bits. The request +contains: + +- session and paragraph identity; +- the expected committed engine revision; +- the target paragraph's pending text/style/geometry mutations; +- the demanded semantic mask and explicit output ceiling; and +- no renderer policy work, compositing change, paragraph reorder, removal, or publication acknowledgment. + +Each call prepares one complete paragraph. The session holds at most one speculative transaction, but that transaction +may contain multiple paragraph-keyed entries prepared by sequential queries. A repeated query for an already-pending +paragraph either proves the same input fingerprint and reuses it or aborts the speculative transaction before rebuilding +it; it never creates an independent identity-allocation branch. + +### Query execution + +Rust prepares the target paragraph through the least stage that satisfies the query. A multiline size query needs flow +layout and positioned line spans but does not need policy gathering or physical-record packing. A future natural-shape +query can stop earlier when its contract does not require line placement. + +Shaping remains paragraph-contextual. HarfBuzz recommends passing the complete paragraph while selecting a run range so +joining and combining behavior can see surrounding text. “One paragraph” is therefore the bounded reusable unit; an +arbitrary substring is not independently cacheable without an explicit narrowed-context correctness contract. + +The query returns: + +- a small copied semantic response for synchronous host ownership; and +- an opaque candidate token identifying the prepared set, committed base revision, speculative generation, and exact + normalized-input fingerprint. + +The operation does not advance engine revision, plan revision, renderer publication generation, stable-pool retirement, +or **committed** identity counters. Measurement reaches positioning, so the speculative transaction reserves glyph-ID +and content-revision ranges and records their high-water marks. Adoption seeds all other frame allocation after those +reservations; abort releases them without advancing committed counters. It does not make the candidate visible to +rendering. + +### Candidate state and paragraph modes + +The speculative transaction adds a bounded descriptor, not another complete buffer. It records: + +- committed base revision and monotonically changing speculative generation; +- ordered paragraph IDs and exact normalized mutation fingerprints; +- reserved glyph-ID and content-revision high-water marks; +- each paragraph's `positioned_changed` value and satisfied semantic mask; +- whether an entry updates a committed paragraph or owns a not-yet-committed `ParagraphState`; and +- the host font-stack and material leases that keep candidate resources alive. + +A full frame assigns every paragraph exactly one mode: + +- **prepare** — clear/rebuild pending stages from this frame's mutations; +- **adopt** — preserve a validated candidate's pending stages and carry its `positioned_changed` value into gathering; + or +- **leave committed** — require that no pending stage flag is set and use the committed state. + +At commit, a prepared flag may exist only for a paragraph that this frame prepared or validly adopted. Adopt plus an +ordinary mutation/removal for the same paragraph is invalid. This explicit mode prevents today's unconditional +`ParagraphState::prepare` call from erasing the candidate and prevents `commit_all` from publishing unvalidated pending +state. + +For a paragraph that has never committed, the candidate owns its `ParagraphState` without inserting it into the live +session or leaving `lifecycle_prepared` set across calls. Adoption moves that state into the ordinary paragraph lifecycle +inside the final transaction. This is the new paragraph's first state, not a third copy. Failure or supersession returns +its allocations to the session spare/high-water pool. + +### Query response + +The response uses the inactive Wasm result arena with a distinct query-result flag. It follows the existing failure +header discipline: it does not change the active render-plan slot or renderer publication generation, never grows memory +inside the query call, and reports required capacity for a reserve/retry. A cold retry may recompute after abort; the warm +path is the performance contract. + +The host copies the demanded semantic result immediately. It is valid only until the next call into that engine session +and is never retained as a Wasm view. A query does not acknowledge a renderer publication. Host-side stack/material +leases remain held until the candidate is adopted, aborted, superseded, or disposed. + +### Frame adoption + +The host associates the opaque token with the exact public property revisions it measured. If those properties remain +current, the next full update names the token and adopted paragraph IDs instead of resending and recomputing them. Rust +verifies the session, committed base revision, live speculative generation, paragraph set, and repeated input +fingerprint, then includes the reserved identities and positioned records in the ordinary session-wide gather and plan +transaction. All other frame allocations begin after the candidate's retained high-water marks, regardless of paragraph +iteration order, so identities cannot collide. + +Successful A/B plan publication commits the candidate set with every other frame mutation. A changed property, +intervening session commit, invalid token, failed frame, or explicit cancellation aborts the speculative transaction, +increments its generation even when the committed engine revision did not change, and leaves displayed paragraphs +unchanged. This prevents a host token from naming pending arenas destroyed by a failed frame. The allocations remain +available for the next preparation. + +```mermaid +sequenceDiagram + participant App + participant Three + participant Wasm as Rust/Wasm engine + participant Candidate as Speculative transaction + participant Plan as Plan compiler + A/B transport + participant GPU as Three/GPU state + + App->>Three: measureLayout(paragraph A) + Three->>Wasm: query A + pending properties + Wasm->>Candidate: prepare A; reserve identities + Candidate-->>Wasm: metrics + token + high-water marks + Wasm-->>Three: inactive-slot query result + Three-->>App: copied frozen measurement + + opt another paragraph is measured before the frame + App->>Three: measureLayout(paragraph B) + Three->>Wasm: query B + same transaction token + Wasm->>Candidate: append B after reserved high-water marks + Wasm-->>Three: copied B result + updated token + Three-->>App: copied frozen measurement + end + + Three->>Wasm: full update; adopt token + other mutations + Wasm->>Candidate: verify base, generation, set, fingerprints, leases + alt candidate is exact and plan staging succeeds + Candidate->>Plan: adopted positioned records + Plan->>Plan: gather, pack, stage inactive A/B slot + Plan-->>Wasm: publish and commit atomically + Wasm-->>Three: render-plan delta + Three->>GPU: apply patches and draws + else stale token, changed input, or frame failure + Wasm->>Candidate: abort; increment speculative generation + Wasm-->>Three: committed layout and plan remain visible + end +``` + +## Why one speculative transaction + +Paragraph data is locally retained, but glyph stable IDs and content revisions are allocated from session-global +cursors. Multiple independent speculative transactions would reserve competing ranges, require rebasing prepared glyphs +at commit, or introduce a second identity-allocation scheme. One transaction keeps a single linear reservation while +still allowing `measureLayout()` calls for A, then B, to retain both paragraph results for the final frame. + +This keeps allocation deterministic and bounds existing paragraphs to their committed plus already-present pending +high-water storage. The descriptor grows only by compact paragraph metadata. If evidence later requires concurrent +transactions, identity assignment should move to final adoption rather than multiplying complete buffers. + +```mermaid +stateDiagram-v2 + [*] --> Idle + Idle --> Prepared: query one committed or new paragraph + Prepared --> Prepared: append another paragraph query + Prepared --> Adopting: full frame names exact token + Prepared --> Aborting: property change / supersession / disposal + Adopting --> Committed: plan staged and published + Adopting --> Aborting: validation or publication failure + Aborting --> Idle: clear pending flags; generation++ + Committed --> Idle: release candidate leases and descriptor +``` + +## Public behavior + +The desired semantic split is: + +- `measureLayout()` with no pending change returns the frozen committed cache without crossing; +- `measureLayout()` with a pending change prepares only that paragraph, returns its pending measurement synchronously, + and adds it to the session's speculative token; +- sequential measurements of other pending paragraphs extend that same transaction instead of invalidating earlier + work; +- the next render synchronization adopts the token if every adopted property revision is still current; and +- inspection remains an explicit larger copy and never becomes render-plan input. + +This resembles Canvas `measureText()` in being a synchronous query that does not render or mutate a scene, while the +opaque retained candidate adds reuse Canvas does not expose. Pretext independently demonstrates the useful separation +between paragraph preparation and cursor-driven line layout; Parley demonstrates retaining shaped layout while +re-breaking and re-aligning it and reusing scratch allocations. + +## Performance and allocation gates + +The feature is admitted only if a benchmark with many retained paragraphs and one pending target proves all of the +following against the current full-frame measurement path: + +- the query shapes/layouts exactly one paragraph and executes zero policy programs; +- it serializes zero render resources, buffers, patches, primitives, draws, or retirements; +- adopting the candidate performs zero repeated analysis, shaping, flow layout, or positioning for that paragraph; +- settled queries allocate nothing after high-water reservation; +- the query plus later adopting frame is faster than the current measurement-carrying full update plus an unchanged + successor frame; and +- abort, supersession, output growth, and failed final publication preserve the last committed layout and plan bytes. + +The benchmark must report query latency and combined query-plus-frame latency for both one pending paragraph and N +sequentially measured pending paragraphs. The N-paragraph case must prove one speculative transaction preserves earlier +prepared results and unique identities. A faster single query that makes the eventual frame or common multi-measurement +workflow slower is not a win. + +## Implementation boundary + +Land this from the merged Rust/Three cutover on its own stack. The work is cohesive but not “easy” because it must: + +1. factor paragraph preparation from session plan preparation; +2. add one retained speculative-transaction descriptor and explicit prepare/adopt/leave-committed modes; +3. reserve speculative identity ranges without committing or colliding with other frame mutations; +4. keep not-yet-committed paragraphs and host resource leases alive without opening live session lifecycle state; +5. return a targeted inactive-slot response without masquerading as a render-plan publication; +6. teach Three to associate one token with exact paragraph property revisions; and +7. prove single- and multi-paragraph reuse, invalidation, failure atomicity, bounded memory, and combined latency in + compiled Wasm. + +No third output slot, third GPU buffer, host callback, second shaping implementation, or layout arrays in the command +buffer are part of this design. The required candidate descriptor and, for a new paragraph only, its first owned +`ParagraphState`, are explicit transaction state rather than hidden complete buffers. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 5a228e0b..248ec1b8 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -13,7 +13,7 @@ tags: - abi generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T13:43:37Z' + at: '2026-08-09T14:25:40Z' sources: - id: layout-benchmark resource: ../../packages/text/scripts/benchmark-paragraph-layout.mts @@ -66,6 +66,9 @@ sources: - id: pretext resource: https://github.com/chenglou/pretext title: Pretext incremental per-line text layout + - id: paragraph-query-preparation + resource: paragraph-query-preparation.md + title: Paragraph-scoped preparation and synchronous layout queries - id: webrender resource: https://firefox-source-docs.mozilla.org/gfx/RenderingOverview.html title: Firefox rendering overview and display lists @@ -771,6 +774,11 @@ flags, and shaped origins, including glyphs such as spaces that deliberately pro zero on ordinary rendering updates. Caret, selection, hit testing, accessibility, and diagnostics must each prove a bounded record shape before admission. +The current semantic query measures committed state or rides a complete pending frame. A separate +[paragraph-scoped prepare/query design](paragraph-query-preparation.md) records the bounded follow-up for synchronously +measuring one pending paragraph, retaining its prepared result, and adopting it into the next full frame without +compiling a render plan during the query or adding a third full buffer. + A policy may request a renderer augmentation without moving semantic layout into the plan. The first-party Three policy writes each renderable glyph's session-global stable ID to one compact `u32` stream beside the technique's existing origin stream. Three uses that directed identifier only to implement optional presentation motion over retained GPU diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index 66ebe33b..a56cf968 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -22,10 +22,13 @@ sources: - id: 'engine-integration-plan' resource: '../planning/engine-integration-boundary.md' title: 'Renderer-neutral extraction plan' + - id: 'paragraph-query-preparation' + resource: '../planning/paragraph-query-preparation.md' + title: 'Paragraph-scoped preparation and synchronous layout queries' generated: - by: anthropic-claude/opus-5 - at: '2026-08-07T19:05:00Z' + by: openai-codex/gpt-5.6 + at: '2026-08-09T14:25:40Z' --- # Canonical implementation roadmap @@ -154,7 +157,8 @@ These rows replace the former separate backlog. Each is intended to become one f | 11.14 | ⬜ | Add the professional typography the editorial showcase requires: `wordSpacing`, first-line indent, paragraph space before/after, and justification controls covering minimum/maximum word-space ratio, letter-space expansion, and last-line policy. | L | 11.12–11.13 | | 11.15 | ⬜ | Settle Three material authority, so applications supply their own `NodeMaterial` and gain lighting, shadows, and depth-composited effects without implementing a raster program. Resolve the open edges in the [material authority concept](../planning/three-material-authority.md) first; it is a recorded proposal, not an accepted design. | M | 11.6 | | 11.16 | 🟡 | Replace duplicate TypeScript shaping, layout, packing, and dirty-plan work with one retained Rust/Wasm frame transaction, validated renderer policy, and incremental render plan; land the Rust, policy/plan, and Three adapter PRs as one coordinated stack after exact Bitmap/MSDF/Slug, benchmark-app, size, and browser parity. | XL | 11.6 | -| 11.17 | ⬜ | Complete the Rust engine's realtime publishing set over that proven path: spacing, decorations, interaction geometry, horizontal and vertical writing, one-call exclusions and sequential regions, bounded CJK tailoring, and optional color-emoji fallback, excluding every explicitly cut unbounded solver or second authored text stream. | XL | 11.16 | +| 11.17 | ⬜ | Add paragraph-scoped synchronous prepare/query and candidate adoption: measure one pending paragraph per call without compiling a render plan, retain one session transaction with linear identity reservation, and reuse its paragraph-keyed results in the next full frame without a third full buffer. | L | 11.16 | +| 11.18 | ⬜ | Complete the Rust engine's realtime publishing set over that proven path: spacing, decorations, interaction geometry, horizontal and vertical writing, one-call exclusions and sequential regions, bounded CJK tailoring, and optional color-emoji fallback, excluding every explicitly cut unbounded solver or second authored text stream. | XL | 11.16 | ## Milestone 0 — accept contracts and versions From 80096e91051e4ba1d91a472a1a6da7d392014303 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 13:40:50 -0400 Subject: [PATCH 092/128] refactor(text): remove legacy layout path --- .github/workflows/ci.yml | 38 + .../noto-sans-cjk-contract-bitmap-16.font.glb | Bin 0 -> 2897572 bytes .../results/cjk-universality-chromium149.json | 101 - .../paragraph-bidi-policy-chromium149.json | 62 - .../results/paragraph-layout-chromium149.json | 59 - .../paragraph-measurement-chromium149.json | 59 - .../shaping-conformance-chromium149.json | 76 - .../scripts/check-bake-fixtures.mts | 4 +- .../check-paragraph-contract-fixtures.mts | 100 + .../generate-paragraph-bidi-contract.mts | 212 -- .../generate-paragraph-cjk-contract.mts | 146 -- .../generate-paragraph-conformance-font.mts | 55 + .../scripts/measure-package-sizes.mts | 230 +- apps/benchmarks/scripts/run-headless.mts | 6 +- apps/benchmarks/scripts/test.mts | 3 +- apps/benchmarks/scripts/verify-v1-bitmap.mts | 50 - apps/benchmarks/size-entries/text-shaper.ts | 1 - apps/benchmarks/size-entries/three-runtime.ts | 4 + .../benchmarks/src/benchmark/fixtures.test.ts | 143 -- .../src/benchmark/package-size-budgets.ts | 104 +- .../src/benchmark/package-sizes.test.ts | 141 +- .../benchmark/paragraph-contract-corpus.ts | 7 + apps/benchmarks/src/benchmark/scenarios.ts | 146 +- .../src/benchmark/shaping-fixture.ts | 149 -- .../targets/conformance/cjk-universality.ts | 213 -- .../targets/conformance/direct-runtime.ts | 654 ------ .../benchmark/targets/conformance/index.ts | 4 +- .../conformance/paragraph-contracts.ts | 399 ++++ .../src/benchmark/targets/registry.ts | 6 +- .../src/benchmark/uikit-layout-fixture.ts | 32 +- .../src/generated/package-sizes.json | 322 ++- apps/benchmarks/src/v1-async-proof.ts | 77 - apps/benchmarks/v1-async.html | 10 - .../vitexec/paragraph-stress-timing.probe.ts | 11 +- docs/engineering/code-style.md | 4 +- docs/packages/benchmarks.md | 8 +- docs/packages/text.md | 1198 ++-------- docs/planning/core-api.md | 1181 ++-------- docs/planning/decision-register.md | 11 +- docs/planning/rust-layout-engine.md | 11 +- docs/planning/three-api.md | 1252 ++-------- packages/text/package.json | 11 +- packages/text/rust/shaper/src/abi_contract.rs | 290 +-- .../rust/shaper/src/engine/flow_geometry.rs | 2 +- .../rust/shaper/src/engine/layout_query.rs | 200 +- .../rust/shaper/src/engine/positioning.rs | 98 +- .../rust/shaper/src/engine/semantic_wire.rs | 61 +- packages/text/rust/shaper/src/engine/state.rs | 245 +- packages/text/rust/shaper/src/lib.rs | 295 --- packages/text/rust/shaper/src/wasm.rs | 89 - packages/text/rust/shaper/src/wire.rs | 446 +--- .../scripts/benchmark-paragraph-layout.mts | 102 +- .../scripts/support/engine-kernel-fixture.mts | 10 +- .../support/paragraph-benchmark-fixture.mts | 20 +- packages/text/src/formatted-text.ts | 5 +- .../text/src/generated/text-shaper-abi.ts | 117 - packages/text/src/index.ts | 69 +- .../text-preparation-worker-protocol.ts | 110 - packages/text/src/loader.ts | 33 - .../text/src/paragraph-batch-attachment.ts | 220 -- packages/text/src/paragraph-batch.ts | 1630 ------------- packages/text/src/paragraph.ts | 2047 ----------------- packages/text/src/r3f.ts | 3 +- packages/text/src/shaper.ts | 549 +---- packages/text/src/text-preparation-worker.ts | 67 - packages/text/src/text-properties.ts | 49 + packages/text/src/text-runtime.ts | 552 +---- packages/text/src/three.ts | 3 +- packages/text/src/three/engine-runtime.ts | 11 +- packages/text/src/three/font-loader.ts | 9 +- packages/text/src/three/text.ts | 9 +- packages/text/src/typegpu.ts | 643 ------ .../text/tests/fuzz/paragraph-policy.test.mjs | 280 --- .../text/tests/fuzz/shaper-request.test.mjs | 184 -- .../tests/integration/bidi-analysis.test.mjs | 61 - .../integration/cjk-universality.test.mjs | 299 --- .../empty-paragraph-features.test.mjs | 65 - .../paragraph-bidi-policy.test.mjs | 372 --- .../paragraph-measurement.test.mjs | 377 --- .../integration/shaper-registration.test.mjs | 374 +-- .../integration/text-runtime-v1.test.mjs | 385 ---- .../tests/integration/text-spans.test.mjs | 567 ----- .../integration/three-engine-runtime.test.mjs | 2 +- .../tests/integration/three-shader.test.mjs | 5 +- .../text/tests/integration/three-v1.test.mjs | 76 +- .../integration/uikit-layout-fixture.test.mjs | 107 - packages/text/tests/types/public-api.test.ts | 77 - .../text/tests/types/text-runtime-api.test.ts | 54 +- .../text/tests/types/typegpu-v1-api.test.ts | 73 - pnpm-lock.yaml | 30 - 90 files changed, 2419 insertions(+), 16253 deletions(-) create mode 100644 apps/benchmarks/fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb delete mode 100644 apps/benchmarks/fixtures/results/cjk-universality-chromium149.json delete mode 100644 apps/benchmarks/fixtures/results/paragraph-bidi-policy-chromium149.json delete mode 100644 apps/benchmarks/fixtures/results/paragraph-layout-chromium149.json delete mode 100644 apps/benchmarks/fixtures/results/paragraph-measurement-chromium149.json delete mode 100644 apps/benchmarks/fixtures/results/shaping-conformance-chromium149.json create mode 100644 apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts delete mode 100644 apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts delete mode 100644 apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts create mode 100644 apps/benchmarks/scripts/generate-paragraph-conformance-font.mts delete mode 100644 apps/benchmarks/size-entries/text-shaper.ts create mode 100644 apps/benchmarks/size-entries/three-runtime.ts create mode 100644 apps/benchmarks/src/benchmark/paragraph-contract-corpus.ts delete mode 100644 apps/benchmarks/src/benchmark/shaping-fixture.ts delete mode 100644 apps/benchmarks/src/benchmark/targets/conformance/cjk-universality.ts delete mode 100644 apps/benchmarks/src/benchmark/targets/conformance/direct-runtime.ts create mode 100644 apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts delete mode 100644 apps/benchmarks/src/v1-async-proof.ts delete mode 100644 apps/benchmarks/v1-async.html delete mode 100644 packages/text/src/internal/text-preparation-worker-protocol.ts delete mode 100644 packages/text/src/paragraph-batch-attachment.ts delete mode 100644 packages/text/src/paragraph-batch.ts delete mode 100644 packages/text/src/paragraph.ts delete mode 100644 packages/text/src/text-preparation-worker.ts create mode 100644 packages/text/src/text-properties.ts delete mode 100644 packages/text/src/typegpu.ts delete mode 100644 packages/text/tests/fuzz/paragraph-policy.test.mjs delete mode 100644 packages/text/tests/fuzz/shaper-request.test.mjs delete mode 100644 packages/text/tests/integration/bidi-analysis.test.mjs delete mode 100644 packages/text/tests/integration/cjk-universality.test.mjs delete mode 100644 packages/text/tests/integration/empty-paragraph-features.test.mjs delete mode 100644 packages/text/tests/integration/paragraph-bidi-policy.test.mjs delete mode 100644 packages/text/tests/integration/paragraph-measurement.test.mjs delete mode 100644 packages/text/tests/integration/text-runtime-v1.test.mjs delete mode 100644 packages/text/tests/integration/text-spans.test.mjs delete mode 100644 packages/text/tests/integration/uikit-layout-fixture.test.mjs delete mode 100644 packages/text/tests/types/typegpu-v1-api.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4e06095..76254e3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,12 +8,50 @@ on: permissions: contents: read + # GitHub models issue/PR comments as pull-request metadata. This permits the + # size reporter to update its PR comment; it cannot write repository content. + pull-requests: write concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: + size: + name: Package size report + if: github.event_name == 'pull_request' + runs-on: ubuntu-24.04 + timeout-minutes: 20 + env: + CI: true + + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Install canonical toolchain + uses: jdx/mise-action@9e7f7633ff6f6d6048a9418a68d48f288f50eb14 # v4.2.3 + with: + version: 2026.7.13 + cache: true + + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + + - name: Compare package delivery sizes with the PR base + uses: andresz1/size-limit-action@94bc357df29c36c8f8d50ea497c3e225c3c95d1d # v1.8.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + build_script: build + skip_step: install + package_manager: pnpm + # The action runs this command in both revisions. The inline adapter + # accepts the base branch's older report, derives the same core total, + # and emits Size Limit's stable [{ name, size }] protocol. + script: >- + node apps/benchmarks/scripts/measure-package-sizes.mts | + node -e "let input='';process.stdin.on('data',chunk=>input+=chunk).on('end',()=>{const report=JSON.parse(input);const entries=report.entries.filter(entry=>entry.status==='measured');const byId=new Map(entries.map(entry=>[entry.id,entry]));if(!byId.has('renderer-neutral-core-total')){const js=byId.get('browser-core');const wasm=byId.get('text-shaper-wasm');if(js&&wasm)byId.set('renderer-neutral-core-total',{id:'renderer-neutral-core-total',format:'aggregate',gzipBytes:js.gzipBytes+wasm.gzipBytes,brotliBytes:js.brotliBytes+wasm.brotliBytes})}const tracked=/^(browser-core|text-shaper-wasm|renderer-neutral-core-total|three-runtime-js|three-renderer-total|font-(inter|icons)-|delivery-three-)/;const rows=[...byId.values()].filter(entry=>tracked.test(entry.id)).flatMap(entry=>entry.format==='javascript'?[{name:entry.id+' (brotli)',size:entry.brotliBytes}]:entry.format==='aggregate'?[{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]:[{name:entry.id+' (raw)',size:entry.rawBytes},{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]);process.stdout.write(JSON.stringify(rows))})" + check: name: Check runs-on: ubuntu-24.04 diff --git a/apps/benchmarks/fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb b/apps/benchmarks/fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb new file mode 100644 index 0000000000000000000000000000000000000000..fbcc7d023a03017e59d03322006578e1579fc9d4 GIT binary patch literal 2897572 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 vwo|7hkmDimwq?YTI|;! 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

;t24N+vf?-$BpdPFqrsk;y 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%0DUNr~MRca2R9P@t; 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 X?03{Oz9RN_8XI4ggSJ+$iT&Sm8()`h_Sr=; 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 J_fr@oe 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

eS?R9H=9-o6Xb^vCBb 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 literal 0 HcmV?d00001 diff --git a/apps/benchmarks/fixtures/results/cjk-universality-chromium149.json b/apps/benchmarks/fixtures/results/cjk-universality-chromium149.json deleted file mode 100644 index 2a4ff0e2..00000000 --- a/apps/benchmarks/fixtures/results/cjk-universality-chromium149.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "targetId": "cjk-universality", - "scenarioId": "cjk-universality", - "status": "passed", - "validation": "3/3 exact CJK corpus + horizontal paragraph outputs", - "measurements": [ - { - "sample": 0, - "durationMs": 0.800000011920929, - "outputBytes": 10622, - "hash": "a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68", - "metrics": { - "sourceUtf16Units": 208, - "corpusCaseCount": 13, - "corpusGlyphCount": 64, - "paragraphCaseCount": 4, - "layoutCount": 12, - "directShapeBoundaryCrossings": 1, - "paragraphShapeBoundaryCrossings": 4, - "reshapeBoundaryCrossings": 0, - "planCount": 8, - "retainedFontBytes": 1539372, - "wasmMemoryBytes": 4587520, - "sourceFontBytes": 16467736, - "artifactBytes": 1540480, - "shapingPayloadRawBytes": 1539372, - "shapingPayloadGzipBytes": 654925, - "shapingPayloadBrotliBytes": 514547, - "coldBakeMs": 574.5, - "coldRegistrationMs": 95.30000001192093, - "coldShaperInitializationMs": 3.0999999940395355 - } - }, - { - "sample": 1, - "durationMs": 0.699999988079071, - "outputBytes": 10622, - "hash": "a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68", - "metrics": { - "sourceUtf16Units": 208, - "corpusCaseCount": 13, - "corpusGlyphCount": 64, - "paragraphCaseCount": 4, - "layoutCount": 12, - "directShapeBoundaryCrossings": 1, - "paragraphShapeBoundaryCrossings": 4, - "reshapeBoundaryCrossings": 0, - "planCount": 8, - "retainedFontBytes": 1539372, - "wasmMemoryBytes": 4587520, - "sourceFontBytes": 16467736, - "artifactBytes": 1540480, - "shapingPayloadRawBytes": 1539372, - "shapingPayloadGzipBytes": 654925, - "shapingPayloadBrotliBytes": 514547, - "coldBakeMs": 574.5, - "coldRegistrationMs": 95.30000001192093, - "coldShaperInitializationMs": 3.0999999940395355 - } - }, - { - "sample": 2, - "durationMs": 0.5999999940395355, - "outputBytes": 10622, - "hash": "a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68", - "metrics": { - "sourceUtf16Units": 208, - "corpusCaseCount": 13, - "corpusGlyphCount": 64, - "paragraphCaseCount": 4, - "layoutCount": 12, - "directShapeBoundaryCrossings": 1, - "paragraphShapeBoundaryCrossings": 4, - "reshapeBoundaryCrossings": 0, - "planCount": 8, - "retainedFontBytes": 1539372, - "wasmMemoryBytes": 4587520, - "sourceFontBytes": 16467736, - "artifactBytes": 1540480, - "shapingPayloadRawBytes": 1539372, - "shapingPayloadGzipBytes": 654925, - "shapingPayloadBrotliBytes": 514547, - "coldBakeMs": 574.5, - "coldRegistrationMs": 95.30000001192093, - "coldShaperInitializationMs": 3.0999999940395355 - } - } - ], - "medianMs": 0.699999988079071, - "p95Ms": 0.800000011920929, - "minMs": 0.5999999940395355, - "maxMs": 0.800000011920929, - "outputBytes": 10622, - "completedAt": "2026-07-25T14:09:40.674Z", - "environment": { - "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36", - "hardwareConcurrency": 10, - "webgpu": true, - "crossOriginIsolated": false - } -} diff --git a/apps/benchmarks/fixtures/results/paragraph-bidi-policy-chromium149.json b/apps/benchmarks/fixtures/results/paragraph-bidi-policy-chromium149.json deleted file mode 100644 index 3312f62a..00000000 --- a/apps/benchmarks/fixtures/results/paragraph-bidi-policy-chromium149.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "targetId": "paragraph-bidi-policy", - "scenarioId": "paragraph-bidi-policy", - "status": "passed", - "validation": "3/3 exact bidi/policy outputs · current-uikit-shaped flow", - "measurements": [ - { - "sample": 0, - "durationMs": 0.20000000298023224, - "outputBytes": 8098, - "hash": "8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c", - "metrics": { - "bidiLayoutCount": 2, - "policyLayoutCount": 9, - "uikitMeasurementCount": 25, - "uikitLayoutCount": 1, - "shapeBoundaryCrossings": 4, - "reshapeBoundaryCrossings": 5 - } - }, - { - "sample": 1, - "durationMs": 0.3999999910593033, - "outputBytes": 8098, - "hash": "8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c", - "metrics": { - "bidiLayoutCount": 2, - "policyLayoutCount": 9, - "uikitMeasurementCount": 25, - "uikitLayoutCount": 1, - "shapeBoundaryCrossings": 4, - "reshapeBoundaryCrossings": 5 - } - }, - { - "sample": 2, - "durationMs": 0.20000000298023224, - "outputBytes": 8098, - "hash": "8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c", - "metrics": { - "bidiLayoutCount": 2, - "policyLayoutCount": 9, - "uikitMeasurementCount": 25, - "uikitLayoutCount": 1, - "shapeBoundaryCrossings": 4, - "reshapeBoundaryCrossings": 5 - } - } - ], - "medianMs": 0.20000000298023224, - "p95Ms": 0.3999999910593033, - "minMs": 0.20000000298023224, - "maxMs": 0.3999999910593033, - "outputBytes": 8098, - "completedAt": "2026-07-25T09:51:33.700Z", - "environment": { - "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36", - "hardwareConcurrency": 10, - "webgpu": true, - "crossOriginIsolated": false - } -} diff --git a/apps/benchmarks/fixtures/results/paragraph-layout-chromium149.json b/apps/benchmarks/fixtures/results/paragraph-layout-chromium149.json deleted file mode 100644 index 9030987d..00000000 --- a/apps/benchmarks/fixtures/results/paragraph-layout-chromium149.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "targetId": "paragraph-layout-engine", - "scenarioId": "paragraph-layout", - "status": "passed", - "validation": "3/3 exact positioned outputs · 1 reshape batch/changed width", - "measurements": [ - { - "sample": 0, - "durationMs": 0.10000000894069672, - "outputBytes": 3786, - "hash": "bb15bbcc:4f111a3f:e8c0e9d5", - "metrics": { - "batchedBoundaryLayouts": 2, - "glyphCount": 165, - "layoutCount": 3, - "reshapeBoundaryCrossings": 2, - "shapeBoundaryCrossings": 1 - } - }, - { - "sample": 1, - "durationMs": 0.29999999701976776, - "outputBytes": 3786, - "hash": "bb15bbcc:4f111a3f:e8c0e9d5", - "metrics": { - "batchedBoundaryLayouts": 2, - "glyphCount": 165, - "layoutCount": 3, - "reshapeBoundaryCrossings": 2, - "shapeBoundaryCrossings": 1 - } - }, - { - "sample": 2, - "durationMs": 0.09999999403953552, - "outputBytes": 3786, - "hash": "bb15bbcc:4f111a3f:e8c0e9d5", - "metrics": { - "batchedBoundaryLayouts": 2, - "glyphCount": 165, - "layoutCount": 3, - "reshapeBoundaryCrossings": 2, - "shapeBoundaryCrossings": 1 - } - } - ], - "medianMs": 0.10000000894069672, - "p95Ms": 0.29999999701976776, - "minMs": 0.09999999403953552, - "maxMs": 0.29999999701976776, - "outputBytes": 3786, - "completedAt": "2026-07-25T08:38:25.186Z", - "environment": { - "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36", - "hardwareConcurrency": 10, - "webgpu": true, - "crossOriginIsolated": false - } -} diff --git a/apps/benchmarks/fixtures/results/paragraph-measurement-chromium149.json b/apps/benchmarks/fixtures/results/paragraph-measurement-chromium149.json deleted file mode 100644 index 11f458b8..00000000 --- a/apps/benchmarks/fixtures/results/paragraph-measurement-chromium149.json +++ /dev/null @@ -1,59 +0,0 @@ -{ - "targetId": "paragraph-engine", - "scenarioId": "paragraph-measurement", - "status": "passed", - "validation": "3/3 exact paragraph outputs · 0 Wasm reflow calls/sample", - "measurements": [ - { - "sample": 0, - "durationMs": 0, - "outputBytes": 168, - "hash": "79874b9d", - "metrics": { - "measurementCount": 3, - "positionedGlyphBytes": 0, - "reflowBoundaryCrossings": 0, - "reshapeBoundaryCrossings": 0, - "shapeBoundaryCrossings": 1 - } - }, - { - "sample": 1, - "durationMs": 0, - "outputBytes": 168, - "hash": "79874b9d", - "metrics": { - "measurementCount": 3, - "positionedGlyphBytes": 0, - "reflowBoundaryCrossings": 0, - "reshapeBoundaryCrossings": 0, - "shapeBoundaryCrossings": 1 - } - }, - { - "sample": 2, - "durationMs": 0, - "outputBytes": 168, - "hash": "79874b9d", - "metrics": { - "measurementCount": 3, - "positionedGlyphBytes": 0, - "reflowBoundaryCrossings": 0, - "reshapeBoundaryCrossings": 0, - "shapeBoundaryCrossings": 1 - } - } - ], - "medianMs": 0, - "p95Ms": 0, - "minMs": 0, - "maxMs": 0, - "outputBytes": 168, - "completedAt": "2026-07-25T08:11:13.819Z", - "environment": { - "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36", - "hardwareConcurrency": 10, - "webgpu": true, - "crossOriginIsolated": false - } -} diff --git a/apps/benchmarks/fixtures/results/shaping-conformance-chromium149.json b/apps/benchmarks/fixtures/results/shaping-conformance-chromium149.json deleted file mode 100644 index 3372bff2..00000000 --- a/apps/benchmarks/fixtures/results/shaping-conformance-chromium149.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "schemaVersion": 0, - "targetId": "harfrust-shaper", - "scenarioId": "shaping-conformance", - "status": "passed", - "validation": "3/3 exact corpus outputs · 1 Wasm call/sample", - "controls": { - "dpr": 1, - "samples": 3, - "warmup": 1 - }, - "measurements": [ - { - "sample": 0, - "durationMs": 0.20000000298023224, - "outputBytes": 2412, - "hash": "dc30c21c", - "metrics": { - "boundaryCrossings": 1, - "coldStartMs": 2.5999999940395355, - "shapeCallMs": 0.09999999403953552, - "goldenCases": 8, - "glyphCount": 97, - "planCount": 3, - "retainedFontBytes": 171056, - "wasmMemoryBytes": 1638400 - } - }, - { - "sample": 1, - "durationMs": 0.19999998807907104, - "outputBytes": 2412, - "hash": "dc30c21c", - "metrics": { - "boundaryCrossings": 1, - "coldStartMs": 2.5999999940395355, - "shapeCallMs": 0.09999999403953552, - "goldenCases": 8, - "glyphCount": 97, - "planCount": 3, - "retainedFontBytes": 171056, - "wasmMemoryBytes": 1638400 - } - }, - { - "sample": 2, - "durationMs": 0.10000000894069672, - "outputBytes": 2412, - "hash": "dc30c21c", - "metrics": { - "boundaryCrossings": 1, - "coldStartMs": 2.5999999940395355, - "shapeCallMs": 0.10000000894069672, - "goldenCases": 8, - "glyphCount": 97, - "planCount": 3, - "retainedFontBytes": 171056, - "wasmMemoryBytes": 1638400 - } - } - ], - "summary": { - "medianMs": 0.19999998807907104, - "p95Ms": 0.20000000298023224, - "minMs": 0.10000000894069672, - "maxMs": 0.20000000298023224, - "outputBytes": 2412 - }, - "capturedAt": "2026-07-25T07:32:50.493Z", - "environment": { - "browser": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/149.0.7827.55 Safari/537.36", - "hardwareConcurrency": 10, - "webgpu": true, - "crossOriginIsolated": false - } -} diff --git a/apps/benchmarks/scripts/check-bake-fixtures.mts b/apps/benchmarks/scripts/check-bake-fixtures.mts index ecfc8c90..744b8434 100644 --- a/apps/benchmarks/scripts/check-bake-fixtures.mts +++ b/apps/benchmarks/scripts/check-bake-fixtures.mts @@ -14,13 +14,13 @@ const fixtureChecks = [ 'generate-showcase-raster-fixtures.mts', 'generate-mtsdf-render-fixture.mts', 'generate-slug-render-fixture.mts', - 'generate-paragraph-bidi-contract.mts', - 'generate-paragraph-cjk-contract.mts', + 'generate-paragraph-conformance-font.mts', ] as const; export async function checkBakeFixtures(): Promise { await buildRuntimePackages(); for (const script of fixtureChecks) await runNodeScript(`scripts/${script}`, ['--check']); + await runNodeScript('scripts/check-paragraph-contract-fixtures.mts'); await runNodeScript('scripts/provision-harfbuzz.mts', ['--check']); await runNodeScript('scripts/generate-japanese-showcase-subset.mts', ['--check']); } diff --git a/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts b/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts new file mode 100644 index 00000000..884091a8 --- /dev/null +++ b/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts @@ -0,0 +1,100 @@ +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; + +import { FontRegistry } from '@pmndrs/text'; + +const arguments_ = process.argv.slice(2); +if (arguments_.length !== 0) throw new Error('usage: check-paragraph-contract-fixtures.mts'); + +const fixtures = new URL('../fixtures/', import.meta.url); +const bidiUrl = new URL('contracts/paragraph-bidi-layout-v0.json', fixtures); +const cjkUrl = new URL('contracts/paragraph-cjk-layout-v0.json', fixtures); +const [bidi, cjk] = await Promise.all([readJsonRecord(bidiUrl), readJsonRecord(cjkUrl)]); + +assertEqual(bidi.schemaVersion, 0, 'paragraph bidi schemaVersion'); +assertEqual(cjk.schemaVersion, 0, 'paragraph CJK schemaVersion'); + +const bidiFonts = record(bidi.fonts, 'paragraph bidi fonts'); +const amiri = record(bidiFonts.amiri, 'paragraph bidi Amiri font'); +const inter = record(bidiFonts.inter, 'paragraph bidi Inter font'); +const cjkFont = record(cjk.font, 'paragraph CJK font'); + +await Promise.all([ + authenticateSource(amiri, new URL('fonts/amiri-1.002/Amiri-Regular.ttf', fixtures), 'Amiri'), + authenticateSource(inter, new URL('fonts/inter-v4.1/Inter-Regular.ttf', fixtures), 'Inter'), + authenticateSource(cjkFont, new URL('fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', fixtures), 'CJK'), + authenticateOracles(amiri, bidiUrl, 'Amiri'), + authenticateOracles(cjkFont, cjkUrl, 'CJK'), + authenticateShaping(amiri, new URL('rendering/amiri-bitmap-16.font.glb', fixtures), 'Amiri'), + authenticateShaping(inter, new URL('rendering/inter-bitmap-16.font.glb', fixtures), 'Inter'), + authenticateShaping(cjkFont, new URL('rendering/noto-sans-cjk-contract-bitmap-16.font.glb', fixtures), 'CJK'), +]); + +async function authenticateSource(metadata: Readonly>, url: URL, label: string): Promise { + const expected = string(metadata.sourceSha256, `${label} sourceSha256`); + const actual = createHash('sha256') + .update(await readFile(url)) + .digest('hex'); + assertEqual(actual, expected, `${label} source SHA-256`); +} + +async function authenticateShaping( + metadata: Readonly>, + url: URL, + label: string, +): Promise { + const expected = string(metadata.shapingHash, `${label} shapingHash`); + const registry = new FontRegistry(); + const font = await registry.registerAsset(await readFile(url)); + try { + assertEqual(font.shapingHash, expected, `${label} registered shaping hash`); + } finally { + font.dispose(); + } +} + +async function authenticateOracles( + metadata: Readonly>, + contractUrl: URL, + label: string, +): Promise { + const source = await readJsonRecord(new URL(string(metadata.sourceOracle, `${label} sourceOracle`), contractUrl)); + const independent = await readJsonRecord( + new URL(string(metadata.independentOracle, `${label} independentOracle`), contractUrl), + ); + assertEqual(record(source.engine, `${label} source oracle engine`).name, 'HarfRust', `${label} source oracle`); + assertEqual( + record(independent.engine, `${label} independent oracle engine`).name, + 'HarfBuzz', + `${label} independent oracle`, + ); +} + +async function readJsonRecord(url: URL): Promise>> { + return record(JSON.parse(await readFile(url, 'utf8')) as unknown, url.pathname); +} + +function record(value: unknown, label: string): Readonly> { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new TypeError(`${label} is not an object`); + return value as Readonly>; +} + +function string(value: unknown, label: string): string { + if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${label} is not a nonempty string`); + return value; +} + +function assertEqual(actual: unknown, expected: unknown, label: string): void { + if (!Object.is(actual, expected)) throw new Error(`${label}: ${String(actual)} !== ${String(expected)}`); +} + +/* @workflow +{ + "name": "fixture:paragraph-contracts:check", + "summary": "Authenticate retained paragraph contracts, their source fonts, shaping payloads, and independent oracles.", + "requirements": "Built runtime packages plus checked-in paragraph fonts, contracts, and shaping oracles.", + "writes": "Nothing. Behavioral equivalence is checked by the public paragraph-contracts browser target.", + "args": [] +} +*/ diff --git a/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts b/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts deleted file mode 100644 index d186aacb..00000000 --- a/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts +++ /dev/null @@ -1,212 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; - -import { - createParagraphEngine, - createRuntimeShaper, - FontRegistry, - type ParagraphConstraints, - type ParagraphStyle, -} from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; - -import { createUikitLayoutFixture, YogaMeasureMode } from '../src/benchmark/uikit-layout-fixture.ts'; -import { paragraphLayoutContract } from '../src/benchmark/paragraph-layout-digest.ts'; - -const root = new URL('../', import.meta.url); -const output = new URL('../fixtures/contracts/paragraph-bidi-layout-v0.json', import.meta.url); -const cliArguments = process.argv.slice(2); -if (cliArguments.some((argument) => argument !== '--check') || cliArguments.length > 1) { - throw new Error('usage: generate-paragraph-bidi-contract.mts [--check]'); -} -const check = cliArguments[0] === '--check'; -const [bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL('../../packages/font-baker/dist/font_baker.wasm', root)), - readFile(new URL('../../packages/text/dist/text_shaper.wasm', root)), -]); - -async function runtime(sourceUrl: URL) { - const source = await readFile(sourceUrl); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0]; - if (artifact === undefined) throw new Error('font baker returned no contract artifact'); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact.bytes); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} - -const amiri = await runtime(new URL('../fixtures/fonts/amiri-1.002/Amiri-Regular.ttf', import.meta.url)); -const amiriEngine = createParagraphEngine({ shaper: amiri.shaper }); -const bidiStyle = { - fontSize: 40, - lineHeight: 1.25, - direction: 'auto', - language: 'ar', -} as const satisfies ParagraphStyle; -const bidiConstraints = { - width: { mode: 'exactly', size: 300 }, - wrap: 'word', - align: 'start', -} as const satisfies ParagraphConstraints; -const bidi: Record = {}; -for (const [id, text] of [ - ['ltr', 'ABC مرحبا 123 DEF'], - ['rtl', 'مرحبا ABC 123 عالم'], -] as const) { - const paragraph = amiriEngine.create({ text, font: amiri.font.handle, style: bidiStyle }); - bidi[id] = { - text, - style: bidiStyle, - constraints: bidiConstraints, - layout: paragraphLayoutContract(paragraph.layout(bidiConstraints)), - }; -} - -const inter = await runtime(new URL('../fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)); -const policyEngine = createParagraphEngine({ shaper: inter.shaper }); -const policyText = 'one two three four five six seven'; -const policyStyle = { - fontSize: 32, - lineHeight: 1.25, - direction: 'ltr', - language: 'en', -} as const satisfies ParagraphStyle; -const paragraph = policyEngine.create({ - text: policyText, - font: inter.font.handle, - style: policyStyle, -}); -const policyInputs = { - start: { width: { mode: 'exactly', size: 180 }, align: 'start' }, - center: { width: { mode: 'exactly', size: 180 }, align: 'center' }, - end: { width: { mode: 'exactly', size: 180 }, align: 'end' }, - justify: { width: { mode: 'exactly', size: 180 }, align: 'justify' }, - clip: { - width: { mode: 'exactly', size: 180 }, - height: { mode: 'exactly', size: 60 }, - overflow: 'clip', - }, - maxLines: { width: { mode: 'exactly', size: 180 }, maxLines: 2, overflow: 'clip' }, - ellipsisOne: { - width: { mode: 'exactly', size: 180 }, - maxLines: 1, - overflow: 'ellipsis', - }, - ellipsisHeightOne: { - width: { mode: 'exactly', size: 180 }, - height: { mode: 'exactly', size: 40 }, - overflow: 'ellipsis', - }, - ellipsisHeightTwo: { - width: { mode: 'exactly', size: 180 }, - height: { mode: 'exactly', size: 80 }, - overflow: 'ellipsis', - }, -} as const satisfies Record; -const policyCases: Record = {}; -for (const [id, constraints] of Object.entries(policyInputs)) { - policyCases[id] = { - constraints, - layout: paragraphLayoutContract(paragraph.layout(constraints), false), - }; -} - -const uikitInput = { - text: 'office AVATAR café — ffi, kerning, marks, and wrapping.', - font: inter.font.handle, - style: { fontSize: 31, lineHeight: 1.23, direction: 'ltr', language: 'en' }, -} as const; -const uikitPolicy = { wrap: 'word', overflow: 'clip' } as const; -const uikitParagraph = policyEngine.create(uikitInput); -const uikitFixture = createUikitLayoutFixture(uikitParagraph, uikitPolicy); -const customLayouting = uikitFixture.customLayouting(); -const uikitNatural = customLayouting.measure( - Number.NaN, - YogaMeasureMode.Undefined, - Number.NaN, - YogaMeasureMode.Undefined, -); -const uikitAtMost = customLayouting.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost); -const uikitExactWidth = customLayouting.measure( - 420.001, - YogaMeasureMode.Exactly, - Number.NaN, - YogaMeasureMode.Undefined, -); -const uikitDefinite = uikitFixture.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly); -const uikitResolved = uikitFixture.layoutResolvedBox([401.24, 150.12], [7, 11, 13, 17], [1, 2, 3, 4]); - -const document = { - schemaVersion: 0, - generatedBy: 'apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts', - fonts: { - amiri: { - fixture: 'amiri-regular-v0', - sourceSha256: 'ab391c4147d054c48976e98322ad0eefe1427aa0e0502a12a4c75d80a70cfcd7', - shapingHash: '2e29d8d1378084212287efa84db35066310164048a6b4495aff97512d46336d5', - sourceOracle: '../shaping/amiri-regular/harfrust.json', - independentOracle: '../shaping/amiri-regular/harfbuzz.json', - }, - inter: { - fixture: 'inter-regular-v0', - sourceSha256: '40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82', - shapingHash: '6a96d9c6f9e59fd6aeb51848413bd4dd8711730a5479a7d004979d80f3b3cd09', - }, - }, - bidi, - policies: { text: policyText, style: policyStyle, cases: policyCases }, - uikit: { - input: { text: uikitInput.text, style: uikitInput.style }, - policy: uikitPolicy, - customLayouting: { - minWidth: customLayouting.minWidth, - minHeight: customLayouting.minHeight, - firstBaseline: customLayouting.firstBaseline, - }, - measurements: { - natural: uikitNatural, - atMost: uikitAtMost, - exactWidth: uikitExactWidth, - definite: uikitDefinite, - }, - resolved: { - outerSize: [401.24, 150.12], - padding: [7, 11, 13, 17], - border: [1, 2, 3, 4], - contentBox: uikitResolved.contentBox, - centeredX: [...uikitResolved.centeredX], - centeredY: [...uikitResolved.centeredY], - layout: paragraphLayoutContract(uikitResolved.layout, false), - }, - }, -}; -if (check) { - const checkedIn = JSON.parse(await readFile(output, 'utf8')) as unknown; - if (JSON.stringify(checkedIn) !== JSON.stringify(document)) { - throw new Error( - 'paragraph bidi contract is stale; run pnpm generate:paragraph-bidi-contract and review the exact diff', - ); - } -} else { - await writeFile(output, `${JSON.stringify(document, undefined, 2)}\n`); -} -/* @workflow -{ - "name": "fixture:paragraph-bidi:generate", - "summary": "Regenerate the public paragraph bidi contract fixture.", - "requirements": "Built runtime packages and authenticated fonts.", - "writes": "Checked-in paragraph bidi contract." -} -*/ -/* @workflow -{ - "name": "fixture:paragraph-bidi:check", - "summary": "Verify the public paragraph bidi contract fixture.", - "requirements": "Built runtime packages and authenticated fonts.", - "writes": "Nothing.", - "args": ["--check"] -} -*/ diff --git a/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts b/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts deleted file mode 100644 index 65b08878..00000000 --- a/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts +++ /dev/null @@ -1,146 +0,0 @@ -import { createHash } from 'node:crypto'; -import { readFile, writeFile } from 'node:fs/promises'; - -import { - createParagraphEngine, - createRuntimeShaper, - FontRegistry, - type ParagraphConstraints, - type ParagraphStyle, - type RuntimeShaper, -} from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; - -import { paragraphLayoutContract } from '../src/benchmark/paragraph-layout-digest.ts'; - -const output = new URL('../fixtures/contracts/paragraph-cjk-layout-v0.json', import.meta.url); -const cliArguments = process.argv.slice(2); -if (cliArguments.some((argument) => argument !== '--check') || cliArguments.length > 1) { - throw new Error('usage: generate-paragraph-cjk-contract.mts [--check]'); -} -const check = cliArguments[0] === '--check'; -const root = new URL('../', import.meta.url); -const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL('../fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', import.meta.url)), - readFile(new URL('../../packages/font-baker/dist/font_baker.wasm', root)), - readFile(new URL('../../packages/text/dist/text_shaper.wasm', root)), -]); -const baker = await createFontBaker(bakerWasm); -const baked = baker.bake({ source, descriptor: { formatVersion: 0, fontFaceIndex: 0 } }); -const artifact = baked.artifacts[0]; -if (artifact === undefined) throw new Error('font baker returned no CJK artifact'); -const registry = new FontRegistry(); -const font = await registry.registerAsset(artifact.bytes); -const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); -const calls = { shape: 0, reshape: 0 }; -const observed = observeShaper(shaper, calls); -const engine = createParagraphEngine({ shaper: observed }); - -const constraints = { - natural: { width: { mode: 'unconstrained' }, wrap: 'word' }, - wide: { width: { mode: 'exactly', size: 480 }, wrap: 'word' }, - narrow: { width: { mode: 'exactly', size: 260 }, wrap: 'word' }, -} as const satisfies Record; -const inputs = { - simplified: { - text: '简体中文段落没有空格,需要在合法边界换行,并保持(标点)与𠀋、禰󠄀完整。', - style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'zh-hans' }, - }, - japanese: { - text: '日本語の文章は空白なしで改行し、句読点「。、」と𠀋、禰󠄀を安全に扱います。', - style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' }, - }, - korean: { - text: '한글 문장과 자모, 漢字를 함께 안전하게 배치합니다.', - style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ko' }, - }, - mixed: { - text: 'pmndrs text:骨かな한글ABC、𠀋、禰󠄀', - style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' }, - }, -} as const satisfies Record; - -const cases: Record = {}; -for (const [id, input] of Object.entries(inputs)) { - const before = { ...calls }; - const paragraph = engine.create({ ...input, font: font.handle }); - const layouts: Record = {}; - for (const [constraintId, value] of Object.entries(constraints)) { - const measured = paragraph.measure(value); - const layout = paragraph.layout(value); - if (paragraph.measure(value) !== measured || paragraph.layout(value) !== layout) { - throw new Error(`${id}.${constraintId} did not reuse its retained result`); - } - layouts[constraintId] = paragraphLayoutContract(layout); - } - cases[id] = { - ...input, - layouts, - calls: { shape: calls.shape - before.shape, reshape: calls.reshape - before.reshape }, - }; -} - -const document = { - schemaVersion: 0, - generatedBy: 'apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts', - font: { - fixture: 'noto-sans-cjk-jp-regular-v0', - sourceSha256: createHash('sha256').update(source).digest('hex'), - artifactSha256: artifact.sha256, - shapingHash: font.shapingHash, - sourceOracle: '../shaping/noto-sans-cjk/harfrust.json', - independentOracle: '../shaping/noto-sans-cjk/harfbuzz.json', - }, - constraints, - cases, -}; -const serialized = `${JSON.stringify(document, undefined, 2)}\n`; -if (check) { - const committed = JSON.parse(await readFile(output, 'utf8')) as unknown; - if (JSON.stringify(committed) !== JSON.stringify(document)) { - throw new Error( - 'paragraph CJK contract is stale; run pnpm generate:paragraph-cjk-contract and review the exact diff', - ); - } -} else { - await writeFile(output, serialized); -} - -shaper.dispose(); -font.dispose(); - -function observeShaper(runtime: RuntimeShaper, counts: { shape: number; reshape: number }): RuntimeShaper { - return { - registry: runtime.registry, - registerFont: (registered) => runtime.registerFont(registered), - disposeFont: (registered) => runtime.disposeFont(registered), - analyzeBidi: (text, direction) => runtime.analyzeBidi(text, direction), - shapeBatch: (request) => { - counts.shape += 1; - return runtime.shapeBatch(request); - }, - reshapeRanges: (request) => { - counts.reshape += 1; - return runtime.reshapeRanges(request); - }, - memoryReport: () => runtime.memoryReport(), - dispose: () => runtime.dispose(), - }; -} -/* @workflow -{ - "name": "fixture:paragraph-cjk:generate", - "summary": "Regenerate the public paragraph CJK contract fixture.", - "requirements": "Built runtime packages and authenticated CJK font.", - "writes": "Checked-in paragraph CJK contract." -} -*/ -/* @workflow -{ - "name": "fixture:paragraph-cjk:check", - "summary": "Verify the public paragraph CJK contract fixture.", - "requirements": "Built runtime packages and authenticated CJK font.", - "writes": "Nothing.", - "args": ["--check"] -} -*/ diff --git a/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts b/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts new file mode 100644 index 00000000..cde427d2 --- /dev/null +++ b/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts @@ -0,0 +1,55 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; + +import { bakeFont } from '@pmndrs/text/bake'; +import { bitmapBaker } from '@pmndrs/text/bakers/bitmap'; + +import { paragraphCjkCoverageText } from '../src/benchmark/paragraph-contract-corpus.ts'; + +const output = resolve('fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb'); +const arguments_ = process.argv.slice(2); +const check = arguments_.includes('--check'); +if (arguments_.some((argument) => argument !== '--check') || arguments_.length > 1) { + throw new Error('usage: generate-paragraph-conformance-font.mts [--check]'); +} +const temporaryDirectory = check ? await mkdtemp(join(tmpdir(), 'pmndrs-text-cjk-contract-')) : undefined; +const generated = temporaryDirectory === undefined ? output : join(temporaryDirectory, 'font.glb'); +try { + await bakeFont({ + input: resolve('fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf'), + output: generated, + font: { fontFaceIndex: 0 }, + rasters: [ + { + baker: bitmapBaker, + packaging: { artifact: 'embedded', pages: 'embedded' }, + options: { strikes: [16], coverage: { text: paragraphCjkCoverageText } }, + }, + ], + }); + if (check) { + const [actual, expected] = await Promise.all([readFile(generated), readFile(output)]); + if (!actual.equals(expected)) throw new Error('paragraph CJK conformance font is stale'); + } +} finally { + if (temporaryDirectory !== undefined) await rm(temporaryDirectory, { recursive: true, force: true }); +} + +/* @workflow +{ + "name": "fixture:paragraph-conformance-font:generate", + "summary": "Generate the sparse Bitmap font used by public Rust paragraph conformance.", + "requirements": "Built runtime packages and authenticated Noto Sans CJK source font.", + "writes": "Checked-in sparse paragraph conformance font asset." +} +*/ +/* @workflow +{ + "name": "fixture:paragraph-conformance-font:check", + "summary": "Verify the sparse Bitmap font used by public Rust paragraph conformance.", + "requirements": "Built runtime packages and authenticated Noto Sans CJK source font.", + "writes": "Nothing.", + "args": ["--check"] +} +*/ diff --git a/apps/benchmarks/scripts/measure-package-sizes.mts b/apps/benchmarks/scripts/measure-package-sizes.mts index ab7fee4e..03213a03 100644 --- a/apps/benchmarks/scripts/measure-package-sizes.mts +++ b/apps/benchmarks/scripts/measure-package-sizes.mts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { brotliCompressSync, constants, gzipSync } from 'node:zlib'; +import { brotliCompressSync, constants, gunzipSync, gzipSync } from 'node:zlib'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { build } from 'vite'; @@ -10,7 +10,7 @@ interface MeasuredEntry { readonly id: string; readonly label: string; readonly status: 'measured'; - readonly format: 'javascript' | 'wasm'; + readonly format: 'javascript' | 'wasm' | 'font-asset' | 'aggregate'; readonly sha256: string; readonly rawBytes: number; readonly minifiedBytes: number; @@ -253,6 +253,42 @@ async function measureWasm(id: string, label: string, source: URL): Promise { + const transferred = await readFile(source); + const payload = transport === 'gzip' ? gunzipSync(transferred) : transferred; + return { + id, + label, + status: 'measured', + format: 'font-asset', + sha256: sha256(transferred), + rawBytes: payload.byteLength, + minifiedBytes: payload.byteLength, + gzipBytes: transport === 'gzip' ? transferred.byteLength : gzipSync(payload, { level: 9 }).byteLength, + brotliBytes: compression(payload).brotliBytes, + }; +} + +function aggregateSize(id: string, label: string, parts: readonly MeasuredEntry[]): MeasuredEntry { + const identity = new TextEncoder().encode(parts.map((part) => `${part.id}:${part.sha256}`).join('\n')); + return { + id, + label, + status: 'measured', + format: 'aggregate', + sha256: sha256(identity), + rawBytes: parts.reduce((total, part) => total + part.rawBytes, 0), + minifiedBytes: parts.reduce((total, part) => total + part.minifiedBytes, 0), + gzipBytes: parts.reduce((total, part) => total + part.gzipBytes, 0), + brotliBytes: parts.reduce((total, part) => total + part.brotliBytes, 0), + }; +} + async function measureAdmittedMsdfGenerator(): Promise { const evidence = JSON.parse( await readFile( @@ -296,32 +332,129 @@ async function measureAdmittedMsdfGenerator(): Promise { }; } +const browserCore = await measureJavaScript( + 'browser-core', + 'Renderer-neutral core JS (peers and Wasm external)', + new URL('../size-entries/text-core.ts', import.meta.url), + false, + true, + true, + { + expectedDynamic: ['/packages/text/dist/runtime-bake.js'], + excludedInitial: [ + '/packages/text/dist/runtime-bake.js', + '/packages/text/dist/runtime-bake-worker.js', + '/packages/text/dist/r3f.js', + '/packages/text/dist/three.js', + '/packages/text/dist/raster/bitmap-technique.js', + '/packages/text/dist/raster/msdf.js', + '/packages/text/dist/raster/slug-technique.js', + '/packages/text/dist/bakers/msdf.js', + '/packages/text/dist/node/', + '/packages/font-baker/dist/index.js', + '/packages/font-baker/dist/wasm.js', + '/packages/font-baker/dist/validator.js', + ], + }, +); +const textShaperWasm = await measureWasm( + 'text-shaper-wasm', + 'Text engine Wasm', + new URL('../../../packages/text/dist/text_shaper.wasm', import.meta.url), +); +const threeRuntime = await measureJavaScript( + 'three-runtime-js', + 'Complete Three adapter JS (peers and Wasm external)', + new URL('../size-entries/three-runtime.ts', import.meta.url), + false, + true, + true, +); +const interBitmap = await measureFontAsset( + 'font-inter-bitmap-16-32', + 'Inter 4.1 Bitmap font asset (16 + 32 ppem)', + new URL('../fixtures/rendering/inter-bitmap-16-32.font.glb', import.meta.url), + 'identity', +); +const interMsdf = await measureFontAsset( + 'font-inter-mtsdf', + 'Inter 4.1 MTSDF font asset', + new URL('../fixtures/rendering/inter-mtsdf.font.glb.gz', import.meta.url), + 'gzip', +); +const interSlug = await measureFontAsset( + 'font-inter-slug', + 'Inter 4.1 Slug font asset', + new URL('../fixtures/rendering/inter-slug.font.glb.gz', import.meta.url), + 'gzip', +); +const iconsBitmap = await measureFontAsset( + 'font-icons-bitmap-16-32', + 'Font Awesome Free 6.7.2 Bitmap icon asset (16 + 32 ppem)', + new URL('../fixtures/rendering/font-awesome-free-6.7.2-bitmap-16-32.font.glb', import.meta.url), + 'identity', +); +const iconsMsdf = await measureFontAsset( + 'font-icons-mtsdf', + 'Font Awesome Free 6.7.2 MTSDF icon asset', + new URL('../fixtures/rendering/font-awesome-free-6.7.2-mtsdf.font.glb.gz', import.meta.url), + 'gzip', +); +const iconsSlug = await measureFontAsset( + 'font-icons-slug', + 'Font Awesome Free 6.7.2 Slug icon asset', + new URL('../fixtures/rendering/font-awesome-free-6.7.2-slug.font.glb.gz', import.meta.url), + 'gzip', +); + const entries: SizeEntry[] = [ - await measureJavaScript( - 'browser-core', - 'Browser core', - new URL('../size-entries/text-core.ts', import.meta.url), - false, - true, - true, - { - expectedDynamic: ['/packages/text/dist/runtime-bake.js'], - excludedInitial: [ - '/packages/text/dist/runtime-bake.js', - '/packages/text/dist/runtime-bake-worker.js', - '/packages/text/dist/r3f.js', - '/packages/text/dist/three.js', - '/packages/text/dist/raster/bitmap-technique.js', - '/packages/text/dist/raster/msdf.js', - '/packages/text/dist/raster/slug-technique.js', - '/packages/text/dist/bakers/msdf.js', - '/packages/text/dist/node/', - '/packages/font-baker/dist/index.js', - '/packages/font-baker/dist/wasm.js', - '/packages/font-baker/dist/validator.js', - ], - }, - ), + browserCore, + textShaperWasm, + aggregateSize('renderer-neutral-core-total', 'Renderer-neutral core total (JS + Wasm)', [ + browserCore, + textShaperWasm, + ]), + threeRuntime, + aggregateSize('three-renderer-total', 'Complete Three text renderer total (adapter JS + Wasm; peers external)', [ + threeRuntime, + textShaperWasm, + ]), + interBitmap, + interMsdf, + interSlug, + iconsBitmap, + iconsMsdf, + iconsSlug, + aggregateSize('delivery-three-inter-bitmap', 'Three + engine + Inter Bitmap delivery total', [ + threeRuntime, + textShaperWasm, + interBitmap, + ]), + aggregateSize('delivery-three-inter-mtsdf', 'Three + engine + Inter MTSDF delivery total', [ + threeRuntime, + textShaperWasm, + interMsdf, + ]), + aggregateSize('delivery-three-inter-slug', 'Three + engine + Inter Slug delivery total', [ + threeRuntime, + textShaperWasm, + interSlug, + ]), + aggregateSize('delivery-three-icons-bitmap', 'Three + engine + Font Awesome Bitmap delivery total', [ + threeRuntime, + textShaperWasm, + iconsBitmap, + ]), + aggregateSize('delivery-three-icons-mtsdf', 'Three + engine + Font Awesome MTSDF delivery total', [ + threeRuntime, + textShaperWasm, + iconsMsdf, + ]), + aggregateSize('delivery-three-icons-slug', 'Three + engine + Font Awesome Slug delivery total', [ + threeRuntime, + textShaperWasm, + iconsSlug, + ]), await measureJavaScript( 'font-validator-js', 'Lazy font validator JS', @@ -339,21 +472,6 @@ const entries: SizeEntry[] = [ true, true, ), - await measureJavaScript( - 'text-shaper-js', - 'Text shaper JS', - new URL('../size-entries/text-shaper.ts', import.meta.url), - false, - true, - false, - undefined, - ['performance.now'], - ), - await measureWasm( - 'text-shaper-wasm', - 'Text shaper Wasm', - new URL('../../../packages/text/dist/text_shaper.wasm', import.meta.url), - ), await measureJavaScript( 'bitmap-runtime-js', 'Bitmap runtime JS graph', @@ -456,14 +574,38 @@ const report = { }; const output = new URL('../src/generated/package-sizes.json', import.meta.url); const serialized = `${JSON.stringify(report, null, 2)}\n`; -if (process.argv.includes('--check')) { +const sizeLimitJson = process.argv.includes('--size-limit-json'); +if (sizeLimitJson) { + const committed = await readFile(output, 'utf8'); + assertPackageSizeReportFresh(JSON.parse(committed) as PackageSizeReport, report); + const results = entries.flatMap((entry) => { + if (entry.status !== 'measured') return []; + switch (entry.format) { + case 'javascript': + return [{ name: `${entry.id} (brotli)`, size: entry.brotliBytes }]; + case 'wasm': + case 'font-asset': + return [ + { name: `${entry.id} (raw)`, size: entry.rawBytes }, + { name: `${entry.id} (gzip)`, size: entry.gzipBytes }, + { name: `${entry.id} (brotli)`, size: entry.brotliBytes }, + ]; + case 'aggregate': + return [ + { name: `${entry.id} (gzip)`, size: entry.gzipBytes }, + { name: `${entry.id} (brotli)`, size: entry.brotliBytes }, + ]; + } + }); + process.stdout.write(JSON.stringify(results)); +} else if (process.argv.includes('--check')) { const committed = await readFile(output, 'utf8'); assertPackageSizeReportFresh(JSON.parse(committed) as PackageSizeReport, report); } else { await mkdir(new URL('../src/generated/', import.meta.url), { recursive: true }); await writeFile(output, serialized); + process.stdout.write(serialized); } -process.stdout.write(serialized); /* @workflow { "name": "release:size:generate", diff --git a/apps/benchmarks/scripts/run-headless.mts b/apps/benchmarks/scripts/run-headless.mts index e481de16..323f166d 100644 --- a/apps/benchmarks/scripts/run-headless.mts +++ b/apps/benchmarks/scripts/run-headless.mts @@ -33,11 +33,7 @@ const conformanceCases: readonly BenchmarkCase[] = [ { targetId: 'react-text-reconciliation', scenarioId: 'react-text-reconciliation' }, { targetId: 'font-baker', scenarioId: 'cold-load-payload' }, { targetId: 'font-loader-worker', scenarioId: 'worker-fallback' }, - { targetId: 'harfrust-shaper', scenarioId: 'shaping-conformance' }, - { targetId: 'paragraph-engine', scenarioId: 'paragraph-measurement' }, - { targetId: 'paragraph-layout-engine', scenarioId: 'paragraph-layout' }, - { targetId: 'paragraph-bidi-policy', scenarioId: 'paragraph-bidi-policy' }, - { targetId: 'cjk-universality', scenarioId: 'cjk-universality' }, + { targetId: 'paragraph-contracts', scenarioId: 'paragraph-contracts' }, { targetId: 'advanced-shaping-conformance', scenarioId: 'advanced-shaping-conformance', diff --git a/apps/benchmarks/scripts/test.mts b/apps/benchmarks/scripts/test.mts index 9bd0faff..6e370868 100644 --- a/apps/benchmarks/scripts/test.mts +++ b/apps/benchmarks/scripts/test.mts @@ -3,8 +3,7 @@ import { buildRuntimePackages, isMainModule, run, runNodeScript } from './suppor export async function runBenchmarkTest(options: { readonly runtimePackagesReady?: boolean } = {}): Promise { if (!options.runtimePackagesReady) await buildRuntimePackages(); await runNodeScript('scripts/measure-package-sizes.mts', ['--check']); - await runNodeScript('scripts/generate-paragraph-bidi-contract.mts', ['--check']); - await runNodeScript('scripts/generate-paragraph-cjk-contract.mts', ['--check']); + await runNodeScript('scripts/check-paragraph-contract-fixtures.mts'); await runNodeScript('node_modules/vitest/vitest.mjs', ['run']); await run(process.execPath, ['--test', 'scripts/workflows.test.mts']); await runNodeScript('scripts/run-headless.mts', [ diff --git a/apps/benchmarks/scripts/verify-v1-bitmap.mts b/apps/benchmarks/scripts/verify-v1-bitmap.mts index 40475155..00edb977 100644 --- a/apps/benchmarks/scripts/verify-v1-bitmap.mts +++ b/apps/benchmarks/scripts/verify-v1-bitmap.mts @@ -32,17 +32,6 @@ interface ComposeProofResult { readonly canonicalGreenPixels: number; } -interface AsyncProofResult { - readonly status: string; - readonly workerCount: number; - readonly glyphCount: number; - readonly progressEvents: number; - readonly snapshotGlyphCount: number; - readonly desiredGlyphCount: number; - readonly superseded: boolean; - readonly aborted: boolean; -} - const root = fileURLToPath(new URL('..', import.meta.url)); const vite = fileURLToPath(new URL('../node_modules/.bin/vite', import.meta.url)); const server = spawn(vite, ['--host', '127.0.0.1', '--port', '5177', '--strictPort'], { @@ -170,45 +159,6 @@ try { process.stdout.write(`${expected} compose: ${JSON.stringify(result)}\n`); await page.close(); } - const asyncPage = await browser.newPage(); - const asyncErrors: string[] = []; - asyncPage.on('console', (message) => { - if (message.type() === 'error') asyncErrors.push(message.text()); - }); - asyncPage.on('pageerror', (error) => asyncErrors.push(error.message)); - await asyncPage.goto('http://127.0.0.1:5177/v1-async.html', { waitUntil: 'domcontentloaded' }); - const asyncEvaluation = await asyncPage.evaluate(() => - (window as typeof window & { targetV1AsyncReady: Promise }).targetV1AsyncReady.then( - (value) => ({ ok: true as const, value }), - (cause: unknown) => { - const nested = - typeof cause === 'object' && cause !== null && 'cause' in cause && cause.cause instanceof Error - ? cause.cause - : undefined; - const error = cause instanceof Error ? cause : (nested ?? new Error(JSON.stringify(cause))); - return { ok: false as const, error: { name: error.name, message: error.message, stack: error.stack } }; - }, - ), - ); - if (asyncErrors.length !== 0) throw new Error(`async Worker browser errors: ${asyncErrors.join(' | ')}`); - if (!asyncEvaluation.ok) - throw new Error( - `target-v1 async Worker rejected: ${asyncEvaluation.error.name}: ${asyncEvaluation.error.message}\n${asyncEvaluation.error.stack ?? ''}`, - ); - const asyncResult = asyncEvaluation.value; - if ( - asyncResult.status !== 'published' || - asyncResult.workerCount !== 1 || - asyncResult.glyphCount !== 11 || - asyncResult.progressEvents < 2 || - asyncResult.snapshotGlyphCount !== 8 || - asyncResult.desiredGlyphCount !== 22 || - !asyncResult.superseded || - !asyncResult.aborted - ) - throw new Error(`target-v1 async Worker did not prepare the expected paragraph: ${JSON.stringify(asyncResult)}`); - process.stdout.write(`worker: ${JSON.stringify(asyncResult)}\n`); - await asyncPage.close(); } finally { await browser.close(); server.kill('SIGTERM'); diff --git a/apps/benchmarks/size-entries/text-shaper.ts b/apps/benchmarks/size-entries/text-shaper.ts deleted file mode 100644 index 149a21c2..00000000 --- a/apps/benchmarks/size-entries/text-shaper.ts +++ /dev/null @@ -1 +0,0 @@ -export { createRuntimeShaper } from '@pmndrs/text'; diff --git a/apps/benchmarks/size-entries/three-runtime.ts b/apps/benchmarks/size-entries/three-runtime.ts new file mode 100644 index 00000000..0f7d768a --- /dev/null +++ b/apps/benchmarks/size-entries/three-runtime.ts @@ -0,0 +1,4 @@ +export * from '@pmndrs/text/three'; +export { bitmap } from '@pmndrs/text/three/bitmap'; +export { msdf } from '@pmndrs/text/three/msdf'; +export { slug } from '@pmndrs/text/three/slug'; diff --git a/apps/benchmarks/src/benchmark/fixtures.test.ts b/apps/benchmarks/src/benchmark/fixtures.test.ts index 4d36663c..85df78a6 100644 --- a/apps/benchmarks/src/benchmark/fixtures.test.ts +++ b/apps/benchmarks/src/benchmark/fixtures.test.ts @@ -93,111 +93,6 @@ describe('canonical Inter fixtures', () => { expect(createHash('sha256').update(image).digest('hex')).toBe(metadata.image.sha256); }); - it('records the browser shaping conformance and one-call memory evidence', async () => { - const result = JSON.parse( - await readFile(new URL('results/shaping-conformance-chromium149.json', fixtureRoot), 'utf8'), - ); - - expect(result).toMatchObject({ - schemaVersion: 0, - targetId: 'harfrust-shaper', - scenarioId: 'shaping-conformance', - status: 'passed', - controls: { dpr: 1, samples: 3, warmup: 1 }, - }); - expect(result.measurements).toHaveLength(3); - expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set(['dc30c21c'])); - expect( - result.measurements.every( - ({ metrics }: { metrics: Record }) => - metrics.boundaryCrossings === 1 && - metrics.goldenCases === 8 && - metrics.glyphCount === 97 && - metrics.planCount === 3 && - metrics.retainedFontBytes === 171056, - ), - ).toBe(true); - }); - - it('records exact browser paragraph measurement with zero Wasm reflow calls', async () => { - const result = JSON.parse( - await readFile(new URL('results/paragraph-measurement-chromium149.json', fixtureRoot), 'utf8'), - ); - - expect(result).toMatchObject({ - targetId: 'paragraph-engine', - scenarioId: 'paragraph-measurement', - status: 'passed', - outputBytes: 168, - }); - expect(result.measurements).toHaveLength(3); - expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set(['79874b9d'])); - expect( - result.measurements.every( - ({ metrics }: { metrics: Record }) => - metrics.shapeBoundaryCrossings === 1 && - metrics.reshapeBoundaryCrossings === 0 && - metrics.reflowBoundaryCrossings === 0 && - metrics.measurementCount === 3 && - metrics.positionedGlyphBytes === 0, - ), - ).toBe(true); - }); - - it('records exact browser positioned layouts and batched boundary reshaping', async () => { - const result = JSON.parse( - await readFile(new URL('results/paragraph-layout-chromium149.json', fixtureRoot), 'utf8'), - ); - - expect(result).toMatchObject({ - targetId: 'paragraph-layout-engine', - scenarioId: 'paragraph-layout', - status: 'passed', - outputBytes: 3786, - }); - expect(result.measurements).toHaveLength(3); - expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual( - new Set(['bb15bbcc:4f111a3f:e8c0e9d5']), - ); - expect( - result.measurements.every( - ({ metrics }: { metrics: Record }) => - metrics.shapeBoundaryCrossings === 1 && - metrics.reshapeBoundaryCrossings === 2 && - metrics.batchedBoundaryLayouts === 2 && - metrics.layoutCount === 3 && - metrics.glyphCount === 165, - ), - ).toBe(true); - }); - - it('records exact browser bidi, policy, and current-uikit-shaped output', async () => { - const result = JSON.parse( - await readFile(new URL('results/paragraph-bidi-policy-chromium149.json', fixtureRoot), 'utf8'), - ); - const expectedHash = - '8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c'; - - expect(result).toMatchObject({ - targetId: 'paragraph-bidi-policy', - scenarioId: 'paragraph-bidi-policy', - status: 'passed', - outputBytes: 8098, - }); - expect(result.measurements).toHaveLength(3); - expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set([expectedHash])); - expect( - result.measurements.every( - ({ metrics }: { metrics: Record }) => - metrics.bidiLayoutCount === 2 && - metrics.policyLayoutCount === 9 && - metrics.uikitMeasurementCount === 25 && - metrics.uikitLayoutCount === 1 && - metrics.shapeBoundaryCrossings === 4 && - metrics.reshapeBoundaryCrossings === 5, - ), - ).toBe(true); - }); }); describe('advanced-shaping result', () => { @@ -394,42 +289,4 @@ describe('canonical Noto Sans CJK fixtures', () => { expect(harfrust.cases).toEqual(harfbuzz.cases); }); - it('records exact Chromium CJK shaping, layout, memory, and payload evidence', async () => { - const result = JSON.parse( - await readFile(new URL('results/cjk-universality-chromium149.json', fixtureRoot), 'utf8'), - ); - const expectedHash = - 'a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68'; - - expect(result).toMatchObject({ - targetId: 'cjk-universality', - scenarioId: 'cjk-universality', - status: 'passed', - outputBytes: 10622, - environment: { webgpu: true }, - }); - expect(result.measurements).toHaveLength(3); - expect(new Set(result.measurements.map(({ hash }: { hash: string }) => hash))).toEqual(new Set([expectedHash])); - expect( - result.measurements.every( - ({ metrics }: { metrics: Record }) => - metrics.sourceUtf16Units === 208 && - metrics.corpusCaseCount === 13 && - metrics.corpusGlyphCount === 64 && - metrics.paragraphCaseCount === 4 && - metrics.layoutCount === 12 && - metrics.directShapeBoundaryCrossings === 1 && - metrics.paragraphShapeBoundaryCrossings === 4 && - metrics.reshapeBoundaryCrossings === 0 && - metrics.planCount === 8 && - metrics.retainedFontBytes === 1539372 && - metrics.wasmMemoryBytes === 4587520 && - metrics.sourceFontBytes === 16467736 && - metrics.artifactBytes === 1540480 && - metrics.shapingPayloadRawBytes === 1539372 && - metrics.shapingPayloadGzipBytes === 654925 && - metrics.shapingPayloadBrotliBytes === 514547, - ), - ).toBe(true); - }); }); diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index a5163c44..2c946886 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -23,17 +23,101 @@ export const packageSizeBudgets = { gzipBytes: 3_200, brotliBytes: 2_850, }, - 'text-shaper-js': { - rawBytes: 55_000, - minifiedBytes: 38_500, - gzipBytes: 10_500, - brotliBytes: 9_500, - }, 'text-shaper-wasm': { - rawBytes: 693_000, - minifiedBytes: 693_000, - gzipBytes: 259_000, - brotliBytes: 203_000, + rawBytes: 1_125_000, + minifiedBytes: 1_125_000, + gzipBytes: 430_000, + brotliBytes: 340_000, + }, + 'renderer-neutral-core-total': { + rawBytes: 1_230_000, + minifiedBytes: 1_200_000, + gzipBytes: 450_000, + brotliBytes: 360_000, + }, + 'three-runtime-js': { + rawBytes: 350_000, + minifiedBytes: 232_000, + gzipBytes: 60_000, + brotliBytes: 51_000, + }, + 'three-renderer-total': { + rawBytes: 1_480_000, + minifiedBytes: 1_360_000, + gzipBytes: 490_000, + brotliBytes: 390_000, + }, + 'font-inter-bitmap-16-32': { + rawBytes: 3_200_000, + minifiedBytes: 3_200_000, + gzipBytes: 570_000, + brotliBytes: 430_000, + }, + 'font-inter-mtsdf': { + rawBytes: 40_000_000, + minifiedBytes: 40_000_000, + gzipBytes: 7_000_000, + brotliBytes: 3_400_000, + }, + 'font-inter-slug': { + rawBytes: 3_600_000, + minifiedBytes: 3_600_000, + gzipBytes: 650_000, + brotliBytes: 430_000, + }, + 'font-icons-bitmap-16-32': { + rawBytes: 2_500_000, + minifiedBytes: 2_500_000, + gzipBytes: 470_000, + brotliBytes: 375_000, + }, + 'font-icons-mtsdf': { + rawBytes: 33_000_000, + minifiedBytes: 33_000_000, + gzipBytes: 7_500_000, + brotliBytes: 3_500_000, + }, + 'font-icons-slug': { + rawBytes: 3_100_000, + minifiedBytes: 3_100_000, + gzipBytes: 690_000, + brotliBytes: 510_000, + }, + 'delivery-three-inter-bitmap': { + rawBytes: 4_700_000, + minifiedBytes: 4_600_000, + gzipBytes: 1_080_000, + brotliBytes: 830_000, + }, + 'delivery-three-inter-mtsdf': { + rawBytes: 41_000_000, + minifiedBytes: 41_000_000, + gzipBytes: 7_500_000, + brotliBytes: 3_800_000, + }, + 'delivery-three-inter-slug': { + rawBytes: 5_100_000, + minifiedBytes: 5_000_000, + gzipBytes: 1_150_000, + brotliBytes: 830_000, + }, + 'delivery-three-icons-bitmap': { + rawBytes: 4_000_000, + minifiedBytes: 3_900_000, + gzipBytes: 970_000, + brotliBytes: 770_000, + }, + 'delivery-three-icons-mtsdf': { + rawBytes: 35_000_000, + minifiedBytes: 35_000_000, + gzipBytes: 8_000_000, + brotliBytes: 3_900_000, + }, + 'delivery-three-icons-slug': { + rawBytes: 4_600_000, + minifiedBytes: 4_500_000, + gzipBytes: 1_200_000, + brotliBytes: 910_000, }, 'bitmap-runtime-js': { rawBytes: 425_000, diff --git a/apps/benchmarks/src/benchmark/package-sizes.test.ts b/apps/benchmarks/src/benchmark/package-sizes.test.ts index 32f8ff58..744791b8 100644 --- a/apps/benchmarks/src/benchmark/package-sizes.test.ts +++ b/apps/benchmarks/src/benchmark/package-sizes.test.ts @@ -26,8 +26,10 @@ describe('independent package-size report', () => { 'font-validator-js', 'runtime-baker-host-js', 'runtime-baker-worker-js', - 'text-shaper-js', 'text-shaper-wasm', + 'renderer-neutral-core-total', + 'three-runtime-js', + 'three-renderer-total', 'bitmap-runtime-js', 'mtsdf-runtime-js', 'slug-runtime-js', @@ -65,118 +67,38 @@ describe('independent package-size report', () => { } }); - it('bounds accumulated target-v1 growth from the pre-coverage baseline', () => { - const coverageGrowth = { - // These ceilings exist to keep feature work honest and to push back on duplication, not to model a delivery - // constraint. Target-v1 spent the allowance on two consolidations rather than on new surface: one span cascade - // replaced a style sweep plus seven per-property heaps and now serves both the shaping and paint layers, and the - // Three Bitmap program regained the device-pixel snapping milestone 1 records as a hard contract. - // - // The browser-core allowance was raised once more for the layout tiering: 646 Brotli bytes bought a retained - // layout session that stops a content-box change from re-preparing the paragraph, Unicode and bidi reuse across - // any change that alters neither text nor base direction, positioning that writes typed arrays in place, and the - // opt-in phase profiler that measures all of it. A resize went from 130.78ms to 33.72ms at 25,515 glyphs, so this - // is bytes traded for time rather than new surface, and the raised ceiling keeps the same one-or-two-feature gap. - // - // Raised again for the structure-of-arrays cluster measurement and the pooled instance packing, then lowered - // when the layout profiler came out once its evidence was recorded: that returned 3,026 raw and 253 Brotli, - // mostly from its call sites rather than the module. A reflow lays out in 8.12ms at 25,515 glyphs against a - // 110.40ms pre-optimization baseline, inside the 120Hz budget. The ceiling tracks what the tree actually - // measures, so the next feature meets resistance rather than inherited slack. - // - // The three runtime baselines are re-derived against the tree with merged-v0 deleted, which shed roughly 215 KB - // from each graph, so growth is once again measured from something that exists. browser-core keeps its original - // pre-coverage baseline because deleting v0 did not move it: the root index never referenced v0, v0 re-exported - // the root. Each ceiling leaves roughly one or two features of room and no more, so it starts pushing back soon - // rather than quietly absorbing whatever lands next. - 'browser-core': { - rawBytes: { baseline: 324_269, maximumGrowth: 64_000 }, - minifiedBytes: { baseline: 247_205, maximumGrowth: 38_000 }, - gzipBytes: { baseline: 72_108, maximumGrowth: 10_500 }, - brotliBytes: { baseline: 55_251, maximumGrowth: 8_500 }, - }, - 'bitmap-baker-js': { - rawBytes: { baseline: 17_478, maximumGrowth: 5_700 }, - minifiedBytes: { baseline: 11_682, maximumGrowth: 4_000 }, - gzipBytes: { baseline: 3_893, maximumGrowth: 900 }, - brotliBytes: { baseline: 3_448, maximumGrowth: 800 }, - }, - 'bitmap-baker-wasm': { - rawBytes: { baseline: 606_995, maximumGrowth: 20_000 }, - minifiedBytes: { baseline: 606_995, maximumGrowth: 20_000 }, - gzipBytes: { baseline: 226_702, maximumGrowth: 8_100 }, - brotliBytes: { baseline: 173_552, maximumGrowth: 7_000 }, - }, - 'bitmap-runtime-js': { - rawBytes: { baseline: 89_604, maximumGrowth: 12_000 }, - minifiedBytes: { baseline: 60_671, maximumGrowth: 7_000 }, - gzipBytes: { baseline: 16_081, maximumGrowth: 1_800 }, - brotliBytes: { baseline: 14_229, maximumGrowth: 1_500 }, - }, - 'mtsdf-baker-wasm': { - rawBytes: { baseline: 534_709, maximumGrowth: 18_500 }, - minifiedBytes: { baseline: 534_709, maximumGrowth: 18_500 }, - gzipBytes: { baseline: 208_474, maximumGrowth: 6_700 }, - brotliBytes: { baseline: 163_570, maximumGrowth: 5_800 }, - }, - 'mtsdf-baker-js': { - rawBytes: { baseline: 21_809, maximumGrowth: 5_200 }, - minifiedBytes: { baseline: 15_430, maximumGrowth: 3_800 }, - gzipBytes: { baseline: 4_701, maximumGrowth: 900 }, - brotliBytes: { baseline: 4_176, maximumGrowth: 800 }, - }, - 'mtsdf-runtime-js': { - rawBytes: { baseline: 94_145, maximumGrowth: 12_000 }, - minifiedBytes: { baseline: 63_256, maximumGrowth: 7_000 }, - gzipBytes: { baseline: 16_777, maximumGrowth: 1_800 }, - brotliBytes: { baseline: 14_857, maximumGrowth: 1_500 }, - }, - } as const; + it('reports separately delivered JS and Wasm as exact consumer totals', () => { + const measured = new Map(report.entries.map((entry) => [entry.id, entry])); const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const; - for (const [id, expectation] of Object.entries(coverageGrowth)) { - const entry = report.entries.find((candidate) => candidate.id === id); - expect(entry?.status).toBe('measured'); - if (entry?.status !== 'measured') throw new Error(`Missing measured size entry: ${id}`); - for (const field of fields) { - const { baseline, maximumGrowth } = expectation[field]; - expect(entry[field] - baseline).toBeLessThanOrEqual(maximumGrowth); + for (const [aggregateId, javascriptId] of [ + ['renderer-neutral-core-total', 'browser-core'], + ['three-renderer-total', 'three-runtime-js'], + ] as const) { + const aggregate = measured.get(aggregateId); + const javascript = measured.get(javascriptId); + const wasm = measured.get('text-shaper-wasm'); + expect(aggregate?.format).toBe('aggregate'); + if (aggregate === undefined || javascript === undefined || wasm === undefined) { + throw new Error(`Missing aggregate size inputs for ${aggregateId}`); } + for (const field of fields) expect(aggregate[field]).toBe(javascript[field] + wasm[field]); } - }); - it('bounds retained-capacity growth from the warm-publication baseline', () => { - const retainedCapacityGrowth = { - 'bitmap-runtime-js': { - baseline: { rawBytes: 89_604, minifiedBytes: 60_671, gzipBytes: 16_081, brotliBytes: 14_229 }, - // These ceilings were reviewed against a target-v1 that was missing two things it now carries. The Three - // Bitmap program had no device-pixel snapping, which milestone 1 records as a hard density contract and - // which is what makes this graph reproduce the pinned merged-v0 frame exactly. Spans resolved shaping and - // paint through two unrelated mechanisms that disagreed, replaced by one containment cascade — a net cost, - // since it deleted the previous style sweep and its per-property heaps. - // - // Baselines re-derived after merged-v0 deletion. Brotli stays the tightest of the four because it is what - // ships to browsers. NOTE these three graphs are currently within three bytes of each other because /three - // registers every built-in program at module scope; once technique-paired subpaths restore separation per - // D-158 they will diverge again and these baselines need re-deriving once more. - maximumGrowth: { rawBytes: 12_000, minifiedBytes: 7_000, gzipBytes: 1_800, brotliBytes: 1_500 }, - }, - 'mtsdf-runtime-js': { - baseline: { rawBytes: 94_145, minifiedBytes: 63_256, gzipBytes: 16_777, brotliBytes: 14_857 }, - maximumGrowth: { rawBytes: 12_000, minifiedBytes: 7_000, gzipBytes: 1_800, brotliBytes: 1_500 }, - }, - 'slug-runtime-js': { - baseline: { rawBytes: 79_169, minifiedBytes: 53_547, gzipBytes: 14_214, brotliBytes: 12_612 }, - maximumGrowth: { rawBytes: 12_000, minifiedBytes: 7_000, gzipBytes: 1_800, brotliBytes: 1_500 }, - }, - } as const; - const fields = ['rawBytes', 'minifiedBytes', 'gzipBytes', 'brotliBytes'] as const; - for (const [id, expectation] of Object.entries(retainedCapacityGrowth)) { - const entry = report.entries.find((candidate) => candidate.id === id); - expect(entry?.status).toBe('measured'); - if (entry?.status !== 'measured') throw new Error(`Missing measured size entry: ${id}`); - for (const field of fields) { - expect(entry[field] - expectation.baseline[field]).toBeLessThanOrEqual(expectation.maximumGrowth[field]); + for (const [aggregateId, assetId] of [ + ['delivery-three-inter-bitmap', 'font-inter-bitmap-16-32'], + ['delivery-three-inter-mtsdf', 'font-inter-mtsdf'], + ['delivery-three-inter-slug', 'font-inter-slug'], + ['delivery-three-icons-bitmap', 'font-icons-bitmap-16-32'], + ['delivery-three-icons-mtsdf', 'font-icons-mtsdf'], + ['delivery-three-icons-slug', 'font-icons-slug'], + ] as const) { + const aggregate = measured.get(aggregateId); + const renderer = measured.get('three-renderer-total'); + const asset = measured.get(assetId); + if (aggregate === undefined || renderer === undefined || asset === undefined) { + throw new Error(`Missing delivery size inputs for ${aggregateId}`); } + for (const field of fields) expect(aggregate[field]).toBe(renderer[field] + asset[field]); } }); @@ -199,7 +121,7 @@ describe('independent package-size report', () => { expect(unicode?.status).toBe('measured'); if (core?.status !== 'measured' || unicode?.status !== 'measured') return; expect(unicode.minifiedBytes).toBeGreaterThan(0); - expect(core.minifiedBytes).toBeGreaterThan(unicode.minifiedBytes); + expect(core.minifiedBytes).toBeLessThan(unicode.minifiedBytes); }); it('keeps foreign-host native-tool variance inside complete reviewed budgets', () => { @@ -210,7 +132,6 @@ describe('independent package-size report', () => { 'font-validator-js': [740_402, 584_255, 137_585, 112_927], 'runtime-baker-host-js': [5_264, 3_861, 1_480, 1_322], 'runtime-baker-worker-js': [13_315, 9_010, 3_030, 2_665], - 'text-shaper-js': [43_944, 30_648, 8_798, 7_832], 'text-shaper-wasm': [692_111, 692_111, 258_524, 202_634], 'portable-baker-js': [10_046, 6_647, 2_338, 2_060], 'portable-baker-wasm': [433_755, 433_755, 168_266, 136_961], diff --git a/apps/benchmarks/src/benchmark/paragraph-contract-corpus.ts b/apps/benchmarks/src/benchmark/paragraph-contract-corpus.ts new file mode 100644 index 00000000..76d879f2 --- /dev/null +++ b/apps/benchmarks/src/benchmark/paragraph-contract-corpus.ts @@ -0,0 +1,7 @@ +import cjkContract from '../../fixtures/contracts/paragraph-cjk-layout-v0.json'; + +export const paragraphCjkCoverageText = Object.values(cjkContract.cases) + .map(({ text }) => text) + .join('') + // Variation selectors shape beside their base scalar but do not name standalone cmap glyphs. + .replace(/[\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}]/gu, ''); diff --git a/apps/benchmarks/src/benchmark/scenarios.ts b/apps/benchmarks/src/benchmark/scenarios.ts index 14f59e0a..749b046a 100644 --- a/apps/benchmarks/src/benchmark/scenarios.ts +++ b/apps/benchmarks/src/benchmark/scenarios.ts @@ -1,15 +1,5 @@ import type { BenchmarkScenario } from './contracts'; import { ADVANCED_SHAPING_CASES } from '../workloads/advanced-shaping/scene'; -import paragraphBidiContract from '../../fixtures/contracts/paragraph-bidi-layout-v0.json'; -import cjkContract from '../../fixtures/contracts/paragraph-cjk-layout-v0.json'; -import cjkManifest from '../../fixtures/fonts/noto-sans-cjk-2.004/manifest.json'; -import cjkOracle from '../../fixtures/shaping/noto-sans-cjk/harfrust.json'; - -const paragraphPolicyHash = [ - ...Object.values(paragraphBidiContract.bidi).map(({ layout }) => layout.hash), - ...Object.values(paragraphBidiContract.policies.cases).map(({ layout }) => layout.hash), - paragraphBidiContract.uikit.resolved.layout.hash, -].join(':'); const ADVANCED_SHAPING_HASH = '51ba1d14'; const UPDATED_EXTERNAL_RASTER_GLYPHS = 13; @@ -318,110 +308,20 @@ function reactTextValidation(values: readonly import('./contracts').BenchmarkMea return `${values.length}/${values.length} exact React Text reconciliations + R3F frames`; } -function shapingValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string { - deterministicValidation(values.map((value) => value.hash)); - for (const value of values) { - if (value.metrics?.boundaryCrossings !== 1 || value.metrics.goldenCases !== 8 || value.metrics.planCount !== 3) { - throw new Error('Shaping sample did not preserve its call, corpus, and plan-cache contract'); - } - } - return `${values.length}/${values.length} exact corpus outputs · 1 Wasm call/sample`; -} - -function paragraphValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string { - deterministicValidation(values.map((value) => value.hash)); - for (const value of values) { - if ( - value.metrics?.shapeBoundaryCrossings !== 1 || - value.metrics.reshapeBoundaryCrossings !== 0 || - value.metrics.reflowBoundaryCrossings !== 0 || - value.metrics.measurementCount !== 3 || - value.metrics.positionedGlyphBytes !== 0 - ) { - throw new Error('Paragraph sample did not preserve its prepare-once, cached-reflow contract'); - } - } - return `${values.length}/${values.length} exact paragraph outputs · 0 Wasm reflow calls/sample`; -} - -function paragraphLayoutValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string { +function paragraphContractsValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string { deterministicValidation(values.map((value) => value.hash)); for (const value of values) { if ( - value.hash !== 'bb15bbcc:4f111a3f:e8c0e9d5' || - value.metrics?.shapeBoundaryCrossings !== 1 || - // Zero, because boundary reshaping requested the whole run as context and so returned the glyphs the retained - // shape already held. The pinned hash above is unchanged by removing it, which is the proof. - value.metrics.reshapeBoundaryCrossings !== 0 || - value.metrics.batchedBoundaryLayouts !== 2 || - value.metrics.layoutCount !== 3 || - value.metrics.glyphCount !== 165 - ) { - throw new Error( - `Paragraph layout sample did not preserve its exact SoA and batch contract: hash=${value.hash} shape=${String(value.metrics?.shapeBoundaryCrossings)} reshape=${String(value.metrics?.reshapeBoundaryCrossings)} batched=${String(value.metrics?.batchedBoundaryLayouts)} layouts=${String(value.metrics?.layoutCount)} glyphs=${String(value.metrics?.glyphCount)}`, - ); - } - } - return `${values.length}/${values.length} exact positioned outputs · no reshape crossings`; -} - -function paragraphPolicyValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string { - deterministicValidation(values.map((value) => value.hash)); - for (const value of values) { - if ( - value.hash !== paragraphPolicyHash || value.metrics?.bidiLayoutCount !== 2 || value.metrics.policyLayoutCount !== 9 || + value.metrics.cjkLayoutCount !== 12 || value.metrics.uikitMeasurementCount !== 25 || - value.metrics.uikitLayoutCount !== 1 || - value.metrics.shapeBoundaryCrossings !== 4 || - value.metrics.reshapeBoundaryCrossings !== 0 - ) { - throw new Error( - `Paragraph policy sample did not preserve its bidi, policy, and uikit contract: hash=${value.hash} shape=${String(value.metrics?.shapeBoundaryCrossings)} reshape=${String(value.metrics?.reshapeBoundaryCrossings)}`, - ); - } - } - return `${values.length}/${values.length} exact bidi/policy outputs · current-uikit-shaped flow`; -} - -function cjkUniversalityValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string { - deterministicValidation(values.map((value) => value.hash)); - const corpusGlyphCount = cjkOracle.cases.reduce((sum, fixture) => sum + fixture.glyphs.length, 0); - const sourceUtf16Units = - cjkOracle.cases.reduce((sum, fixture) => sum + fixture.text.length, 0) + - Object.values(cjkContract.cases).reduce((sum, fixture) => sum + fixture.text.length, 0); - for (const value of values) { - const metrics = value.metrics; - if ( - metrics?.sourceUtf16Units !== sourceUtf16Units || - metrics.corpusCaseCount !== cjkOracle.cases.length || - metrics.corpusGlyphCount !== corpusGlyphCount || - metrics.paragraphCaseCount !== Object.keys(cjkContract.cases).length || - metrics.layoutCount !== Object.keys(cjkContract.cases).length * 3 || - metrics.directShapeBoundaryCrossings !== 1 || - metrics.paragraphShapeBoundaryCrossings !== 4 || - metrics.reshapeBoundaryCrossings !== 0 || - metrics.retainedFontBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.rawBytes || - metrics.sourceFontBytes !== cjkManifest.source.fontBytes || - metrics.artifactBytes !== cjkManifest.bake.expectedCore.artifactBytes || - metrics.shapingPayloadRawBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.rawBytes || - metrics.shapingPayloadGzipBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.gzipBytes || - metrics.shapingPayloadBrotliBytes !== cjkManifest.bake.expectedCore.transport.shapingPayload.brotliBytes || - typeof metrics.planCount !== 'number' || - !Number.isFinite(metrics.planCount) || - metrics.planCount < 4 || - typeof metrics.wasmMemoryBytes !== 'number' || - !Number.isFinite(metrics.wasmMemoryBytes) || - metrics.wasmMemoryBytes <= 0 || - !finiteNonnegative(metrics.coldBakeMs) || - !finiteNonnegative(metrics.coldRegistrationMs) || - !finiteNonnegative(metrics.coldShaperInitializationMs) + value.metrics.uikitLayoutCount !== 1 ) { - throw new Error('CJK sample did not preserve its exact shaping, layout, and payload contract'); + throw new Error('Rust paragraph contracts did not execute the complete retained matrix'); } } - return `${values.length}/${values.length} exact CJK corpus + horizontal paragraph outputs`; + return `${values.length}/${values.length} exact bidi, policy, uikit, and CJK contract matrices`; } function advancedShapingValidation(values: readonly import('./contracts').BenchmarkMeasurement[]): string { @@ -655,39 +555,11 @@ export const scenarios: readonly BenchmarkScenario[] = [ validate: (values) => deterministicValidation(values.map((value) => value.hash)), }, { - id: 'shaping-conformance', - label: 'HarfRust shaping conformance', - description: 'Eight pinned runs in one Wasm call with exact SoA output and cache accounting.', - requiredCapabilities: new Set(['shaping', 'font-bytes', 'wasm']), - validate: shapingValidation, - }, - { - id: 'paragraph-measurement', - label: 'Paragraph measurement', - description: 'Exact GLB-backed broad shape followed by cached wide and narrow reflow.', - requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']), - validate: paragraphValidation, - }, - { - id: 'paragraph-layout', - label: 'Positioned paragraph layout', - description: 'Exact natural, wide, and narrow SoA output with cached batched boundary reshape.', - requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']), - validate: paragraphLayoutValidation, - }, - { - id: 'paragraph-bidi-policy', - label: 'Bidi + paragraph policies', - description: 'Exact Amiri bidi, line policies, and current-uikit-shaped retained layout.', - requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']), - validate: paragraphPolicyValidation, - }, - { - id: 'cjk-universality', - label: 'CJK universality', - description: 'Exact pan-CJK source/reduced shaping and horizontal no-space reflow.', + id: 'paragraph-contracts', + label: 'Rust paragraph contracts', + description: 'Exact retained bidi, policy, uikit, and CJK layouts through public Text.', requiredCapabilities: new Set(['paragraph', 'shaping', 'font-bytes', 'wasm']), - validate: cjkUniversalityValidation, + validate: paragraphContractsValidation, }, ]; diff --git a/apps/benchmarks/src/benchmark/shaping-fixture.ts b/apps/benchmarks/src/benchmark/shaping-fixture.ts deleted file mode 100644 index a0f4d703..00000000 --- a/apps/benchmarks/src/benchmark/shaping-fixture.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { RegisteredFont, ShapeBatchRequest, ShapedBatchViews } from '@pmndrs/text'; - -export interface ShapingOracleCase { - readonly id: string; - readonly text: string; - readonly segment: { - readonly direction: 'ltr' | 'rtl'; - readonly script: string; - readonly language: string; - readonly features: readonly string[]; - }; - readonly glyphs: readonly { - readonly glyphId: number; - readonly cluster: number; - readonly xAdvance: number; - readonly yAdvance: number; - readonly xOffset: number; - readonly yOffset: number; - readonly flags: number; - }[]; -} - -export function shapingFixtureBatch( - cases: readonly ShapingOracleCase[], - font: RegisteredFont['handle'], -): ShapeBatchRequest { - const codeUnits: number[] = []; - const features: { tag: string; value: number; start: number; end: number }[] = []; - const runs: ShapeBatchRequest['runs'][number][] = []; - for (const fixture of cases) { - const start = codeUnits.length; - for (let index = 0; index < fixture.text.length; index += 1) { - codeUnits.push(fixture.text.charCodeAt(index)); - } - const end = codeUnits.length; - const featureStart = features.length; - for (const source of fixture.segment.features) { - const match = /^(.{4})(?:=(\d+))?$/.exec(source); - if (match === null) throw new Error(`unsupported shaping fixture feature ${source}`); - features.push({ - tag: match[1]!, - value: match[2] === undefined ? 1 : Number(match[2]), - start, - end, - }); - } - runs.push({ - font, - textStart: start, - textEnd: end, - direction: fixture.segment.direction, - script: fixture.segment.script, - language: fixture.segment.language, - clusterLevel: 0, - flags: 0x40, - featureStart, - featureCount: features.length - featureStart, - }); - } - return { textUtf16: Uint16Array.from(codeUnits), runs, features }; -} - -export function assertShapingFixture( - shaped: ShapedBatchViews, - font: RegisteredFont['handle'], - cases: readonly ShapingOracleCase[], -): void { - exactArray('fontHandles', shaped.fontHandles, [font]); - exactArray( - 'runFontSlots', - shaped.runFontSlots, - cases.map(() => 0), - ); - let glyphStart = 0; - let textStart = 0; - for (const [run, fixture] of cases.entries()) { - if (shaped.runGlyphStarts[run] !== glyphStart) { - throw new Error(`shaping fixture ${fixture.id} has an unexpected glyph start`); - } - if (shaped.runGlyphCounts[run] !== fixture.glyphs.length) { - throw new Error(`shaping fixture ${fixture.id} has an unexpected glyph count`); - } - for (const [local, expected] of fixture.glyphs.entries()) { - const glyph = glyphStart + local; - exactValue(fixture.id, 'glyphId', local, shaped.glyphIds[glyph], expected.glyphId); - exactValue(fixture.id, 'cluster', local, shaped.clusters[glyph], expected.cluster + textStart); - exactValue(fixture.id, 'xAdvance', local, shaped.xAdvances[glyph], expected.xAdvance); - exactValue(fixture.id, 'yAdvance', local, shaped.yAdvances[glyph], expected.yAdvance); - exactValue(fixture.id, 'xOffset', local, shaped.xOffsets[glyph], expected.xOffset); - exactValue(fixture.id, 'yOffset', local, shaped.yOffsets[glyph], expected.yOffset); - exactValue(fixture.id, 'flags', local, shaped.glyphFlags[glyph], expected.flags); - } - glyphStart += fixture.glyphs.length; - textStart += fixture.text.length; - } - if (glyphStart !== shaped.glyphIds.length) { - throw new Error('shaping output contains trailing glyphs outside the pinned corpus'); - } -} - -export function shapedFixtureBytes(shaped: ShapedBatchViews): number { - return shapingArrays(shaped).reduce((sum, values) => sum + values.byteLength, 0); -} - -export function hashShapedFixture(shaped: ShapedBatchViews): string { - let hash = 2_166_136_261; - for (const values of shapingArrays(shaped)) { - hash = Math.imul(hash ^ values.length, 16_777_619); - for (let index = 0; index < values.length; index += 1) { - hash = Math.imul(hash ^ (values[index]! >>> 0), 16_777_619); - } - } - return (hash >>> 0).toString(16).padStart(8, '0'); -} - -function shapingArrays(shaped: ShapedBatchViews): readonly (Uint16Array | Uint32Array | Int32Array)[] { - return [ - shaped.fontHandles, - shaped.runFontSlots, - shaped.runGlyphStarts, - shaped.runGlyphCounts, - shaped.glyphIds, - shaped.clusters, - shaped.xAdvances, - shaped.yAdvances, - shaped.xOffsets, - shaped.yOffsets, - shaped.glyphFlags, - ]; -} - -function exactArray(label: string, actual: ArrayLike, expected: readonly number[]): void { - if (actual.length !== expected.length) throw new Error(`${label} length differs from its fixture`); - for (let index = 0; index < expected.length; index += 1) { - exactValue('batch', label, index, actual[index], expected[index]); - } -} - -function exactValue( - fixture: string, - field: string, - index: number, - actual: number | undefined, - expected: number | undefined, -): void { - if (actual !== expected) { - throw new Error(`shaping fixture ${fixture}.${field}[${index}] differs: ${String(actual)} !== ${String(expected)}`); - } -} diff --git a/apps/benchmarks/src/benchmark/targets/conformance/cjk-universality.ts b/apps/benchmarks/src/benchmark/targets/conformance/cjk-universality.ts deleted file mode 100644 index f967a3dc..00000000 --- a/apps/benchmarks/src/benchmark/targets/conformance/cjk-universality.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { - createParagraphEngine, - createRuntimeShaper, - FontRegistry, - type LayoutParagraph as Paragraph, - type ParagraphConstraints, - type ParagraphLayout, - type ParagraphStyle, - type RegisteredFont, - type RuntimeShaper, - type ShapeBatchRequest, -} from '@pmndrs/text'; -import cjkContract from '../../../../fixtures/contracts/paragraph-cjk-layout-v0.json'; -import cjkFontUrl from '../../../../fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf?url'; -import cjkManifest from '../../../../fixtures/fonts/noto-sans-cjk-2.004/manifest.json'; -import cjkOracle from '../../../../fixtures/shaping/noto-sans-cjk/harfrust.json'; -import type { BenchmarkTarget } from '../../contracts'; -import { exactValue } from '../../exact-value'; -import { hashParagraphLayouts, paragraphLayoutContract } from '../../paragraph-layout-digest'; -import { - assertShapingFixture, - hashShapedFixture, - shapedFixtureBytes, - shapingFixtureBatch, - type ShapingOracleCase, -} from '../../shaping-fixture'; -import { loadDirectWasmDependencies } from '../shared/direct-wasm'; - -interface ContractCase { - readonly text: string; - readonly style: ParagraphStyle; - readonly layouts: Readonly>>>; -} - -const oracleCases = cjkOracle.cases as readonly ShapingOracleCase[]; -const paragraphCases = Object.values(cjkContract.cases) as readonly ContractCase[]; -const paragraphConstraints = cjkContract.constraints as Readonly>; -let state: CjkState | undefined; - -interface CjkState { - readonly font: RegisteredFont; - readonly shaper: RuntimeShaper; - readonly paragraphs: readonly Paragraph[]; - readonly shapingRequest: ShapeBatchRequest; - readonly calls: { shape: number; reshape: number }; - readonly cold: { bakeMs: number; registrationMs: number; shaperInitializationMs: number }; -} - -export const cjkUniversalityTarget: BenchmarkTarget = { - id: 'cjk-universality', - label: 'CJK universality', - detail: 'Noto Sans CJK · exact shaping + horizontal paragraphs', - color: 'cyan', - capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']), - status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'), - load: async () => { - if (state !== undefined) return; - const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies(); - const [bakerResponse, shaperResponse, fontResponse] = await Promise.all([ - fetch(bakerWasmUrl), - fetch(shaperWasmUrl), - fetch(cjkFontUrl), - ]); - for (const [label, response] of [ - ['font baker Wasm', bakerResponse], - ['text shaper Wasm', shaperResponse], - ['CJK font fixture', fontResponse], - ] as const) { - if (!response.ok) throw new Error(`unable to load ${label} (${response.status})`); - } - const [bakerWasm, shaperWasm, source] = await Promise.all([ - bakerResponse.arrayBuffer(), - shaperResponse.arrayBuffer(), - fontResponse.arrayBuffer(), - ]); - const baker = await createFontBaker(bakerWasm); - const bakeStart = performance.now(); - const artifact = baker.bake({ - source: new Uint8Array(source), - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0]; - const bakeMs = performance.now() - bakeStart; - if (artifact === undefined || artifact.sha256 !== cjkManifest.bake.expectedCore.artifactSha256) { - throw new Error('CJK bake differs from the authenticated artifact contract'); - } - const registry = new FontRegistry(); - const registrationStart = performance.now(); - const font = await registry.registerAsset(artifact.bytes); - const registrationMs = performance.now() - registrationStart; - if (font.shapingHash !== cjkManifest.bake.expectedCore.shapingHash) { - throw new Error('CJK registration differs from the authenticated shaping identity'); - } - const shaperStart = performance.now(); - const runtime = await createRuntimeShaper({ registry, wasm: shaperWasm }); - runtime.registerFont(font); - const shaperInitializationMs = performance.now() - shaperStart; - const calls = { shape: 0, reshape: 0 }; - const engine = createParagraphEngine({ shaper: observeShaper(runtime, calls) }); - const paragraphs = paragraphCases.map((fixture) => - engine.create({ text: fixture.text, font: font.handle, style: fixture.style }), - ); - state = { - font, - shaper: runtime, - paragraphs, - shapingRequest: shapingFixtureBatch(oracleCases, font.handle), - calls, - cold: { bakeMs, registrationMs, shaperInitializationMs }, - }; - }, - run: async () => { - if (state === undefined) throw new Error('CJK universality target was not loaded'); - const shaped = state.shaper.shapeBatch(state.shapingRequest); - assertShapingFixture(shaped, state.font.handle, oracleCases); - const layouts: ParagraphLayout[] = []; - for (const [caseIndex, fixture] of paragraphCases.entries()) { - const paragraph = state.paragraphs[caseIndex]; - if (paragraph === undefined) throw new Error(`CJK paragraph ${caseIndex} is missing`); - for (const [constraintId, constraints] of Object.entries(paragraphConstraints)) { - const layout = paragraph.layout(constraints); - assertExactContract( - `${caseIndex}.${constraintId}`, - paragraphLayoutContract(layout), - fixture.layouts[constraintId], - ); - layouts.push(layout); - } - } - const memory = state.shaper.memoryReport(); - return { - bytes: shapedFixtureBytes(shaped) + layouts.reduce((sum, layout) => sum + layoutBytes(layout), 0), - hash: `${hashShapedFixture(shaped)}:${hashParagraphLayouts(layouts)}`, - metrics: { - sourceUtf16Units: - oracleCases.reduce((sum, fixture) => sum + fixture.text.length, 0) + - paragraphCases.reduce((sum, fixture) => sum + fixture.text.length, 0), - corpusCaseCount: oracleCases.length, - corpusGlyphCount: shaped.glyphIds.length, - paragraphCaseCount: paragraphCases.length, - layoutCount: layouts.length, - directShapeBoundaryCrossings: 1, - paragraphShapeBoundaryCrossings: state.calls.shape, - reshapeBoundaryCrossings: state.calls.reshape, - planCount: memory.planCount, - retainedFontBytes: memory.retainedFontBytes, - wasmMemoryBytes: memory.wasmMemoryBytes, - sourceFontBytes: cjkManifest.source.fontBytes, - artifactBytes: cjkManifest.bake.expectedCore.artifactBytes, - shapingPayloadRawBytes: cjkManifest.bake.expectedCore.transport.shapingPayload.rawBytes, - shapingPayloadGzipBytes: cjkManifest.bake.expectedCore.transport.shapingPayload.gzipBytes, - shapingPayloadBrotliBytes: cjkManifest.bake.expectedCore.transport.shapingPayload.brotliBytes, - coldBakeMs: state.cold.bakeMs, - coldRegistrationMs: state.cold.registrationMs, - coldShaperInitializationMs: state.cold.shaperInitializationMs, - }, - }; - }, - dispose: async () => { - if (state === undefined) return; - for (const paragraph of state.paragraphs) paragraph.dispose(); - state.shaper.dispose(); - state.font.dispose(); - state = undefined; - }, -}; - -function observeShaper(runtime: RuntimeShaper, calls: { shape: number; reshape: number }): RuntimeShaper { - return { - registry: runtime.registry, - registerFont: (font) => runtime.registerFont(font), - disposeFont: (font) => runtime.disposeFont(font), - analyzeBidi: (text, direction) => runtime.analyzeBidi(text, direction), - shapeBatch: (request) => { - calls.shape += 1; - return runtime.shapeBatch(request); - }, - reshapeRanges: (request) => { - calls.reshape += 1; - return runtime.reshapeRanges(request); - }, - memoryReport: () => runtime.memoryReport(), - dispose: () => runtime.dispose(), - }; -} - -function assertExactContract( - label: string, - actual: Readonly>, - expected: Readonly> | undefined, -): void { - if (expected === undefined || !exactValue(actual, expected)) { - throw new Error(`${label} differs from the exact CJK paragraph contract`); - } -} - -function layoutBytes(layout: ParagraphLayout): number { - return [ - layout.fontHandles, - layout.glyphFontSlots, - layout.glyphIds, - layout.clusters, - layout.glyphFontSizes, - layout.x, - layout.y, - layout.glyphFlags, - layout.lineTextStarts, - layout.lineTextEnds, - layout.lineGlyphStarts, - layout.lineGlyphCounts, - layout.lineBaselines, - layout.lineAdvances, - ].reduce((sum, values) => sum + values.byteLength, 0); -} diff --git a/apps/benchmarks/src/benchmark/targets/conformance/direct-runtime.ts b/apps/benchmarks/src/benchmark/targets/conformance/direct-runtime.ts deleted file mode 100644 index ca636258..00000000 --- a/apps/benchmarks/src/benchmark/targets/conformance/direct-runtime.ts +++ /dev/null @@ -1,654 +0,0 @@ -import { - createParagraphEngine, - createRuntimeShaper, - FontRegistry, - type LayoutParagraph as Paragraph, - type ParagraphConstraints, - type ParagraphLayout, - type ParagraphMeasurement, - type ParagraphStyle, - type RegisteredFont, - type RuntimeShaper, - type ShapeBatchRequest, -} from '@pmndrs/text'; -import canonicalFontUrl from '../../../../fixtures/fonts/inter-v4.1/Inter-Regular.ttf?url'; -import amiriFontUrl from '../../../../fixtures/fonts/amiri-1.002/Amiri-Regular.ttf?url'; -import canonicalParagraphLayout from '../../../../fixtures/contracts/paragraph-layout-v0.json'; -import canonicalShapingOracle from '../../../../fixtures/shaping/inter-regular/harfrust.json'; -import paragraphBidiContract from '../../../../fixtures/contracts/paragraph-bidi-layout-v0.json'; -import type { BenchmarkTarget } from '../../contracts'; -import { exactValue as exactJsonValue } from '../../exact-value'; -import { hashParagraphLayout, hashParagraphLayouts, paragraphLayoutBytes } from '../../paragraph-layout-digest'; -import { - assertShapingFixture, - hashShapedFixture, - shapedFixtureBytes, - shapingFixtureBatch, - type ShapingOracleCase, -} from '../../shaping-fixture'; -import { createUikitLayoutFixture, YogaMeasureMode } from '../../uikit-layout-fixture'; -import { loadDirectWasmDependencies } from '../shared/direct-wasm'; - -const shapingCases = canonicalShapingOracle.cases as readonly ShapingOracleCase[]; -let runtimeShaper: RuntimeShaper | undefined; -let runtimeShaperFont: RegisteredFont | undefined; -let runtimeShapingRequest: ShapeBatchRequest | undefined; -let runtimeShaperColdStartMs = 0; - -const harfrustShaperTarget: BenchmarkTarget = { - id: 'harfrust-shaper', - label: 'HarfRust Wasm shaper', - detail: 'validated GLB · 8 golden runs · 1 coarse call', - color: 'amber', - capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping']), - status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'), - load: async () => { - if (runtimeShaper !== undefined && runtimeShaperFont !== undefined && runtimeShapingRequest !== undefined) return; - const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies(); - const [bakerResponse, shaperResponse, fontResponse] = await Promise.all([ - fetch(bakerWasmUrl), - fetch(shaperWasmUrl), - fetch(canonicalFontUrl), - ]); - if (!bakerResponse.ok) throw new Error(`Unable to load font baker Wasm (${bakerResponse.status})`); - if (!shaperResponse.ok) throw new Error(`Unable to load text shaper Wasm (${shaperResponse.status})`); - if (!fontResponse.ok) throw new Error(`Unable to load canonical font fixture (${fontResponse.status})`); - const [bakerWasm, shaperWasm, source] = await Promise.all([ - bakerResponse.arrayBuffer(), - shaperResponse.arrayBuffer(), - fontResponse.arrayBuffer(), - ]); - const directBaker = await createFontBaker(bakerWasm); - const baked = directBaker.bake({ - source: new Uint8Array(source), - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }); - const artifact = baked.artifacts[0]; - if (artifact === undefined) throw new Error('Font baker returned no shaping artifact'); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact.bytes); - const coldStart = performance.now(); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - runtimeShaperColdStartMs = performance.now() - coldStart; - runtimeShaper = shaper; - runtimeShaperFont = font; - runtimeShapingRequest = shapingFixtureBatch(shapingCases, font.handle); - }, - run: async () => { - if (runtimeShaper === undefined || runtimeShaperFont === undefined || runtimeShapingRequest === undefined) { - throw new Error('HarfRust shaper target was not loaded'); - } - const shapeStart = performance.now(); - const shaped = runtimeShaper.shapeBatch(runtimeShapingRequest); - const shapeCallMs = performance.now() - shapeStart; - assertShapingFixture(shaped, runtimeShaperFont.handle, shapingCases); - const memory = runtimeShaper.memoryReport(); - return { - bytes: shapedFixtureBytes(shaped), - hash: hashShapedFixture(shaped), - metrics: { - boundaryCrossings: 1, - coldStartMs: runtimeShaperColdStartMs, - shapeCallMs, - goldenCases: shapingCases.length, - glyphCount: shaped.glyphIds.length, - planCount: memory.planCount, - retainedFontBytes: memory.retainedFontBytes, - wasmMemoryBytes: memory.wasmMemoryBytes, - }, - }; - }, - dispose: async () => { - runtimeShaper?.dispose(); - runtimeShaperFont?.dispose(); - runtimeShaper = undefined; - runtimeShaperFont = undefined; - runtimeShapingRequest = undefined; - runtimeShaperColdStartMs = 0; - }, -}; - -const paragraphGolden = { - natural: canonicalParagraphLayout.goldens.natural.measurement, - wide: canonicalParagraphLayout.goldens.wide.measurement, - narrow: canonicalParagraphLayout.goldens.narrow.measurement, -}; -const paragraphLayoutGolden = { - natural: canonicalParagraphLayout.goldens.natural.layout, - wide: canonicalParagraphLayout.goldens.wide.layout, - narrow: canonicalParagraphLayout.goldens.narrow.layout, -}; - -let paragraphShaper: RuntimeShaper | undefined; -let paragraphFont: RegisteredFont | undefined; -let measuredParagraph: Paragraph | undefined; -let paragraphShapeCalls = 0; -let paragraphReshapeCalls = 0; - -async function loadParagraphFixture(): Promise { - if (paragraphShaper !== undefined && paragraphFont !== undefined && measuredParagraph !== undefined) return; - const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies(); - const [bakerResponse, shaperResponse, fontResponse] = await Promise.all([ - fetch(bakerWasmUrl), - fetch(shaperWasmUrl), - fetch(canonicalFontUrl), - ]); - if (!bakerResponse.ok) throw new Error(`Unable to load font baker Wasm (${bakerResponse.status})`); - if (!shaperResponse.ok) throw new Error(`Unable to load text shaper Wasm (${shaperResponse.status})`); - if (!fontResponse.ok) throw new Error(`Unable to load canonical font fixture (${fontResponse.status})`); - const [bakerWasm, shaperWasm, source] = await Promise.all([ - bakerResponse.arrayBuffer(), - shaperResponse.arrayBuffer(), - fontResponse.arrayBuffer(), - ]); - const directBaker = await createFontBaker(bakerWasm); - const artifact = directBaker.bake({ - source: new Uint8Array(source), - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0]; - if (artifact === undefined) throw new Error('Font baker returned no paragraph artifact'); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact.bytes); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - const observedShaper: RuntimeShaper = { - registry: shaper.registry, - registerFont: (registered) => shaper.registerFont(registered), - disposeFont: (registered) => shaper.disposeFont(registered), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => { - paragraphShapeCalls += 1; - return shaper.shapeBatch(request); - }, - reshapeRanges: (request) => { - paragraphReshapeCalls += 1; - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; - const fixture = shapingCases.find(({ id }) => id === 'paragraph'); - if (fixture === undefined) throw new Error('Canonical paragraph shaping fixture is missing'); - const expectedNaturalWidth = - (fixture.glyphs.reduce((sum, glyph) => sum + glyph.xAdvance, 0) * 32) / font.metrics.unitsPerEm; - if (expectedNaturalWidth !== paragraphGolden.natural.width) { - throw new Error('Paragraph width golden is not derived from the pinned HarfRust advances'); - } - const paragraph = createParagraphEngine({ shaper: observedShaper }).create({ - text: fixture.text, - font: font.handle, - style: { - fontSize: 32, - lineHeight: 1.3, - language: 'en', - direction: 'ltr', - features: [], - }, - }); - paragraphShaper = shaper; - paragraphFont = font; - measuredParagraph = paragraph; -} - -async function disposeParagraphFixture(): Promise { - measuredParagraph?.dispose(); - paragraphShaper?.dispose(); - paragraphFont?.dispose(); - measuredParagraph = undefined; - paragraphShaper = undefined; - paragraphFont = undefined; - paragraphShapeCalls = 0; - paragraphReshapeCalls = 0; -} - -const paragraphTarget: BenchmarkTarget = { - id: 'paragraph-engine', - label: 'JavaScript paragraph engine', - detail: 'validated GLB · exact HarfRust widths · cached reflow', - color: 'violet', - capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']), - status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'), - load: loadParagraphFixture, - run: async () => { - if (measuredParagraph === undefined) throw new Error('Paragraph target was not loaded'); - const shapeCalls = paragraphShapeCalls; - const reshapeCalls = paragraphReshapeCalls; - const natural = measuredParagraph.measure(); - const wideConstraints = { width: { mode: 'at-most' as const, size: 720 } }; - const wide = measuredParagraph.measure(wideConstraints); - const cachedWide = measuredParagraph.measure(wideConstraints); - const narrow = measuredParagraph.measure({ width: { mode: 'at-most', size: 360 } }); - exactMeasurement('natural', natural, paragraphGolden.natural); - exactMeasurement('wide', wide, paragraphGolden.wide); - exactMeasurement('narrow', narrow, paragraphGolden.narrow); - if (cachedWide !== wide) throw new Error('Equivalent paragraph constraints missed the cache'); - if (paragraphShapeCalls !== shapeCalls || paragraphReshapeCalls !== reshapeCalls) { - throw new Error('Width-only paragraph reflow crossed the Wasm boundary'); - } - return { - bytes: 3 * 7 * Float64Array.BYTES_PER_ELEMENT, - hash: hashMeasurements([natural, wide, narrow]), - metrics: { - measurementCount: 3, - positionedGlyphBytes: 0, - reflowBoundaryCrossings: 0, - reshapeBoundaryCrossings: paragraphReshapeCalls, - shapeBoundaryCrossings: paragraphShapeCalls, - }, - }; - }, - dispose: disposeParagraphFixture, -}; - -const paragraphLayoutTarget: BenchmarkTarget = { - id: 'paragraph-layout-engine', - label: 'Positioned paragraph engine', - detail: 'validated GLB · exact SoA · batched boundary reshape', - color: 'cyan', - capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']), - status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'), - load: loadParagraphFixture, - run: async () => { - if (measuredParagraph === undefined || paragraphFont === undefined) { - throw new Error('Paragraph layout target was not loaded'); - } - const natural = measuredParagraph.layout(); - const wideConstraints = { width: { mode: 'at-most' as const, size: 720 } }; - const wide = measuredParagraph.layout(wideConstraints); - const cachedWide = measuredParagraph.layout(wideConstraints); - const narrow = measuredParagraph.layout({ width: { mode: 'at-most', size: 360 } }); - if (cachedWide !== wide) throw new Error('Equivalent positioned constraints missed the cache'); - exactParagraphLayout('natural', natural, paragraphLayoutGolden.natural, paragraphFont.handle); - exactParagraphLayout('wide', wide, paragraphLayoutGolden.wide, paragraphFont.handle); - exactParagraphLayout('narrow', narrow, paragraphLayoutGolden.narrow, paragraphFont.handle); - return { - bytes: paragraphLayoutBytes(natural) + paragraphLayoutBytes(wide) + paragraphLayoutBytes(narrow), - hash: hashParagraphLayouts([natural, wide, narrow]), - metrics: { - batchedBoundaryLayouts: 2, - glyphCount: natural.glyphIds.length + wide.glyphIds.length + narrow.glyphIds.length, - layoutCount: 3, - reshapeBoundaryCrossings: paragraphReshapeCalls, - shapeBoundaryCrossings: paragraphShapeCalls, - }, - }; - }, - dispose: disposeParagraphFixture, -}; - -interface ContractLayout { - readonly measurement: ParagraphMeasurement; - readonly hash: string; - readonly glyphFontSlots?: readonly number[]; - readonly glyphIds: readonly number[]; - readonly clusters: readonly number[]; - readonly glyphFontSizes?: readonly number[]; - readonly x: readonly number[]; - readonly y?: readonly number[]; - readonly glyphFlags?: readonly number[]; - readonly lineTextStarts: readonly number[]; - readonly lineTextEnds: readonly number[]; - readonly lineGlyphStarts: readonly number[]; - readonly lineGlyphCounts: readonly number[]; - readonly lineBaselines: readonly number[]; - readonly lineAdvances: readonly number[]; -} - -let policyShaper: RuntimeShaper | undefined; -let policyFonts: readonly RegisteredFont[] = []; -let bidiParagraphs: readonly Paragraph[] = []; -let policyParagraph: Paragraph | undefined; -let uikitParagraph: Paragraph | undefined; -let policyShapeCalls = 0; -let policyReshapeCalls = 0; - -async function loadParagraphPolicyFixture(): Promise { - if (policyShaper !== undefined) return; - const { bakerWasmUrl, createFontBaker, shaperWasmUrl } = await loadDirectWasmDependencies(); - const [bakerResponse, shaperResponse, interResponse, amiriResponse] = await Promise.all([ - fetch(bakerWasmUrl), - fetch(shaperWasmUrl), - fetch(canonicalFontUrl), - fetch(amiriFontUrl), - ]); - for (const [label, response] of [ - ['font baker Wasm', bakerResponse], - ['text shaper Wasm', shaperResponse], - ['Inter fixture', interResponse], - ['Amiri fixture', amiriResponse], - ] as const) { - if (!response.ok) throw new Error(`Unable to load ${label} (${response.status})`); - } - const [bakerBytes, shaperBytes, interSource, amiriSource] = await Promise.all([ - bakerResponse.arrayBuffer(), - shaperResponse.arrayBuffer(), - interResponse.arrayBuffer(), - amiriResponse.arrayBuffer(), - ]); - const directBaker = await createFontBaker(bakerBytes); - const bakeArtifact = (source: ArrayBuffer) => { - const artifact = directBaker.bake({ - source: new Uint8Array(source), - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0]; - if (artifact === undefined) throw new Error('Font baker returned no paragraph policy artifact'); - return artifact; - }; - const interArtifact = bakeArtifact(interSource); - const amiriArtifact = bakeArtifact(amiriSource); - const registry = new FontRegistry(); - const [inter, amiri] = await Promise.all([ - registry.registerAsset(interArtifact.bytes), - registry.registerAsset(amiriArtifact.bytes), - ]); - if ( - inter.shapingHash !== paragraphBidiContract.fonts.inter.shapingHash || - amiri.shapingHash !== paragraphBidiContract.fonts.amiri.shapingHash - ) { - throw new Error('Paragraph policy fixtures retained unexpected shaping identities'); - } - const shaper = await createRuntimeShaper({ registry, wasm: shaperBytes }); - const observed: RuntimeShaper = { - registry, - registerFont: (font) => shaper.registerFont(font), - disposeFont: (font) => shaper.disposeFont(font), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => { - policyShapeCalls += 1; - return shaper.shapeBatch(request); - }, - reshapeRanges: (request) => { - policyReshapeCalls += 1; - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; - const engine = createParagraphEngine({ shaper: observed }); - bidiParagraphs = Object.values(paragraphBidiContract.bidi).map((fixture) => - engine.create({ - text: fixture.text, - font: amiri.handle, - style: fixture.style as ParagraphStyle, - }), - ); - policyParagraph = engine.create({ - text: paragraphBidiContract.policies.text, - font: inter.handle, - style: paragraphBidiContract.policies.style as ParagraphStyle, - }); - uikitParagraph = engine.create({ - text: paragraphBidiContract.uikit.input.text, - font: inter.handle, - style: paragraphBidiContract.uikit.input.style as ParagraphStyle, - }); - policyShaper = shaper; - policyFonts = [inter, amiri]; -} - -async function disposeParagraphPolicyFixture(): Promise { - for (const paragraph of [...bidiParagraphs, policyParagraph, uikitParagraph]) paragraph?.dispose(); - policyShaper?.dispose(); - for (const font of policyFonts) font.dispose(); - policyShaper = undefined; - policyFonts = []; - bidiParagraphs = []; - policyParagraph = undefined; - uikitParagraph = undefined; - policyShapeCalls = 0; - policyReshapeCalls = 0; -} - -const paragraphPolicyTarget: BenchmarkTarget = { - id: 'paragraph-bidi-policy', - label: 'Bidi + paragraph policies', - detail: 'Amiri GLB · UAX #9 · alignment · truncation · uikit seam', - color: 'amber', - capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']), - status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'), - load: loadParagraphPolicyFixture, - run: async () => { - if (policyParagraph === undefined || uikitParagraph === undefined) { - throw new Error('Paragraph policy target was not loaded'); - } - const layouts: ParagraphLayout[] = []; - for (const [index, fixture] of Object.values(paragraphBidiContract.bidi).entries()) { - const layout = bidiParagraphs[index]?.layout(fixture.constraints as ParagraphConstraints); - if (layout === undefined) throw new Error('Bidi paragraph fixture is missing'); - exactContractLayout(`bidi.${index}`, layout, fixture.layout); - layouts.push(layout); - } - for (const [id, fixture] of Object.entries(paragraphBidiContract.policies.cases)) { - const layout = policyParagraph.layout(fixture.constraints as ParagraphConstraints); - exactContractLayout(`policy.${id}`, layout, fixture.layout); - layouts.push(layout); - } - - const uikit = createUikitLayoutFixture(uikitParagraph, paragraphBidiContract.uikit.policy as ParagraphConstraints); - const custom = uikit.customLayouting(); - exactObject( - 'uikit.customLayouting', - { - minWidth: custom.minWidth, - minHeight: custom.minHeight, - firstBaseline: custom.firstBaseline, - }, - paragraphBidiContract.uikit.customLayouting, - ); - const natural = custom.measure(NaN, YogaMeasureMode.Undefined, NaN, YogaMeasureMode.Undefined); - const atMost = custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost); - const exactWidth = custom.measure(420.001, YogaMeasureMode.Exactly, NaN, YogaMeasureMode.Undefined); - for (let index = 0; index < 20; index += 1) { - exactObject( - 'uikit.repeatedAtMost', - custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost), - paragraphBidiContract.uikit.measurements.atMost, - ); - } - exactObject('uikit.natural', natural, paragraphBidiContract.uikit.measurements.natural); - exactObject('uikit.atMost', atMost, paragraphBidiContract.uikit.measurements.atMost); - exactObject('uikit.exactWidth', exactWidth, paragraphBidiContract.uikit.measurements.exactWidth); - exactObject( - 'uikit.definite', - uikit.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly), - paragraphBidiContract.uikit.measurements.definite, - ); - if (uikit.calls.layout !== 0) throw new Error('uikit measurement materialized glyph arrays'); - const resolved = uikit.layoutResolvedBox( - paragraphBidiContract.uikit.resolved.outerSize as unknown as readonly [number, number], - paragraphBidiContract.uikit.resolved.padding as unknown as readonly [number, number, number, number], - paragraphBidiContract.uikit.resolved.border as unknown as readonly [number, number, number, number], - ); - exactObject('uikit.contentBox', resolved.contentBox, paragraphBidiContract.uikit.resolved.contentBox); - exactArray('uikit.centeredX', resolved.centeredX, paragraphBidiContract.uikit.resolved.centeredX); - exactArray('uikit.centeredY', resolved.centeredY, paragraphBidiContract.uikit.resolved.centeredY); - exactContractLayout('uikit.layout', resolved.layout, paragraphBidiContract.uikit.resolved.layout); - layouts.push(resolved.layout); - - return { - bytes: - layouts.reduce((sum, layout) => sum + paragraphLayoutBytes(layout), 0) + - resolved.centeredX.byteLength + - resolved.centeredY.byteLength, - hash: hashParagraphLayouts(layouts), - metrics: { - bidiLayoutCount: 2, - policyLayoutCount: 9, - uikitMeasurementCount: uikit.calls.measure, - uikitLayoutCount: uikit.calls.layout, - shapeBoundaryCrossings: policyShapeCalls, - reshapeBoundaryCrossings: policyReshapeCalls, - }, - }; - }, - dispose: disposeParagraphPolicyFixture, -}; - -interface ParagraphLayoutGolden { - readonly hash: string; - readonly glyphCount: number; - readonly lineTextStarts: readonly number[]; - readonly lineTextEnds: readonly number[]; - readonly lineGlyphStarts: readonly number[]; - readonly lineGlyphCounts: readonly number[]; - readonly lineBaselines: readonly number[]; - readonly lineAdvances: readonly number[]; -} - -function exactParagraphLayout( - label: string, - layout: ParagraphLayout, - golden: ParagraphLayoutGolden, - fontHandle: RegisteredFont['handle'], -): void { - const fixture = shapingCases.find(({ id }) => id === 'paragraph'); - if (fixture === undefined) throw new Error('Canonical paragraph shaping fixture is missing'); - exactArray( - `${label}.glyphIds`, - layout.glyphIds, - fixture.glyphs.map(({ glyphId }) => glyphId), - ); - exactArray( - `${label}.clusters`, - layout.clusters, - fixture.glyphs.map(({ cluster }) => cluster), - ); - exactArray( - `${label}.glyphFlags`, - layout.glyphFlags, - fixture.glyphs.map(({ flags }) => flags), - ); - if ( - layout.fontHandles.length !== 1 || - layout.fontHandles[0] !== fontHandle || - layout.glyphFontSlots.some((slot) => slot !== 0) - ) { - throw new Error(`${label} paragraph layout did not normalize its font slots`); - } - if (layout.glyphIds.length !== golden.glyphCount) { - throw new Error(`${label} paragraph layout has an unexpected glyph count`); - } - exactArray(`${label}.lineTextStarts`, layout.lineTextStarts, golden.lineTextStarts); - exactArray(`${label}.lineTextEnds`, layout.lineTextEnds, golden.lineTextEnds); - exactArray(`${label}.lineGlyphStarts`, layout.lineGlyphStarts, golden.lineGlyphStarts); - exactArray(`${label}.lineGlyphCounts`, layout.lineGlyphCounts, golden.lineGlyphCounts); - exactArray(`${label}.lineBaselines`, layout.lineBaselines, golden.lineBaselines); - exactArray(`${label}.lineAdvances`, layout.lineAdvances, golden.lineAdvances); - const hash = hashParagraphLayout(layout); - if (hash !== golden.hash) throw new Error(`${label} paragraph layout hash ${hash} != ${golden.hash}`); -} - -function exactContractLayout(label: string, layout: ParagraphLayout, golden: ContractLayout): void { - exactMeasurement(label, layout, golden.measurement); - for (const field of [ - 'glyphIds', - 'clusters', - 'x', - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', - ] as const) { - exactArray(`${label}.${field}`, layout[field], golden[field]); - } - for (const field of ['glyphFontSlots', 'glyphFontSizes', 'y', 'glyphFlags'] as const) { - const expected = golden[field]; - if (expected !== undefined) exactArray(`${label}.${field}`, layout[field], expected); - } - const hash = hashParagraphLayout(layout); - if (hash !== golden.hash) throw new Error(`${label} hash ${hash} != ${golden.hash}`); -} - -function exactObject( - label: string, - actual: Readonly>, - expected: Readonly>, -): void { - if (!exactJsonValue(actual, expected)) { - throw new Error(`${label} differs`); - } -} - -function exactMeasurement(label: string, actual: ParagraphMeasurement, expected: ParagraphMeasurement): void { - for (const key of [ - 'width', - 'height', - 'contentWidth', - 'contentHeight', - 'firstBaseline', - 'lastBaseline', - 'overflowed', - ] as const) { - if (actual[key] !== expected[key]) { - throw new Error(`Paragraph ${label}.${key} differs: ${String(actual[key])} !== ${String(expected[key])}`); - } - } -} - -function hashMeasurements(measurements: readonly ParagraphMeasurement[]): string { - let hash = 2_166_136_261; - for (const measurement of measurements) { - for (const value of [ - measurement.width, - measurement.height, - measurement.contentWidth, - measurement.contentHeight, - measurement.firstBaseline, - measurement.lastBaseline, - Number(measurement.overflowed), - ]) { - for (const codeUnit of String(value)) hash = Math.imul(hash ^ codeUnit.charCodeAt(0), 16_777_619); - } - } - return (hash >>> 0).toString(16).padStart(8, '0'); -} - -function exactArray(label: string, actual: ArrayLike, expected: readonly number[]): void { - if (actual.length !== expected.length) throw new Error(`${label} length differs from its golden`); - for (let index = 0; index < expected.length; index++) { - exactValue('batch', label, index, actual[index], expected[index]); - } -} - -function exactValue( - fixture: string, - field: string, - index: number, - actual: number | undefined, - expected: number | undefined, -): void { - if (actual !== expected) { - throw new Error(`Shaping golden ${fixture}.${field}[${index}] differs: ${String(actual)} !== ${String(expected)}`); - } -} - -function cjkUniversalityTarget(): BenchmarkTarget { - let loaded: BenchmarkTarget | undefined; - return { - id: 'cjk-universality', - label: 'CJK universality', - detail: 'Noto Sans CJK · exact shaping + horizontal paragraphs', - color: 'cyan', - capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph']), - status: (input) => (input.fontBytes === undefined ? 'ready' : 'needs-fixture'), - load: async (controls, context) => { - loaded ??= (await import('./cjk-universality')).cjkUniversalityTarget; - await loaded.load(controls, context); - }, - run: async (input, sampleIndex, controls, context) => { - if (loaded === undefined) throw new Error('CJK universality target was not loaded'); - return loaded.run(input, sampleIndex, controls, context); - }, - dispose: async () => { - const target = loaded; - loaded = undefined; - if (target !== undefined) await target.dispose(); - }, - }; -} - -export function createShapingConformanceTargets(): readonly BenchmarkTarget[] { - return [harfrustShaperTarget, paragraphTarget, paragraphLayoutTarget, paragraphPolicyTarget, cjkUniversalityTarget()]; -} diff --git a/apps/benchmarks/src/benchmark/targets/conformance/index.ts b/apps/benchmarks/src/benchmark/targets/conformance/index.ts index 981cccfa..6998b735 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/index.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/index.ts @@ -1,7 +1,7 @@ import type { BenchmarkInput, BenchmarkTarget, Capability } from '../../contracts'; import { selectableFontFixture } from '../../font-fixtures'; -import { createShapingConformanceTargets } from './direct-runtime'; import { createFontLoaderWorkerConformanceTarget } from './font-loader-worker'; +import { createParagraphContractsConformanceTarget } from './paragraph-contracts'; import { mtsdfRasterConformanceAdapter } from './raster/mtsdf'; import { slugRasterConformanceAdapter } from './raster/slug'; import { createRasterSamplingConformanceTarget, createRasterSourceOutlineConformanceTarget } from './raster/target'; @@ -166,7 +166,7 @@ function runtimeFallbackTarget(technique: Technique, backend: Backend): Benchmar export function createConformanceTargets(): readonly BenchmarkTarget[] { return [ createFontLoaderWorkerConformanceTarget(), - ...createShapingConformanceTargets(), + createParagraphContractsConformanceTarget(), tslBaselineTarget('webgl2'), tslBaselineTarget('webgpu'), advancedShapingTarget(), diff --git a/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts b/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts new file mode 100644 index 00000000..3e0412c5 --- /dev/null +++ b/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts @@ -0,0 +1,399 @@ +import type { + LoadedFont, + ParagraphContentBox, + ParagraphLayout, + ParagraphLayoutInspection, + ParagraphStyle, +} from '@pmndrs/text'; +import { bitmap } from '@pmndrs/text/three/bitmap'; +import { FontLoader, Text, TextGroup, type TextUpdate } from '@pmndrs/text/three'; +import * as THREE from 'three/webgpu'; + +import amiriFontUrl from '../../../../fixtures/rendering/amiri-bitmap-16.font.glb?url'; +import cjkFontUrl from '../../../../fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb?url'; +import interFontUrl from '../../../../fixtures/rendering/inter-bitmap-16.font.glb?url'; +import bidiContractJson from '../../../../fixtures/contracts/paragraph-bidi-layout-v0.json'; +import cjkContractJson from '../../../../fixtures/contracts/paragraph-cjk-layout-v0.json'; +import type { BenchmarkTarget } from '../../contracts'; +import { exactValue } from '../../exact-value'; +import { paragraphCjkCoverageText } from '../../paragraph-contract-corpus'; +import { hashParagraphLayouts, paragraphLayoutBytes, paragraphLayoutContract } from '../../paragraph-layout-digest'; +import { createUikitLayoutFixture, YogaMeasureMode, type UikitParagraphSubject } from '../../uikit-layout-fixture'; + +type BitmapFont = LoadedFont; + +interface LegacyAxis { + readonly mode: 'unconstrained' | 'at-most' | 'exactly'; + readonly size?: number; +} + +interface LegacyConstraints { + readonly width?: LegacyAxis; + readonly height?: LegacyAxis; + readonly maxLines?: number; + readonly wrap?: 'none' | 'word' | 'character'; + readonly align?: 'start' | 'center' | 'end' | 'justify'; + readonly overflow?: 'visible' | 'clip' | 'ellipsis'; +} + +interface LayoutGolden { + readonly measurement: Readonly>; + readonly hash: string; + readonly [field: string]: unknown; +} + +interface ParagraphFixture { + readonly text: string; + readonly style: ParagraphStyle; + readonly constraints: LegacyConstraints; + readonly layout: LayoutGolden; +} + +interface BidiContract { + readonly bidi: Readonly>; + readonly policies: { + readonly text: string; + readonly style: ParagraphStyle; + readonly cases: Readonly>; + }; + readonly uikit: { + readonly input: { readonly text: string; readonly style: ParagraphStyle }; + readonly policy: LegacyConstraints; + readonly customLayouting: Readonly>; + readonly measurements: Readonly>>>; + readonly resolved: { + readonly outerSize: readonly [number, number]; + readonly padding: readonly [number, number, number, number]; + readonly border: readonly [number, number, number, number]; + readonly contentBox: Readonly>; + readonly centeredX: readonly number[]; + readonly centeredY: readonly number[]; + readonly layout: LayoutGolden; + }; + }; +} + +interface CjkContract { + readonly constraints: Readonly>; + readonly cases: Readonly< + Record< + string, + { + readonly text: string; + readonly style: ParagraphStyle; + readonly layouts: Readonly>; + } + > + >; +} + +type State = + | { readonly kind: 'empty' } + | { + readonly kind: 'ready'; + readonly loader: FontLoader; + readonly inter: BitmapFont; + readonly amiri: BitmapFont; + readonly cjk: BitmapFont; + }; + +const bidiContract = bidiContractJson as unknown as BidiContract; +const cjkContract = cjkContractJson as unknown as CjkContract; + +export function createParagraphContractsConformanceTarget(): BenchmarkTarget { + let state: State = { kind: 'empty' }; + return { + id: 'paragraph-contracts', + label: 'Rust paragraph contracts', + detail: 'bidi · policies · uikit seam · full CJK corpus · public Text', + color: 'violet', + capabilities: new Set(['deterministic', 'font-bytes', 'wasm', 'shaping', 'paragraph', 'raster']), + status: () => 'ready', + load: async (_controls, context) => { + if (state.kind === 'ready') return; + const loader = new FontLoader(new THREE.LoadingManager()); + const fonts: BitmapFont[] = []; + try { + const load = (url: string, coverage?: string) => + loader.loadAsync({ + input: { baked: url }, + raster: { + technique: bitmap, + options: { strikes: [16], ...(coverage === undefined ? {} : { coverage: { text: coverage } }) }, + }, + ...(context?.signal === undefined ? {} : { signal: context.signal }), + }); + const loaded = await Promise.all([ + load(interFontUrl), + load(amiriFontUrl), + load(cjkFontUrl, paragraphCjkCoverageText), + ]); + fonts.push(...loaded); + const [inter, amiri, cjk] = loaded; + if (inter === undefined || amiri === undefined || cjk === undefined) { + throw new Error('paragraph contract fonts did not load'); + } + state = { kind: 'ready', loader, inter, amiri, cjk }; + } catch (error) { + for (const font of fonts) font.dispose(); + loader.dispose(); + throw error; + } + }, + run: async (_input, _sampleIndex, _controls, context) => { + context?.signal?.throwIfAborted(); + if (state.kind !== 'ready') throw new Error('paragraph contracts target was not loaded'); + return runContracts(state, context?.signal); + }, + dispose: async () => { + if (state.kind !== 'ready') return; + const ready = state; + state = { kind: 'empty' }; + ready.inter.dispose(); + ready.amiri.dispose(); + ready.cjk.dispose(); + ready.loader.dispose(); + }, + }; +} + +function runContracts(state: Extract, signal: AbortSignal | undefined) { + const group = new TextGroup({ capacity: { size: 4_096, policy: 'grow' } }); + const texts: Text[] = []; + const expected: Array<{ readonly id: string; readonly golden: LayoutGolden; readonly full: boolean }> = []; + const add = ( + id: string, + font: BitmapFont, + text: string, + style: ParagraphStyle, + constraints: LegacyConstraints, + golden: LayoutGolden, + full: boolean, + ) => { + const value = new Text({ font, text, style, contentBox: contentBox(constraints) }); + texts.push(value); + expected.push({ id, golden, full }); + group.add(value); + }; + + for (const [id, fixture] of Object.entries(bidiContract.bidi)) { + add(`bidi.${id}`, state.amiri, fixture.text, fixture.style, fixture.constraints, fixture.layout, true); + } + for (const [id, fixture] of Object.entries(bidiContract.policies.cases)) { + add( + `policy.${id}`, + state.inter, + bidiContract.policies.text, + bidiContract.policies.style, + fixture.constraints, + fixture.layout, + false, + ); + } + for (const [caseId, fixture] of Object.entries(cjkContract.cases)) { + for (const [constraintId, constraints] of Object.entries(cjkContract.constraints)) { + const golden = fixture.layouts[constraintId]; + if (golden === undefined) throw new Error(`CJK contract omitted ${caseId}.${constraintId}`); + add(`cjk.${caseId}.${constraintId}`, state.cjk, fixture.text, fixture.style, constraints, golden, true); + } + } + + let uikitText: Text | undefined; + try { + signal?.throwIfAborted(); + group.updateMatrixWorld(true); + if (group.error !== undefined) throw group.error; + const layouts = texts.map((text, index) => { + const layout = text.inspectLayout(); + const contract = expected[index]; + if (layout === undefined || contract === undefined) throw new Error('paragraph contract layout was not published'); + assertObject(contract.id, paragraphLayoutContract(layout, contract.full), narrowLayoutGolden(contract.golden)); + return layout; + }); + + uikitText = new Text({ + font: state.inter, + text: bidiContract.uikit.input.text, + style: bidiContract.uikit.input.style, + contentBox: contentBox(bidiContract.uikit.policy), + }); + group.add(uikitText); + const subject = textSubject(group, uikitText, bidiContract.uikit.input); + const uikit = createUikitLayoutFixture(subject, contentBox(bidiContract.uikit.policy)); + const custom = uikit.customLayouting(); + assertObject( + 'uikit.customLayouting', + { minWidth: custom.minWidth, minHeight: custom.minHeight, firstBaseline: custom.firstBaseline }, + narrowLayoutGolden(bidiContract.uikit.customLayouting), + ); + const natural = custom.measure(Number.NaN, YogaMeasureMode.Undefined, Number.NaN, YogaMeasureMode.Undefined); + const atMost = custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost); + const exactWidth = custom.measure(420.001, YogaMeasureMode.Exactly, Number.NaN, YogaMeasureMode.Undefined); + for (let index = 0; index < 20; index += 1) { + assertObject( + 'uikit.repeatedAtMost', + custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost), + bidiContract.uikit.measurements.atMost!, + ); + } + assertObject('uikit.natural', natural, bidiContract.uikit.measurements.natural!); + assertObject('uikit.atMost', atMost, bidiContract.uikit.measurements.atMost!); + assertObject('uikit.exactWidth', exactWidth, uikitExactWidthGolden()); + assertObject( + 'uikit.definite', + uikit.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly), + bidiContract.uikit.measurements.definite!, + ); + const resolved = uikit.layoutResolvedBox( + bidiContract.uikit.resolved.outerSize, + bidiContract.uikit.resolved.padding, + bidiContract.uikit.resolved.border, + ); + assertObject('uikit.contentBox', resolved.contentBox, bidiContract.uikit.resolved.contentBox); + assertObject( + 'uikit.layout', + paragraphLayoutContract(resolved.layout, false), + narrowLayoutGolden(bidiContract.uikit.resolved.layout), + ); + assertArray('uikit.centeredX', resolved.centeredX, bidiContract.uikit.resolved.centeredX); + assertArray('uikit.centeredY', resolved.centeredY, bidiContract.uikit.resolved.centeredY); + layouts.push(resolved.layout as ParagraphLayoutInspection); + + return { + bytes: layouts.reduce((total, layout) => total + paragraphLayoutBytes(layout), 0), + hash: hashParagraphLayouts(layouts), + metrics: { + bidiLayoutCount: Object.keys(bidiContract.bidi).length, + policyLayoutCount: Object.keys(bidiContract.policies.cases).length, + cjkLayoutCount: Object.keys(cjkContract.cases).length * Object.keys(cjkContract.constraints).length, + uikitMeasurementCount: uikit.calls.measure, + uikitLayoutCount: uikit.calls.layout, + }, + }; + } finally { + uikitText?.dispose(); + for (const text of texts) text.dispose(); + group.dispose(); + } +} + +function textSubject( + group: TextGroup, + text: Text, + input: { readonly text: string; readonly style: ParagraphStyle }, +): UikitParagraphSubject> { + let key = ''; + const apply = (value: ParagraphContentBox) => { + const next = JSON.stringify(value); + if (next !== key) { + key = next; + text.contentBox = value; + group.updateMatrixWorld(true); + if (group.error !== undefined) throw group.error; + } + }; + return { + measure(value) { + apply(value); + const measured = text.measureLayout(); + if (measured === undefined) throw new Error('uikit measurement was not published'); + return measured; + }, + layout(value) { + apply(value); + const layout = text.inspectLayout(); + if (layout === undefined) throw new Error('uikit layout was not published'); + return layout; + }, + update(value) { + text.set({ ...input, ...value }); + key = ''; + }, + }; +} + +function contentBox(value: LegacyConstraints): ParagraphContentBox { + return { + ...(value.width === undefined ? {} : { width: axis(value.width) }), + ...(value.height === undefined ? {} : { height: axis(value.height) }), + ...(value.maxLines === undefined ? {} : { maxLines: value.maxLines }), + ...(value.wrap === undefined ? {} : { wrap: value.wrap }), + ...(value.align === undefined ? {} : { align: value.align }), + ...(value.overflow === undefined ? {} : { overflow: value.overflow }), + }; +} + +function axis(value: LegacyAxis) { + if (value.mode === 'unconstrained') return { mode: 'unconstrained' as const }; + if (value.size === undefined) throw new Error(`${value.mode} constraint omitted its size`); + return { mode: value.mode === 'exactly' ? ('exact' as const) : ('at-most' as const), size: value.size }; +} + +function assertObject(label: string, actual: unknown, expected: unknown): void { + if (!exactValue(actual, expected)) { + throw new Error(`${label} differs from its retained paragraph contract at ${firstDifference(actual, expected)}`); + } +} + +function narrowLayoutGolden(value: Value): Value { + if (typeof value === 'number') return Math.fround(value) as Value; + if (Array.isArray(value)) return value.map(narrowLayoutGolden) as Value; + if (isRecord(value)) { + return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, narrowLayoutGolden(child)])) as Value; + } + return value; +} + +function uikitExactWidthGolden(): Readonly> { + const expected = bidiContract.uikit.measurements.exactWidth; + if (expected === undefined) throw new Error('uikit contract omitted exact-width measurement'); + const layoutMeasurement = record(bidiContract.uikit.resolved.layout.measurement, 'uikit resolved measurement'); + const contentHeight = numberValue(layoutMeasurement.contentHeight, 'uikit resolved contentHeight'); + return { ...expected, height: Math.ceil(Math.fround(contentHeight) * 100) / 100 }; +} + +function record(value: unknown, label: string): Readonly> { + if (!isRecord(value)) throw new TypeError(`${label} is not a record`); + return value; +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== 'number' || !Number.isFinite(value)) throw new TypeError(`${label} is not finite`); + return value; +} + +function firstDifference(actual: unknown, expected: unknown, path = '$'): string { + if (exactValue(actual, expected)) return `${path} (no difference)`; + if (Array.isArray(actual) && Array.isArray(expected)) { + if (actual.length !== expected.length) return `${path}.length: ${actual.length} !== ${expected.length}`; + for (let index = 0; index < actual.length; index += 1) { + if (!exactValue(actual[index], expected[index])) return firstDifference(actual[index], expected[index], `${path}[${index}]`); + } + } + if (isRecord(actual) && isRecord(expected)) { + const actualKeys = Object.keys(actual); + const expectedKeys = Object.keys(expected); + if (!exactValue(actualKeys, expectedKeys)) { + return `${path} keys: ${JSON.stringify(actualKeys)} !== ${JSON.stringify(expectedKeys)}`; + } + for (const key of actualKeys) { + if (!exactValue(actual[key], expected[key])) return firstDifference(actual[key], expected[key], `${path}.${key}`); + } + } + return `${path}: ${JSON.stringify(actual)} !== ${JSON.stringify(expected)}`; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function assertArray(label: string, actual: ArrayLike, expected: readonly number[]): void { + if (actual.length !== expected.length) throw new Error(`${label} length differs from its retained paragraph contract`); + for (let index = 0; index < expected.length; index += 1) { + if (actual[index] !== expected[index]) { + throw new Error( + `${label}[${index}] differs from its retained paragraph contract: ${String(actual[index])} !== ${String(expected[index])}`, + ); + } + } +} diff --git a/apps/benchmarks/src/benchmark/targets/registry.ts b/apps/benchmarks/src/benchmark/targets/registry.ts index 7d51a0c7..9d91f6ac 100644 --- a/apps/benchmarks/src/benchmark/targets/registry.ts +++ b/apps/benchmarks/src/benchmark/targets/registry.ts @@ -5,6 +5,7 @@ type TargetGroup = 'product' | 'measurement' | 'conformance'; const targetGroups: Readonly> = { synthetic: 'product', 'font-loader-worker': 'conformance', + 'paragraph-contracts': 'conformance', 'external-raster-proof-webgl2': 'product', 'external-raster-proof-webgpu': 'product', 'bitmap-text-webgl2': 'product', @@ -15,11 +16,6 @@ const targetGroups: Readonly> = { 'slug-text-webgpu': 'product', 'react-text-reconciliation': 'product', 'font-baker': 'measurement', - 'harfrust-shaper': 'conformance', - 'paragraph-engine': 'conformance', - 'paragraph-layout-engine': 'conformance', - 'paragraph-bidi-policy': 'conformance', - 'cjk-universality': 'conformance', 'tsl-webgl2-baseline': 'conformance', 'tsl-webgpu-baseline': 'conformance', 'advanced-shaping-conformance': 'conformance', diff --git a/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts b/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts index df166a98..7100c347 100644 --- a/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts +++ b/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts @@ -1,9 +1,8 @@ import type { - LayoutParagraph as Paragraph, - LayoutParagraphAxisConstraint as ParagraphAxisConstraint, - ParagraphConstraints, - ParagraphInput, + ParagraphAxisConstraint, + ParagraphContentBox, ParagraphLayout, + ParagraphMeasurement, } from '@pmndrs/text'; export const YogaMeasureMode = Object.freeze({ Undefined: 0, Exactly: 1, AtMost: 2 }); @@ -12,7 +11,16 @@ type YogaMeasureModeValue = (typeof YogaMeasureMode)[keyof typeof YogaMeasureMod type Inset = readonly [top: number, right: number, bottom: number, left: number]; type Size = readonly [width: number, height: number]; -export function createUikitLayoutFixture(paragraph: Paragraph, policy: ParagraphConstraints = {}) { +export interface UikitParagraphSubject { + measure(contentBox: ParagraphContentBox): ParagraphMeasurement; + layout(contentBox: ParagraphContentBox): ParagraphLayout; + update(input: Input): void; +} + +export function createUikitLayoutFixture( + paragraph: UikitParagraphSubject, + policy: ParagraphContentBox = {}, +) { let currentPolicy = { ...policy }; let dirtyCount = 1; let paintRevision = 0; @@ -20,11 +28,11 @@ export function createUikitLayoutFixture(paragraph: Paragraph, policy: Paragraph const calls = { measure: 0, layout: 0 }; const measuredParagraph = { - measure(constraints?: ParagraphConstraints) { + measure(constraints: ParagraphContentBox = {}) { calls.measure += 1; return paragraph.measure(constraints); }, - layout(constraints?: ParagraphConstraints) { + layout(constraints: ParagraphContentBox = {}) { calls.layout += 1; return paragraph.layout(constraints); }, @@ -99,8 +107,8 @@ export function createUikitLayoutFixture(paragraph: Paragraph, policy: Paragraph ); const layout = measuredParagraph.layout({ ...currentPolicy, - width: { mode: 'exactly', size: contentWidth }, - height: { mode: 'exactly', size: contentHeight }, + width: { mode: 'exact', size: contentWidth }, + height: { mode: 'exact', size: contentHeight }, }); const contentLeft = -outerWidth / 2 + borderLeft + paddingLeft; const contentTop = outerHeight / 2 - borderTop - paddingTop; @@ -111,11 +119,11 @@ export function createUikitLayoutFixture(paragraph: Paragraph, policy: Paragraph centeredY: Float32Array.from(layout.y, (value) => contentTop - value), }; }, - updateParagraph(input: ParagraphInput) { + updateParagraph(input: Input) { paragraph.update(input); dirtyCount += 1; }, - updateShapingPolicy(policyUpdate: ParagraphConstraints) { + updateShapingPolicy(policyUpdate: ParagraphContentBox) { currentPolicy = { ...currentPolicy, ...policyUpdate }; dirtyCount += 1; }, @@ -132,7 +140,7 @@ function mapYogaAxis(value: number, mode: YogaMeasureModeValue, name: string): P if (mode === YogaMeasureMode.Undefined) return { mode: 'unconstrained' }; const size = validYogaSize(value, name); if (mode === YogaMeasureMode.AtMost) return { mode: 'at-most', size }; - if (mode === YogaMeasureMode.Exactly) return { mode: 'exactly', size }; + if (mode === YogaMeasureMode.Exactly) return { mode: 'exact', size }; throw new RangeError(`unsupported Yoga ${name} measure mode`); } diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 23d3d204..82b0242a 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -7,14 +7,190 @@ "entries": [ { "id": "browser-core", - "label": "Browser core", + "label": "Renderer-neutral core JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "9b3d588ce97cfb32a339a3b1810b61b63d3b48b7a35b9d68ea61a147237b48c0", - "rawBytes": 400841, - "minifiedBytes": 296719, - "gzipBytes": 85926, - "brotliBytes": 66549 + "sha256": "b1b6828d3006773a5e6d3604860e79b7fcad6772081499bc679383bc25fe26b4", + "rawBytes": 98060, + "minifiedBytes": 70089, + "gzipBytes": 18840, + "brotliBytes": 16532 + }, + { + "id": "text-shaper-wasm", + "label": "Text engine Wasm", + "status": "measured", + "format": "wasm", + "sha256": "97d37df44b1a76c36eb301925183cc9646625d42216647dec4536ef01ca085c9", + "rawBytes": 1113113, + "minifiedBytes": 1113113, + "gzipBytes": 422035, + "brotliBytes": 333171 + }, + { + "id": "renderer-neutral-core-total", + "label": "Renderer-neutral core total (JS + Wasm)", + "status": "measured", + "format": "aggregate", + "sha256": "d2fdf4e60388b0ca670c70342d72c17d85f61a0f06cb9f9e6ba63e06cefeb89e", + "rawBytes": 1211173, + "minifiedBytes": 1183202, + "gzipBytes": 440875, + "brotliBytes": 349703 + }, + { + "id": "three-runtime-js", + "label": "Complete Three adapter JS (peers and Wasm external)", + "status": "measured", + "format": "javascript", + "sha256": "f09fad5d5fe7629dbc7ef879c9229302e70e3aa43e57abf694fe4d588eb18d31", + "rawBytes": 341448, + "minifiedBytes": 224927, + "gzipBytes": 57828, + "brotliBytes": 48726 + }, + { + "id": "three-renderer-total", + "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)", + "status": "measured", + "format": "aggregate", + "sha256": "5ace817d61ab3b90e7ccd1bd025e2eab288d1acca8c7a393c6c7a470ddb54bf7", + "rawBytes": 1454561, + "minifiedBytes": 1338040, + "gzipBytes": 479863, + "brotliBytes": 381897 + }, + { + "id": "font-inter-bitmap-16-32", + "label": "Inter 4.1 Bitmap font asset (16 + 32 ppem)", + "status": "measured", + "format": "font-asset", + "sha256": "b8143dc39a49199c934f4cee493ddb6acbbfff34716f5332afd4e8b9cb8e785f", + "rawBytes": 3149648, + "minifiedBytes": 3149648, + "gzipBytes": 558308, + "brotliBytes": 420590 + }, + { + "id": "font-inter-mtsdf", + "label": "Inter 4.1 MTSDF font asset", + "status": "measured", + "format": "font-asset", + "sha256": "1e1980e2b20341c5e7f531e970e37cb879da2907d5c84e9603414717bcf495c0", + "rawBytes": 39347712, + "minifiedBytes": 39347712, + "gzipBytes": 6798412, + "brotliBytes": 3240372 + }, + { + "id": "font-inter-slug", + "label": "Inter 4.1 Slug font asset", + "status": "measured", + "format": "font-asset", + "sha256": "1c3f8fea27d1f404f77e47c4dd4929c122e12091a3d91cd92c30a6b9d6ece094", + "rawBytes": 3444916, + "minifiedBytes": 3444916, + "gzipBytes": 618487, + "brotliBytes": 410035 + }, + { + "id": "font-icons-bitmap-16-32", + "label": "Font Awesome Free 6.7.2 Bitmap icon asset (16 + 32 ppem)", + "status": "measured", + "format": "font-asset", + "sha256": "95550c860396470f8583a990a8e2081289a6a87781836c17b488527c8c4eca45", + "rawBytes": 2381732, + "minifiedBytes": 2381732, + "gzipBytes": 450047, + "brotliBytes": 355549 + }, + { + "id": "font-icons-mtsdf", + "label": "Font Awesome Free 6.7.2 MTSDF icon asset", + "status": "measured", + "format": "font-asset", + "sha256": "75ef82e7ee28d6ee6135634fc13b68ba8fc8fe808c8ec3d8428d8e33cf37c3b6", + "rawBytes": 32580900, + "minifiedBytes": 32580900, + "gzipBytes": 7227824, + "brotliBytes": 3324903 + }, + { + "id": "font-icons-slug", + "label": "Font Awesome Free 6.7.2 Slug icon asset", + "status": "measured", + "format": "font-asset", + "sha256": "dda2ea68d49f45cdfbf48c3a505718eb62931753834e1d175270ae40d2b40b1d", + "rawBytes": 2945412, + "minifiedBytes": 2945412, + "gzipBytes": 658012, + "brotliBytes": 484998 + }, + { + "id": "delivery-three-inter-bitmap", + "label": "Three + engine + Inter Bitmap delivery total", + "status": "measured", + "format": "aggregate", + "sha256": "6ef02616975dfc861bde87d59f54ce34b639a4e28a5b1215adc1a204b692ee3e", + "rawBytes": 4604209, + "minifiedBytes": 4487688, + "gzipBytes": 1038171, + "brotliBytes": 802487 + }, + { + "id": "delivery-three-inter-mtsdf", + "label": "Three + engine + Inter MTSDF delivery total", + "status": "measured", + "format": "aggregate", + "sha256": "50892699abb2c95bf691b633564b797dd2f255fbcddf14ccc81d6e37f11f2802", + "rawBytes": 40802273, + "minifiedBytes": 40685752, + "gzipBytes": 7278275, + "brotliBytes": 3622269 + }, + { + "id": "delivery-three-inter-slug", + "label": "Three + engine + Inter Slug delivery total", + "status": "measured", + "format": "aggregate", + "sha256": "b37dc9de30811fca3f18cba6834bea6c75814405c89d6372fffa0c6eba010cb6", + "rawBytes": 4899477, + "minifiedBytes": 4782956, + "gzipBytes": 1098350, + "brotliBytes": 791932 + }, + { + "id": "delivery-three-icons-bitmap", + "label": "Three + engine + Font Awesome Bitmap delivery total", + "status": "measured", + "format": "aggregate", + "sha256": "371206907c05d52db0a8fe3157edf276217bc10c9e74311843507062c6e57e60", + "rawBytes": 3836293, + "minifiedBytes": 3719772, + "gzipBytes": 929910, + "brotliBytes": 737446 + }, + { + "id": "delivery-three-icons-mtsdf", + "label": "Three + engine + Font Awesome MTSDF delivery total", + "status": "measured", + "format": "aggregate", + "sha256": "1993fd4f13d8fa9fc1199d5bd446f5757db4694303e93085de85f8a42bc79804", + "rawBytes": 34035461, + "minifiedBytes": 33918940, + "gzipBytes": 7707687, + "brotliBytes": 3706800 + }, + { + "id": "delivery-three-icons-slug", + "label": "Three + engine + Font Awesome Slug delivery total", + "status": "measured", + "format": "aggregate", + "sha256": "7919cf075ff6239b6a982f8e537763b02ac1a8401feb77d8ae097f192a85e178", + "rawBytes": 4399973, + "minifiedBytes": 4283452, + "gzipBytes": 1137875, + "brotliBytes": 866895 }, { "id": "font-validator-js", @@ -22,7 +198,7 @@ "status": "measured", "format": "javascript", "sha256": "acdb803764e9ab7c5b7f4d070a0065d9354d4206b0cc5a52ceb1eb6acb306552", - "rawBytes": 740645, + "rawBytes": 740357, "minifiedBytes": 584479, "gzipBytes": 137637, "brotliBytes": 112898 @@ -32,77 +208,55 @@ "label": "Runtime baker host JS", "status": "measured", "format": "javascript", - "sha256": "1c2517a9ac99ebe7c73791cdf28761693602f34e0f4e90232dc2ccf746351f1e", - "rawBytes": 11437, - "minifiedBytes": 9524, - "gzipBytes": 3826, - "brotliBytes": 3435 + "sha256": "7063bed30a6b2bd08863bd05e411241f08245988e4fe36ce0c1032bf390641d0", + "rawBytes": 11395, + "minifiedBytes": 9482, + "gzipBytes": 3820, + "brotliBytes": 3456 }, { "id": "runtime-baker-worker-js", "label": "Runtime baker Worker JS", "status": "measured", "format": "javascript", - "sha256": "31dc8dd13f3eefcbb497ac666911a8a402de2a32dde89544b91b288b0037110a", - "rawBytes": 12908, - "minifiedBytes": 8936, - "gzipBytes": 3003, - "brotliBytes": 2671 - }, - { - "id": "text-shaper-js", - "label": "Text shaper JS", - "status": "measured", - "format": "javascript", - "sha256": "056dffeb58c5f1636f9d340bc959de4a74bf9d580d2a9f6e990f9c5ec5c00c65", - "rawBytes": 70619, - "minifiedBytes": 52395, - "gzipBytes": 14262, - "brotliBytes": 12522 - }, - { - "id": "text-shaper-wasm", - "label": "Text shaper Wasm", - "status": "measured", - "format": "wasm", - "sha256": "194dd880ca0d8fb4eda7797f7a68dc47cab0beb7f006f79187b87be92ae0e446", - "rawBytes": 1101079, - "minifiedBytes": 1101079, - "gzipBytes": 417984, - "brotliBytes": 328164 + "sha256": "5a418bcb13438cacc81e69ae9550cb03a4a2d467ffb381d07f021675e122f2ec", + "rawBytes": 12854, + "minifiedBytes": 8888, + "gzipBytes": 2997, + "brotliBytes": 2667 }, { "id": "bitmap-runtime-js", "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "d6c8f3c7f2ba574d6b97b6bbffd38065cdfa14f819dc4cff49fe3da1979acc78", - "rawBytes": 327653, - "minifiedBytes": 216317, - "gzipBytes": 55015, - "brotliBytes": 46269 + "sha256": "4e92a363a41952e152366c1443093c59522bbe50e6d2794c49b8bb69e2accc67", + "rawBytes": 330985, + "minifiedBytes": 218147, + "gzipBytes": 55848, + "brotliBytes": 47080 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "1a2a4748af7e89c030c14e14627f2125e19157a248762650655808f97c7299be", - "rawBytes": 327649, - "minifiedBytes": 216382, - "gzipBytes": 55010, - "brotliBytes": 46263 + "sha256": "905e8d39ef96ed4f95a91e7169be7df84dd291b7e22849a2e84c533fb54bc9b4", + "rawBytes": 330981, + "minifiedBytes": 218211, + "gzipBytes": 55841, + "brotliBytes": 47117 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "8a2e93e57b098643d33fe2339f75224e73d7c25a4aa3aaefbf0e1de2745637e5", - "rawBytes": 327651, - "minifiedBytes": 216306, - "gzipBytes": 54979, - "brotliBytes": 46275 + "sha256": "bdc057fb140338e6fb848d19cf8487771529a07f7fb541638c1a9becc1035eac", + "rawBytes": 330983, + "minifiedBytes": 218139, + "gzipBytes": 55850, + "brotliBytes": 47027 }, { "id": "bitmap-baker-wasm", @@ -120,22 +274,22 @@ "label": "Bitmap fixed baker host JS", "status": "measured", "format": "javascript", - "sha256": "877cf243a4102cc2090d8b30d0ee22aa14fe150edaf758a0b405635ed4e8856f", - "rawBytes": 23110, - "minifiedBytes": 15621, - "gzipBytes": 4785, - "brotliBytes": 4247 + "sha256": "5c2be1d0f852ded18c1ea3d3f5e4488c3fa7713747a5d5420439e374c03e949a", + "rawBytes": 23080, + "minifiedBytes": 15597, + "gzipBytes": 4780, + "brotliBytes": 4240 }, { "id": "mtsdf-generator-js", "label": "MSDF generator host JS", "status": "measured", "format": "javascript", - "sha256": "996b884783be8b708e186ae2fe4a062b856a6e48f30b7044f3c07939b2aacdf0", - "rawBytes": 11543, - "minifiedBytes": 8466, - "gzipBytes": 2658, - "brotliBytes": 2364 + "sha256": "c2b14eae4a888665b1565c86c2daf1fef2850fb5f99ba80b5885b4317dba4356", + "rawBytes": 11531, + "minifiedBytes": 8460, + "gzipBytes": 2653, + "brotliBytes": 2360 }, { "id": "mtsdf-generator-wasm", @@ -164,11 +318,11 @@ "label": "MSDF fixed baker host JS", "status": "measured", "format": "javascript", - "sha256": "fd2140ee7f48c10e41be0f132e01712b2f3b44186f0561c41cbe013fb763b5ae", - "rawBytes": 26924, - "minifiedBytes": 19112, - "gzipBytes": 5531, - "brotliBytes": 4907 + "sha256": "a10639994ebe2d90064d71cd51e8a01c124ba6779c7a288e8d5cd4f0427d98a9", + "rawBytes": 26894, + "minifiedBytes": 19088, + "gzipBytes": 5527, + "brotliBytes": 4903 }, { "id": "slug-baker-wasm", @@ -186,22 +340,22 @@ "label": "Slug fixed baker host JS", "status": "measured", "format": "javascript", - "sha256": "c0f844c626cb8994c8bc4de9cb9ddf1fe099899e5c056edd7afc6cdc838d0be3", - "rawBytes": 18721, - "minifiedBytes": 12913, - "gzipBytes": 4129, - "brotliBytes": 3680 + "sha256": "97414de8d9d2bf608d20b22fb4b1e18b03ced1c7d798cf7b237ca603f9231bb7", + "rawBytes": 18691, + "minifiedBytes": 12889, + "gzipBytes": 4124, + "brotliBytes": 3674 }, { "id": "portable-baker-js", "label": "Portable baker JS", "status": "measured", "format": "javascript", - "sha256": "077bf3546c43678e8b01512302c80b511bd11bd6ee2913ff9fdfc3d4a38a0055", - "rawBytes": 9012, - "minifiedBytes": 6077, - "gzipBytes": 2175, - "brotliBytes": 1937 + "sha256": "27e96b24ffcc374bc0cbcc1102bbc72a3c2d04fc294cd8cf8c7ccce8b88946a5", + "rawBytes": 8994, + "minifiedBytes": 6071, + "gzipBytes": 2170, + "brotliBytes": 1934 }, { "id": "portable-baker-wasm", @@ -219,11 +373,11 @@ "label": "Unicode 17 analysis JS", "status": "measured", "format": "javascript", - "sha256": "7b4320ddbb5d713a92337daa13f762ef9f56ba3e2bb0ffd3ef2354a702d8a1d7", - "rawBytes": 167796, - "minifiedBytes": 141127, - "gzipBytes": 42406, - "brotliBytes": 31287 + "sha256": "7d9c59e19c774fe61109c88315feb1e6614957f7db0f212462b2206fb6386a62", + "rawBytes": 167712, + "minifiedBytes": 141103, + "gzipBytes": 42399, + "brotliBytes": 31307 } ] } diff --git a/apps/benchmarks/src/v1-async-proof.ts b/apps/benchmarks/src/v1-async-proof.ts deleted file mode 100644 index 4f37912b..00000000 --- a/apps/benchmarks/src/v1-async-proof.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createTextPreparationWorker, createTextRuntime } from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; - -declare global { - interface Window { - targetV1AsyncReady: Promise; - } -} - -interface TargetV1AsyncResult { - readonly status: string; - readonly workerCount: number; - readonly glyphCount: number; - readonly progressEvents: number; - readonly snapshotGlyphCount: number; - readonly desiredGlyphCount: number; - readonly superseded: boolean; - readonly aborted: boolean; -} - -window.targetV1AsyncReady = prepare(); - -async function prepare(): Promise { - let workerCount = 0; - const runtime = await createTextRuntime({ - async: { - createWorker: () => { - workerCount += 1; - return createTextPreparationWorker(); - }, - }, - }); - const font = await runtime.loadFont({ - input: { baked: '/fixtures/rendering/inter-bitmap-16.font.glb' }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - const paragraph = batch.add({ font, text: 'Worker shaping proof' }); - const progress: unknown[] = []; - try { - const outcome = await runtime.updateAsync({ onProgress: (value) => progress.push(value) }); - - paragraph.text = 'Worker A'; - const snapshotA = runtime.updateAsync(); - paragraph.text = 'Worker desired state B'; - await snapshotA; - const snapshotGlyphCount = paragraph.committed?.layout.glyphIds.length ?? 0; - runtime.update(); - const desiredGlyphCount = paragraph.committed?.layout.glyphIds.length ?? 0; - - paragraph.text = 'Old worker'; - const oldWorker = runtime.updateAsync(); - paragraph.text = 'Newest sync'; - runtime.update(); - const superseded = (await oldWorker).status === 'superseded'; - - paragraph.text = 'Abort worker'; - const controller = new AbortController(); - const aborting = runtime.updateAsync({ signal: controller.signal }); - controller.abort('proof complete'); - const aborted = (await aborting).status === 'aborted'; - return { - status: outcome.status, - workerCount, - glyphCount: paragraph.committed?.layout.glyphIds.length ?? 0, - progressEvents: progress.length, - snapshotGlyphCount, - desiredGlyphCount, - superseded, - aborted, - }; - } finally { - batch.dispose(); - font.dispose(); - runtime.dispose(); - } -} diff --git a/apps/benchmarks/v1-async.html b/apps/benchmarks/v1-async.html deleted file mode 100644 index 55b45ccd..00000000 --- a/apps/benchmarks/v1-async.html +++ /dev/null @@ -1,10 +0,0 @@ - - - - - target-v1 async preparation proof - - - - - diff --git a/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts b/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts index b7ddd31a..d177f23e 100644 --- a/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts +++ b/apps/benchmarks/vitexec/paragraph-stress-timing.probe.ts @@ -24,10 +24,9 @@ await new Promise((resolve, reject) => { const measures = performance.getEntriesByType('measure') as PerformanceMeasure[]; const summaries = Object.fromEntries( - [...new Set(measures.map(({ name }) => name))].sort().map((name) => [ - name, - summarize(measures.filter((entry) => entry.name === name).map(({ duration }) => duration)), - ]), + [...new Set(measures.map(({ name }) => name))] + .sort() + .map((name) => [name, summarize(measures.filter((entry) => entry.name === name).map(({ duration }) => duration))]), ); const elapsed = frameDeltas.reduce((sum, duration) => sum + duration, 0); console.log( @@ -50,7 +49,9 @@ function waitForViewport(): Promise { const candidate = document.querySelector( '[data-testid="comparison-live-viewport"][data-workload="paragraph-stress"]', ); - return candidate !== null && Number(candidate.dataset.framesPerSecond) > 0 && Number(candidate.dataset.glyphCount) > 0 + return candidate !== null && + Number(candidate.dataset.framesPerSecond) > 0 && + Number(candidate.dataset.glyphCount) > 0 ? candidate : undefined; }; diff --git a/docs/engineering/code-style.md b/docs/engineering/code-style.md index 0ca3b683..bea05ca5 100644 --- a/docs/engineering/code-style.md +++ b/docs/engineering/code-style.md @@ -18,8 +18,8 @@ sources: resource: ../../packages/text/src/internal/runtime-bake-protocol.ts title: Runtime bake protocol - id: paragraph - resource: ../../packages/text/src/paragraph.ts - title: Paragraph engine + resource: ../../packages/text/src/three/text.ts + title: Retained paragraph and Three.js synchronization boundary - id: benchmark-runner resource: ../../apps/benchmarks/src/benchmark/runner.ts title: Shared benchmark lifecycle diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index fcd343a0..9eaf584e 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:805069ef81fb411215ec4ac945aebe4573faccb6fc0a77fdc7e0923fed407895' +source_digest: 'sha256:cedbf0466d5682351c98761d2e874f9dcf8955b5eec88a548f9144efedf0d338' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -83,9 +83,9 @@ sources: - id: v1-compose-proof resource: ../../apps/benchmarks/src/v1-compose-proof.ts title: Target-v1 composed canonical-shader browser proof - - id: v1-async-proof - resource: ../../apps/benchmarks/src/v1-async-proof.ts - title: Target-v1 Worker synchronization browser proof + - id: paragraph-contracts + resource: ../../apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts + title: Public Rust paragraph conformance target - id: bitmap-text-product-target resource: ../../apps/benchmarks/src/benchmark/targets/product/bitmap-text.ts title: Finite Bitmap public Text product target diff --git a/docs/packages/text.md b/docs/packages/text.md index 13b78c9e..38b8f51b 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -1,1099 +1,221 @@ --- type: Workspace Package title: '@pmndrs/text' -description: Implements public font loading, shaping, paragraph measurement, static discovery, and portable raster artifact contracts. +description: Implements portable font loading, retained Rust shaping and layout, renderer-directed command planning, and maintained Three.js and React Three Fiber adapters. resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:31242b7ffc89ac76c6f4bf65ca6dc7a6b9339ea9e7ee2ed0fd371439878d4a02' -tags: [package, public-api, typescript, contracts] +source_digest: 'sha256:7543198f3107315061bd1615a4cfb50356c9e96441e3a5e701d28bab6b682515' +tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest resource: ../../packages/text/package.json title: Package manifest - - id: api-contract - resource: ../planning/api-shapes.md - title: Runtime and bake API V0 - - id: discovery - resource: ../../packages/text/src/discovery.ts - title: Static project discovery implementation - - id: compiler-adapter - resource: ../../packages/text/src/compiler-adapter.ts - title: Pinned TypeScript compiler adapter - - id: typescript-go-node-variance - resource: https://github.com/microsoft/typescript-go/issues/4528 - title: TypeScript Go Three.js Node variance expansion - - id: definitelytyped-node-extras - resource: https://github.com/DefinitelyTyped/DefinitelyTyped/pull/75246 - title: Upstream NodeExtras lookup-map fix - - id: bitmap-identity - resource: ../../packages/text/src/raster/bitmap-technique.ts - title: Bitmap descriptor and raster identity implementation - - id: bitmap-baker - resource: ../../packages/text/rust/bitmap-baker - title: Portable bitmap generator implementation - - id: raster-coverage - resource: ../../packages/text/src/raster-coverage.ts - title: Bounded runtime raster coverage contract - - id: bitmap-validator - resource: ../../packages/text/src/bakers/bitmap-validator.ts - title: Layered bitmap artifact validator - - id: mtsdf-admission - resource: ../../packages/text/rust/mtsdf-admission - title: Non-shipping MTSDF generator admission harness - - id: mtsdf-baker-profile - resource: ../../packages/text/scripts/profile-mtsdf-baker.mjs - title: MTSDF artifact baker phase profiler - - id: mtsdf-baker-profile-evidence - resource: ../../packages/text/rust/mtsdf-admission/evidence/baker-phases-v0.json - title: MTSDF artifact baker phase evidence - - id: mtsdf-host - resource: ../../packages/text/src/internal/mtsdf-generator.ts - title: MTSDF direct-memory TypeScript host - - id: mtsdf-contract - resource: ../../packages/text/src/raster/msdf.ts - title: Portable MTSDF runtime technique - - id: mtsdf-baker - resource: ../../packages/text/src/bakers/msdf.ts - title: Fixed MTSDF baker host - - id: mtsdf-validator - resource: ../../packages/text/src/bakers/msdf-validator.ts - title: Layered MTSDF artifact validator - - id: mtsdf-fontations - resource: ../../packages/text/rust/mtsdf-fontations - title: Shared Fontations MTSDF provider - - id: slug-contract - resource: ../../packages/text/src/internal/slug-contract.ts - title: Fixed Slug V0 identity contract - - id: slug-validator - resource: ../../packages/text/src/bakers/slug-validator.ts - title: Layered Slug artifact validator - - id: slug-baker - resource: ../../packages/text/rust/slug-baker - title: Portable Slug artifact baker - - id: slug-baker-host - resource: ../../packages/text/src/bakers/slug.ts - title: Direct-memory Slug baker host - - id: slug-runtime - resource: ../../packages/text/src/raster/slug-technique.ts - title: Portable analytic Slug runtime technique - - id: slug-shaders - resource: ../../packages/text/src/internal/slug-shaders - title: Three.js TSL Slug shader implementation - - id: slug-outline-research - resource: ../planning/slug-outline-research.md - title: Slug outline architecture - - id: raster-wasm-host - resource: ../../packages/text/src/internal/raster-baker-wasm.ts - title: Shared direct-memory raster baker host - - id: raster-atlas-runtime - resource: ../../packages/text/src/internal/raster-atlas.ts - title: Renderer-neutral lossless-atlas decoder - - id: raster-technique-api - resource: ../../packages/text/src/raster-technique.ts - title: Portable raster technique contract - - id: text-runtime-v1 + - id: public-api + resource: ../../packages/text/src/index.ts + title: Renderer-neutral public exports + - id: runtime resource: ../../packages/text/src/text-runtime.ts - title: Target-v1 renderer-neutral text runtime - - id: paragraph-batch-v1 - resource: ../../packages/text/src/paragraph-batch.ts - title: Target-v1 paragraph batching and canonical storage - - id: formatted-text-v1 - resource: ../../packages/text/src/formatted-text.ts - title: Target-v1 formatted text and span composer - - id: paragraph-attachment-v1 - resource: ../../packages/text/src/paragraph-batch-attachment.ts - title: Target-v1 renderer attachment coordinator - - id: three-v1 - resource: ../../packages/text/src/three.ts - title: Maintained target-v1 Three.js integration - - id: r3f-v1 - resource: ../../packages/text/src/r3f.ts - title: Maintained target-v1 React Three Fiber integration - - id: typegpu-v1 - resource: ../../packages/text/src/typegpu.ts - title: Maintained target-v1 TypeGPU integration - - id: raster-ktx - resource: ../../packages/text/src/internal/raster-ktx.ts - title: Shared dependency-light KTX2 validation - - id: raster-records - resource: ../../packages/text/src/internal/raster-records.ts - title: Shared dependency-light dense-record validation - - id: render-policy-wire - resource: ../../packages/text/src/internal/render-policy-wire.ts - title: First-party render-policy compiler - - id: font-binding-wire - resource: ../../packages/text/src/internal/font-binding-wire.ts - title: First-party font-binding compiler - - id: text-engine-host + title: Font and Rust-runtime ownership + - id: text-properties + resource: ../../packages/text/src/text-properties.ts + title: Paragraph input contract + - id: layout-query + resource: ../../packages/text/src/layout.ts + title: Explicit layout-query values + - id: rust-engine + resource: ../../packages/text/rust/shaper/src/engine/state.rs + title: Retained Rust text engine + - id: frame-host resource: ../../packages/text/src/internal/text-engine-host.ts - title: Retained frame host - - id: render-plan-view - resource: ../../packages/text/src/internal/render-plan-view.ts - title: Zero-copy render-plan reader - - id: engine-frame-wire - resource: ../../packages/text/src/internal/engine-frame-wire.ts - title: Complete retained-frame request compiler - - id: three-engine-runtime - resource: ../../packages/text/src/three/engine-runtime.ts - title: Lazy Three engine coordinator - - id: raster-validation - resource: ../../packages/text/src/internal/raster-artifact-validation.ts - title: Shared standalone raster artifact validation - - id: composition - resource: ../../packages/text/src/internal/compose-bake.ts - title: Generic core/raster artifact composer - - id: node-host - resource: ../../packages/text/src/node/bake.ts - title: Node bake API and filesystem host - - id: loader - resource: ../../packages/text/src/loader.ts - title: Baked-first loader and registry - - id: runtime-bake - resource: ../../packages/text/src/runtime-bake.ts - title: Lazy module-Worker bake host - - id: core-bake-policy - resource: ../../packages/text/src/internal/core-bake-policy.ts - title: Shared offline/runtime core bake policy - - id: raster-bake-plan - resource: ../../packages/text/src/internal/raster-bake-plan.ts - title: Single-evaluation raster plan resolution - - id: shaper-bridge - resource: ../../packages/text/src/shaper.ts - title: Direct-memory runtime shaper bridge - - id: shaper-core - resource: ../../packages/text/rust/shaper - title: HarfRust Wasm shaper implementation - - id: paragraph - resource: ../../packages/text/src/paragraph.ts - title: Paragraph engine implementation - - id: text-object + title: Single-export Wasm host + - id: three-api + resource: ../../packages/text/src/three.ts + title: Three.js public exports + - id: three-text resource: ../../packages/text/src/three/text.ts - title: Framework-neutral Three.js Text object - - id: raster-runtime - resource: ../../packages/text/src/raster-runtime.ts - title: Shared decoded-raster runtime - - id: mtsdf-technique - resource: ../../packages/text/src/raster/msdf.ts - title: Renderer-neutral MTSDF technique - - id: bitmap-technique - resource: ../../packages/text/src/raster/bitmap-technique.ts - title: Renderer-neutral Bitmap technique - - id: slug-technique - resource: ../../packages/text/src/raster/slug-technique.ts - title: Renderer-neutral Slug technique - - id: react-runtime + title: Three.js retained text lifecycle + - id: three-plan + resource: ../../packages/text/src/three/engine-plan-target.ts + title: Three.js render-plan executor + - id: three-policy + resource: ../../packages/text/src/three/plan-program-registry.ts + title: Three.js policy-program registry + - id: r3f resource: ../../packages/text/src/r3f.ts - title: React Three Fiber reconciliation layer - - id: unicode-analysis - resource: ../../packages/text/src/internal/unicode.ts - title: Unicode analysis implementation + title: React Three Fiber adapter + - id: engine-design + resource: ../planning/rust-layout-engine.md + title: Rust text engine and render-plan design + - id: core-api-reference + resource: ../planning/core-api.md + title: Core text API reference + - id: three-api-reference + resource: ../planning/three-api.md + title: Three.js text API reference generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T14:10:06Z' + at: '2026-08-09T18:30:00Z' --- # Package reference: `@pmndrs/text` -Status: 🚧 Target-v1 core and maintained integrations are in progress - -Target-v1 now has an executable renderer-neutral `TextRuntime`, `ParagraphBatch`, attachment state machine, and Bitmap, -MTSDF, and Slug techniques. The maintained `/three` adapter renders all three techniques through `WebGPURenderer` on native -WebGPU and forced WebGL2, `/r3f` retains those Three objects through React 19 Strict Mode without leaking font leases, and -the first `/typegpu` slice provides the caller-owned-root engine plus exact program/target boundary. Built-in TypeGPU raster -programs and their live-pixel proof remain open. `RasterTechnique` preserves -exact options, descriptor, decoded data, binding, and canonical storage types without `any`; its public helpers validate -and brand technique and resource identities without requiring casts. A `RasterGlyphInput` is valid only for the `select` -or `writeStorage` call that receives it, because packing pools one input per glyph and rewrites it on every update rather -than allocating a glyph-sized set each time; a technique that needs a field beyond the call copies the value. -`/raster/bitmap`, `/raster/mtsdf`, and `/raster/slug` -decode and authenticate CPU resources without importing Three, explicitly omit absent records, select stable physical -bindings, and pack positive-down paragraph origins plus technique fields into typed canonical arrays. Bitmap selects a -strike/page per glyph and retains R8 pages; MTSDF retains one RGBA8 atlas-array binding per font; Slug retains its original -RGBA16F curve, R32 header, and R16 reference bytes so Three's R16-to-R32 workaround remains executor-owned. Focused package -tests prove selection, range writes, binding identity, coordinates, paint, and analytic addresses. The merged-v0 Bitmap and -Slug renderer modules, the `/raster/msdf` spelling, the merged-v0 `Text`, and the `/react` binding are deleted; `/raster/bitmap`, `/raster/mtsdf`, `/raster/slug`, `/three`, `/r3f`, and `/typegpu` are the whole renderer surface. The -Bitmap conformance lane no longer needs a fallback: driven by command-buffer-backed `Text`, the Three executor, and -`LoadedFont` raster data, it reproduces the benchmark's independent CPU atlas compositor in zero mismatched bytes and -returns the same pinned full-frame hash `a47930d3…e893`, the same 5,930 lit and 3,473 half-coverage pixels, and the same -`[68, 18, 313, 112]` ink bounds the merged-v0 renderer produced. Reaching that required two corrections to the exported -Bitmap graph, both invisible to a coverage-threshold smoke check and both caught only by the exact oracle: the graph had -inherited merged-v0's vertical atlas flip, which belongs to that renderer's `flipY`-enabled upload rather than to the -target-v1 pages, and it had dropped the physical-pixel snap the strike's integer placement depends on. Every Presentation -surface now renders through the target-v1 techniques and the `/three` adapter. - -The `/three` adapter registers each raster technique through `registerThreeRasterPlanProgram`, keyed by the technique's -stable identifier rather than object identity. A registration contributes static policy bytecode, cold font/resource -binding compilation, and material realization. Rust interprets the policy while shaping, laying out, packing, and emitting -the command buffer; no JavaScript callback runs in that hot path. The Three executor owns only resource tables, GPU -objects, synchronization, and reversible presentation overrides. It does not retain a candidate/current paragraph target -or independently derive layout and render state. An unregistered technique fails during engine setup with its identifier. - -One retained session prewarms one reusable paragraph state rather than multiplying `TextGroup`'s total glyph capacity -through every child. Additional paragraphs grow their Unicode-through-positioning arenas from actual content. When Rust -recycles removed storage, it clears all committed and pending semantics, identity counters, fingerprints, and preparation -flags while preserving vector capacities. Three sizes frame limits from both the final paragraph set and the actual -removal/insertion and text/style mutation tables. Public integration replaces a group's complete child set twice through -one session, and the Rust regression proves the recycled paragraph contains only its new text at the same allocation -capacity. - -`/three` also exports each canonical technique shader as `bitmapShader`, `mtsdfShader`, and `slugShader`.[^three-v1] Each -takes one glyph instance's resolved nodes plus that batch's bound GPU resources and returns a named readonly output: -position, coverage, resolved colour, opacity, and the intermediate stages the technique produces, such as MTSDF's separate -fill, outline-ring, and shadow coverage or Slug's dilated render coordinate. These are not a parallel copy maintained for -external use. The command-buffer executor builds the first-party Bitmap, MTSDF, and Slug materials from exactly these -functions. `defineTextMaterial` receives that same canonical shader result and a `createDefaultMaterial()` factory, so -customization does not need to own packing, geometry, or layout. Each function reads `positionLocal` and `uv()` from the -executor's unit quad. - -`bitmapShader` additionally publishes `clipPosition`, the projected quad rounded to whole physical pixels, which the executor -assigns to `material.vertexNode`. Bitmap coverage is authored at one atlas texel per device pixel, so an unsnapped quad -resamples the strike rather than reproducing it, and placing that snap in the exported shader rather than in the built-in -executor is what makes a custom material inherit it by construction instead of by convention. The output carries no other -route to a vertex stage, so the seam cannot be silently skipped. MTSDF and Slug deliberately publish no such member: a -distance field reconstructs its edge from the screen-space gradient and Slug integrates coverage analytically from -outlines, so both are correct at any subpixel placement and must keep the default projection. Bitmap pages upload in the -atlas's own top-down row order with `flipY` disabled, and `atlasUv` addresses that same space directly, so the sampled row -is the baked row on both backends. - -The custom-material proof renders one paragraph twice on native WebGPU and forced WebGL2: once with the default Bitmap -material, then with a `defineTextMaterial` factory that begins from `createDefaultMaterial()` and changes only final colour. -Both passes light an identical 2,616-pixel set while the customized pass emits no green channel, so it inherited canonical -placement, snapping, coverage, policy packing, and command-buffer batching instead of reimplementing them. -The retained proof pages light 2,606 pixels for Bitmap, 1,935 for MTSDF, and 1,510 for Slug on both backends. - -Two target-v1 raster defects surfaced when the benchmark began driving these programs against the exact conformance -oracles rather than against themselves. Slug published each quad's lower-left em corner while its shader documented and -consumed the upper-left, so every glyph integrated its coverage vertically mirrored inside a correctly placed quad; -publishing the top and walking em space downward moved the CPU band-walk reference from 22.94 mean absolute error with -22,911 severe error pixels to 0.223 with none, and restored the independent browser-rasterized source-outline envelope. -Bitmap carried two defects at once. It kept the merged renderer's vertical atlas flip, which belonged to a `flipY` -upload the target-v1 program no longer performs, so every fragment sampled the mirrored row of its page; and it dropped -the device-pixel snapping milestone 1 records as a hard density contract. Correcting both restores the pinned merged-v0 -frame exactly: hash `a47930d3…e893`, 3,473 half-coverage pixels, and ink bounds `[68, 18, 313, 112]`. - -Both defects were masked by self-comparison. A rendered-pixel count taken from the program under test only proves the -program is stable, not correct, so each technique is held against a reference computed independently of it. - -The Three `FontLoader` forwards the two per-load capabilities the core runtime already accepted but the adapter withheld. -A request may carry an `AbortSignal`, so a cancelled load stops instead of running to completion; the merged-v0 registry -and loader both accepted one, and several consumers abort mid-load. Loader options may name a `FontRegistry`, so an -application holding registry-scoped state reaches the fonts this loader produces rather than receiving fonts owned by a -registry it cannot address. - -Readonly `Text.gpuBytes` and `TextGroup.gpuBytes` report the bytes of the GPU resources their attached target currently -retains: the textures it shares across batches plus the instance buffers its committed revision owns. Reporting belongs to -the target because only the target knows the realized allocation — Bitmap's R8 pages, MTSDF's layer-padded RGBA8 atlas -array, and Slug's RGBA16F curves, R32UI headers, and pair-packed R32UI references — while the portable techniques end at -CPU data and never describe engine residency. A revision that transferred its resources to a successor reports nothing, so -a warm commit cannot count the same buffers twice; an unbound `Text` and a third-party target that omits the optional -`ThreeRasterTargetAccounting` accessor both report zero. A `Text` inside a `TextGroup` shares that group's target, so both -objects report the same batch-wide total rather than a per-paragraph share. On the retained proof pages at the default 256-glyph capacity, -16-pixel Inter Bitmap measures 707,584 bytes as one 1024×679 R8 page plus 12,288 attribute bytes, MTSDF measures -41,971,712 bytes as its 41,943,040-byte padded atlas array plus 28,672 attribute bytes, and Slug measures 3,190,784 bytes; -the same totals are reported on WebGPU and forced WebGL2. - -The `txt` and `span` composer emits UTF-16 ranges over the composed string.[^formatted-text-v1] One span carries two kinds -of data with different consumption points: shaping data (`font`, `fontSize`, `lineHeight`, `letterSpacing`, `language`, -`direction`, `features`) must resolve before shaping because it segments runs and changes advances, while paint data -(`color`, `opacity`, `outline`, `shadow`) and the render variant resolve at glyph-instance packing. Both kinds resolve -through one cascade with one set of semantics, and part company only where each is consumed: the resolved shaping style -becomes disjoint segments that intersect with UAX #24 script and UAX #9 bidi runs before shaping, and the resolved paint -becomes per-glyph values indexed during packing. Resolution therefore cannot give one answer for `fontSize` and a -different one for `color`. - -The cascade folds every span covering a cluster from the outermost inward and merges **per property**, so a span states -only what it changes and inherits the rest from the scope enclosing it. A style-only span shapes from the surrounding -font; a span stating only `color` keeps the surrounding opacity, outline, and shadow; a span stating only `opacity` -re-applies that opacity to the inherited fill, outline, and shadow colours. An absent property group stays absent rather -than arriving as an empty object, so a span cannot silently reset a range to a default glyph colour or shaping style. - -Precedence follows containment rather than array order: the innermost covering span wins each property, and spans over -exactly the same range fall back to array order. Producer emission order is therefore not load-bearing, and a hand-built -span array that lists a contained span before the span enclosing it still resolves innermost-first. Partial overlap has no -innermost span at all, so it is rejected with a typed `SpanNestingError` naming both offending spans and their ranges -instead of resolving to whichever span a consumer happened to visit last. The font-fallback overlay the layout path -generates is machine-produced rather than authored, so it is split at the authored boundaries it crosses and stays inside -the same invariant. Resolution runs once per paragraph revision, keyed on the property snapshot, so packing indexes a -precomputed per-glyph result instead of rescanning the span array for every glyph. - -Ranges count UTF-16 code units, so an astral character before a span shifts that -span by two. Replacement content owns its own formatting on both the core `Paragraph` and the Three `Text`: assigning a -literal installs that literal's spans, and assigning a plain string clears the spans it replaced rather than reinterpreting -stale ranges against unrelated text. Runtime integration covers each of these against real shaped output — inherited font -handles and glyph IDs, a nested style-only span shaping from the font its enclosing span selected, each paint property -inherited independently through MTSDF fill, outline, and shadow storage, a span font size moving both shaped advances and -the line break, the typed nesting error and order-independent precedence, per-glyph font sizes and canonical linear -colours, cluster indices across a surrogate pair, tuple-spread and direct `span` calls producing identical layout, and a -formatted literal driven through `TextGroup` binding, `updateMatrixWorld`, and the drawn per-run instance counts. - -`Text` is a composite `Object3D`, not a `Group`, so it honors the primary `groupOrder` of any caller-owned parent Group. -Generated raster batches also use neutral `Object3D` roots rather than nested Groups. `Text.renderOrder` becomes the secondary -base applied to drawable meshes, which preserve their first-glyph/page-run-local offsets. Cold publication, warm retained -commits, base changes, and multi-font spans need no reshaping or per-frame descendant walk. Bitmap, MTSDF, Slug, and the -external proof package implement the required batch method, and the Three adapter rejects a plugin batch that would reset -inheritance with a nested Group. - -Milestone 10.4 proves that the open contract is implementable outside this package. The private -`@pmndrs/text-glyph-example-raster` consumer owns a new literal kind, companion GLB, embedded/external records, static and runtime -bakers, decoder, retained Three.js/TSL adapter, dirty uploads, overflow, abort, and disposal without importing this package's -internals or first-party raster modules. The proof made the Three adapter requirement explicit through public -`RasterObjectDrawBatch` and `ThreeRasterDrawBatch` types while keeping portable `RasterDrawBatch` renderer-neutral. Its -neutral Three.js root preserves renderer-local transparent-run order beneath the caller-owned parent Group and `Text` base. It -also corrected `RasterRuntime.load` to retain the caller's `resolveResource` callback in cache-owned options; authenticated -external records now traverse the same deduplicated load as their companion artifact. The public type additions erase at -runtime. Compact forwarding of the complete public option bag makes browser core and every first-party runtime closure 55 raw -/ 49 minified bytes smaller than the parent. Browser core changes by −11 gzip / +76 Brotli bytes, Bitmap by −15 / −74, MTSDF -by −14 / −41, and Slug by −13 / +6. The lazy validator, every baker host, and every Wasm artifact remain byte-identical. - -Slug V0 renders fill and opacity and rejects outline or shadow paint before batch allocation or paint mutation. The removed exact-distance outline and the bounded replacement gate are retained in the [outline research record](../planning/slug-outline-research.md). - -This package owns the accepted public core and React contract types. Its fixtures prove literal font and raster inference, capability composition, source/baked input rules, paragraph constraints, React prop derivation, lazy raster and `useFont` inference, and invalid combinations at compile time. React and React Three Fiber remain optional peer capabilities and are not reachable from the core entry point. Three.js-facing runtime values and types resolve through the public `three/webgpu` and `three/tsl` subpaths rather than the legacy root or internal source exports, matching the renderer boundary used by first-party raster work; package lint rejects those forbidden imports. Public raster-baker descriptors are constrained to `JsonValue` while preserving their exact inferred shape. Plugin-produced values are still revalidated during their unavoidable RFC 8785 canonicalization pass: exotic prototypes, cycles, excessive nesting, non-finite numbers, invalid Unicode, and non-JSON values cannot collide with a valid raster identity, while repeated non-cyclic references remain legal. Project plans resolve each descriptor and `rasterKey` once, then carry that same pair through ordering, packaging, and baking so a stateful plugin cannot make identity drift within one bake. - -Every `Text` generation now uses one required renderer-neutral raster transaction. `stageBatch` receives the prior compatible batch when one exists and returns an unpublished target plus synchronous `commit` and idempotent `abort`; there is no separate build, optional retained-update, or repaint-mutation contract. A candidate publishes only after every participating raster stages successfully. Failure, cancellation, or stale completion aborts staging without touching the live generation, while commit transfers exact batch ownership atomically. The portable batch surface owns only idempotent disposal and imports no Three.js type; the Three-backed `Text` adapter separately validates and attaches each target object. Bitmap, MTSDF, and Slug allocate deterministic 25% instance slack capped at 256 glyphs, track logical draw count independently, and retain geometry, material, texture, attribute, and backing-array identity while arbitrary replacement glyphs fit. Bitmap updates origin, size, UV, and color fields; MTSDF updates its complete 28-float instance record; Slug updates both its 17-float and six-integer records. Shrinks and exact-capacity growth update authoritative `instanceCount`, while overflow, strike changes, or incompatible ordered Bitmap/Slug page-run topology stage a correctness-preserving replacement. Dirty instances coalesce through 32-instance buckets and fall back to one logical full-range upload above eight disjoint ranges; unconsumed Three.js ranges carry into the next stage so a later commit cannot lose an earlier GPU upload.[^bitmap-identity][^mtsdf-contract][^slug-runtime] - -Milestone 9 introduces the fixed Slug V0 identity and standalone artifact-validation boundary.[^slug-contract][^slug-validator] The validator layers the pinned Khronos and byte-identical extension schemas over exact 40-byte dense records, exclusive buffer-view ownership, lossless native RGBA16F KTX2 curve pages, R32UI header grids, R16UI reference grids, checked page-relative addressing, authenticated external resources, and bounded GPU residency. Malformed identity, record, address, padding, KTX2 descriptor, integer-grid tail, external hash, and residency cases are named negative controls. That boundary feeds the package-owned Slug baker, registered-raster loader, analytic runtime, and framework-neutral public `Text` path described below. The retained all-external public-loader framebuffer gate and performance-review packets close Milestone 9; additional Slug tuning remains future measured research rather than unfinished renderer integration. - -The package-owned Slug baker now composes the ported outline conversion and exact packing crates into deterministic embedded or independently authenticated external-page artifacts.[^slug-baker] Its Rust-generated V0 ABI drives the same shared direct/segmented host mechanics used by the existing raster bakers, including bounded artifact windows, synchronous progress forwarding, owned-copy-before-release behavior, structured errors, and transactional allocation cleanup.[^slug-baker-host] The serial module-Worker entry remains lazy; neither the baker host nor Wasm enters the initial renderer graph. - -The fixed Slug renderer copies and adapts the reviewed Three Flatland TSL implementation to the package's exact V0 resources rather than recreating the analytic coverage graph.[^slug-runtime][^slug-shaders] It uploads lossless RGBA16F curves and R32UI headers directly; the Three.js adapter pair-packs exact R16UI glyph-local references into R32UI texels because Three 0.185.1's WebGL TSL backend otherwise declares a float sampler for `UnsignedShortType`. The adapter retains two bytes per reference plus at most one terminal padding value, while the authenticated artifact remains unchanged. Integer instanced addresses, consecutive page runs, hostile per-band work caps, the stable q-form solver, and loop-invariant derivative and reciprocal hoists remain intact. The patched `NodeExtras` declaration makes ordinary public TSL operator calls tractable; the private compatibility boundary now erases only the two runtime-supported operations Three 0.185.1 does not type precisely: boolean-form `Loop` and unsigned `textureLoad`. Shader construction stays inside `Fn` boundaries; assignment nodes append through the active TSL stack, axis-specific declaration names produce clean diagnostics, and package tests prove that ordinary core, Bitmap, and MTSDF entry graphs do not eagerly include Slug. The shared registered-raster resource seam resolves embedded views or independently packaged resources through a caller resolver or the companion artifact's retained URL/fetch context, then enforces declared length, SHA-256 identity, cancellation, and the registry resource ceiling before Slug creates any GPU object. Embedded and external baker outputs prove byte-identical records and curve/header/reference resources; retained WebGPU and forced-WebGL2 captures now cover exact pixels plus the seven-source visual and performance corpus. - -The shipped Slug runtime retains only the original 17-float fill instance layout and one fill material. The removed one-draw outline was visually correct but added a separate closest-distance curve solver after ordinary coverage. Retained 268-glyph measurements placed it at `2.44×–4.33×` fill-only GPU time across WebGPU/WebGL2 and DPR 1/2, so the implementation, TSL distance graph, outline attributes, CPU stroke authority, and benchmark controls were deleted rather than preserved as a fallback. Any future outline must reuse ordinary fill traversal, stay within the documented near-fill performance gate, and match or beat MTSDF outline quality.[^slug-outline-research] - -The non-shipping `autoresearch-fixed32-bands` baker feature proves that V0's per-glyph band counts are an executable optimization seam without becoming a JavaScript option or alternate distribution. It produces separately retained candidate artifacts; the ordinary Wasm build remains fixed 16. Exact dual-backend quality and interleaved product evidence reject universal fixed 32 as the production policy because its curve-work reduction comes with double-digit payload and residency growth. The separately gated `autoresearch-adaptive-bands` feature selects only among 16, 32, and 64 at bake time and never enters the production Wasm build. Its first six-reference target is rejected at the artifact gate because 64-band escalation grows every source. The distinct `autoresearch-adaptive32-bands` feature caps the same trigger at 32 under a separately precommitted manifest; its exact quality and complete dual-backend timing evidence also reject the universal policy because the added resources do not yield a reliable guard-wide product win. The copied packed-hull format likewise preserves exact pixels but is rejected after complete dual-backend product measurement: no source clears the 5% gate on both backends, while the incompatible reference layout adds 29.0–44.2% gzip bytes and 17.4–25.2% GPU residency. Its implementation remains at the retained candidate commit rather than adding a runtime format branch. The root-contribution challenger is also rejected and removed from the shipping tree after exact same-build measurement. Inspection of final Three 0.185.1 programs corrected the older mechanism: boolean `select` already emits control flow, so the candidate coalesces eight root-condition branches into four rather than replacing eager arithmetic. All 28 quality cells remain byte-identical and resources remain exact, but no source clears the 5% gate on both backends; the seven-source median paired deltas are a 0.84% WebGPU regression and a 1.56% WebGL2 improvement. Its implementation remains available at the retained candidate commit without adding an experiment API to the public raster. None of these experiments changes production output or rewrites the retained rejections. - -The workspace pnpm patch carries DefinitelyTyped's `NodeExtras` lookup-map rewrite for `@types/three` 0.185.1.[^definitelytyped-node-extras] It replaces the deeply nested `Node` conditional/intersection chain identified by TypeScript Go issue 4528 without changing the public node extensions.[^typescript-go-node-variance] The compile-only TSL regression covers the previously explosive method chain, uint shift/bitwise operations, integer division/modulo, vector derivatives, and object-form loop; it completes in 215 milliseconds at 4 MiB peak RSS. The complete text project completes in 346 milliseconds at 252 MiB, so package and build scripts invoke the pinned compiler directly and the former native-process memory guard is removed. The private unsigned-texture adapter remains separately justified because Three 0.185.1 types `textureLoad` as a generic float `TextureNode` even for an unsigned data texture; it is not compiler containment. - -Milestone 8.1 adds a repository-owned `no_std + alloc` Rust MTSDF core and a non-shipping admission harness beside the package's existing bakers. Typed AoS outline construction lowers once into kind-segregated SoA spans with contour identity; reusable scratch keeps per-pixel traversal allocation-free and now retains corner-coloring storage between glyphs. True signed curve distances, contour-aware overlap combination, nonzero-fill sign correction, and deterministic edge coloring produce zero coverage mismatches against pinned native `msdfgen` 1.13.0 across ordinary, acute, overlapping, self-intersecting, quadratic, cubic, and counter fixtures. Quadratic segments use the exact stationary-point polynomial solve from the pinned reference rather than nine seeded Newton searches; a regression fixture proves the former approximation can choose a materially wrong distance. Post-quantization correction retains the reference-compatible edge-fast pass for ordinary channel collisions, then applies at most four MTSDF-specific passes only to cells where bilinear RGB coverage disagrees with true-distance alpha near the coverage boundary. Both correction stages are glyph-local. Mean alpha error stays between 0.470 and 0.549 bytes with zero oracle coverage mismatches. The optimized `wasm32-unknown-unknown` admission module imports nothing and its compiled graph contains no font parser, WGPU, native binding, or WASI dependency. Its recorded binary hash and compressed sizes are explicitly host-labeled: the recorded host requires exact freshness, while every foreign host must rebuild the module, reproduce the portable admission contract and synthetic output, retain zero imports, and remain under the same reviewed raw/optimized/gzip/Brotli ceilings. A bounded cargo-fuzz lane covers malformed outline streams. The oracle, channel-SIMD, scalar-tile, and adjacent-texel SIMD implementations remain test-only; scalar is the single production kernel. - -The geometry core is independent of its host boundary. A sibling `mtsdf-baker` crate owns the package allocator and seven-function generator C ABI for allocation, release, generation, and borrowed-result access. Build-only Rust generation derives the portable JSON and exact typed TypeScript contract; production Wasm does not embed or export that contract. Callers write one checked header plus fixed command records directly into Wasm memory; the module accepts only exact active pointer/length pairs and rejects a released allocation. The generator exposes a checked sampling transform for production baking: every glyph may be placed on one global plane grid with one authoritative distance range, rather than stretching each glyph independently to its rounded texture dimensions. The legacy one-em oracle path uses the same implementation and remains byte-identical. A feature-minimal admission build preserves the independently measured generator boundary, while the package publishes one full baker module containing that kernel and the artifact pipeline rather than duplicating it as a second Wasm resource. Its internal TypeScript host writes discriminated move/line/quadratic/cubic/close commands directly into linear memory, maps statuses to typed errors, verifies the exact RGBA8 length, copies borrowed output before release, and releases requests after every later failure. All seven native-oracle cases retain their independent SHA-256 identities through the host; malformed numeric/outline input, forged release ranges, stale allocations, ABI drift, and cleanup after invalid output are named regressions. The feature-minimal scalar boundary remains separately measured from its host. - -Milestone 8.2 composed that kernel into the original fixed `@pmndrs/text/bakers/msdf` artifact path. One shared Fontations adapter supplies maintained unscaled line, quadratic, and cubic outlines to both admission evidence and the baker; no second parser or outline bridge exists. Its 64 px/em, full-eight-pixel-range descriptor hashes to `e944ba8d…fe93`. Item 8.6 now exposes `emSize` and full `pixelRange` as authenticated integer bake options in `1..=1022` and `1..=1020`. Omitted or partial options resolve against 64/8; explicit effective 64/8 canonicalizes to the legacy fieldless descriptor and raster key, while every non-default descriptor carries both effective values. `planeUnitsPerEm` equals `emSize`, and each glyph is evaluated only over its tight source-outline rectangle plus `ceil(pixelRange / 2)` field-padding texels on that global plane grid. Correction operates over the same glyph-local rectangle before copying into a 1024-pixel atlas page. Real 155-glyph subset bakes at 32/4 and 32/6 pass artifact validation, establishing the control path without changing the recommended default before quality and payload benchmarking. Bitmap and MTSDF descriptors may additionally authenticate bounded raster coverage while retaining the full source-local glyph namespace and dense record table. Standalone validation derives the expected coverage only from that authenticated descriptor; its public context has no second coverage field that could silently disagree. Degenerate non-rendering selected glyphs become exact absent records, while malformed command streams remain typed failures. The shared TypeScript direct-memory host owns allocation, response framing, nested metadata validation, copying, and transactional cleanup for both bitmap and MTSDF bakers. - -Direct raster-baker ABI revision 1 keeps ordinary responses contiguous and moves oversized results through bounded borrowed windows: the host reads metadata once, copies each window while Wasm owns it, and explicitly releases that ownership before the Worker transfers exact result buffers. Every Wasm pointer, status, length, and count is normalized as unsigned at the JavaScript boundary. The generator-only no-default-feature MTSDF module remains valid because artifact-baker fields are optional to the generator host, while the packaged baker requires and validates them. MTSDF quality options travel in the authenticated descriptor and do not change the low-level Wasm ABI. Build output removes obsolete ABI revision 0 files before packing. - -Bitmap and MTSDF runtime fallback share one serial ESM module-Worker host. The same normalized descriptor options drive deliberate Node baking and missing-artifact fallback; Bitmap's Worker normalizer retains both strikes and coverage instead of projecting coverage away. Each dynamically imported baker receives an owned source copy, one active job uses the reusable Worker, queued jobs remain FIFO, cancellation replaces active ownership safely, and an idle Worker terminates. Preparation failures reject through the promised asynchronous API rather than escaping synchronously. Core provenance now retains the authenticated collection-face index; legacy artifacts may default only when their descriptor hash proves face zero, and runtime raster baking always reuses that selected face. Registry subscriptions are released when their final tracked font is disposed. - -Runtime font and raster bake requests accept one optional `onProgress` listener. Its closed contract reports `font` or `raster` stage, a versioned phase, and bounded `completed`/`total` units. The host reports `queued` immediately; Workers report loading, packaging, transfer, and completion; Rust reports raster glyph progress through one generated synchronous Wasm import at roughly one hundred updates per complete pass. Progress messages never resolve a request, mutate render state, or enter the shaping/rendering graph. Consumers may therefore present deterministic progress without polling, timers, or main-thread baking, while omitting the listener pays only the fixed callback branch in optional baker code. - -Item 8.6 makes runtime raster coverage an explicit bounded capability rather than forcing every request through a complete-face atlas. One frozen typed contract accepts sorted, non-overlapping inclusive Unicode scalar ranges, authored scalar text, and sorted exact font-local `u16` glyph IDs under fixed count limits. Unicode ranges skip code points absent from the selected face; authored text fails if any scalar is unmapped; exact glyph IDs fail outside that face. The union selects raster work only: it does not subset the shaping font, remap IDs, or claim GSUB/GPOS closure. Sparse artifacts retain dense records and add one exact `ceil(glyphCount / 8)` little-endian selection bitset, where bit `n % 8` of byte `n / 8` names glyph `n`; unused terminal bits are zero. Selected spaces may remain absent records, while every unselected record must be absent. The canonical seed descriptor and bitset must appear together and are strictly validated. - -Node composition, direct Wasm calls, and the serial module-Worker use the same canonical descriptor and produce byte-identical bounded Bitmap/MTSDF artifacts. Complete descriptors retain their legacy raster keys and complete-face artifact/page hashes. Both runtime decoders rederive the canonical raster key from the authenticated artifact policy before creating GPU resources, so mutated strikes, quality settings, or coverage cannot retain a stale declared identity. Progress totals count selected glyphs rather than the source face, active cancellation replaces the Worker before queued recovery, and warm runtime preparation throws public `RasterCoverageError` with sorted missing font-local IDs before publishing a partial batch. This boundary intentionally leaves transitive shaping closure and compiler source subsetting to Milestone 17. - -The Darwin arm64 coverage-capable Binaryen-optimized MTSDF baker measurement is 552,025 raw, 215,030 gzip, and 168,758 Brotli bytes with one generated synchronous progress import; its independent host is 26,940 raw, 19,117 minified, 5,530 gzip, and 4,908 Brotli bytes and remains lazy outside the shaper and renderer graphs. The corresponding Bitmap baker is 626,940 raw, 234,735 gzip, and 180,503 Brotli bytes. The shared boundary materializes one validated JSON value for artifact packaging and canonical policy hashing instead of instantiating a second derived serializer graph. Relative to the pre-coverage modules, the remaining Brotli growth is 5,188 bytes for MTSDF and 6,951 bytes for Bitmap; dedicated raw, minified, gzip, and Brotli regression gates keep that strict decoding, cmap resolution, and policy-validation cost explicit. Compiled Wasm size and hash remain host-labeled package-size evidence with reviewed foreign-host ceilings; raster fixtures authenticate the generator version and exact baked outputs rather than treating a host compiler binary length as portable artifact identity. Profiling uses a private diagnostic entry that is neither packed nor present in the package export map and is unreachable from every production raster-baker graph, so ordinary bakes perform no clock, memory, or output-size observation. The package-size build fails if a shipped JavaScript graph reaches that module or retains its profiling symbols, if a thin baker host retains `performance.now`, or if a production Wasm import/export exposes profiling or timing. Canonical 64/8 Inter still produces 58,740 record bytes and ten independently addressable single-level lossless RGBA8 KTX2 pages. Their authored levels contain 39,111,736 unpadded texel bytes and occupy 41,943,040 exact bytes after texture-array layer padding; the corrected embedded full-font artifact remains 39,347,712 raw and 6,798,412 gzip bytes. The exact legacy descriptor, artifact, and page hashes remain unchanged. On the 1,402-icon Font Awesome fixture, the exact quadratic solve reduced a two-icon targeted raster pass from about 392 to 170 milliseconds and the refreshed complete-face bake measured 113.5 seconds; these are same-host observations, not portable performance gates. The complete artifact contains nine pages, 32,511,100 bytes of authored texture payload, 36,347,904 bytes of padded GPU allocation, 32,580,900 raw transport bytes, and 7,227,824 gzip transport bytes. The Worker forwards bounded progress while the synchronous Wasm call is active. - -That complete-face duration is a measured kernel cost, not an unexplained Worker stall. The discoverable `text:mtsdf-baker-profile` workflow accepts `--case=small|medium|complete|all` and compiles its native phase observer once. It measures the shared optimized pipeline by selection, outline extraction, texel generation, packing, KTX2 encoding, and GLB serialization, then runs the real optimized Wasm directly and through the serial module Worker. On the recorded Apple arm64 host, texel generation consumes 245.40 of 248.32 native milliseconds for the 39-selected-glyph case, 4,429.17 of 4,445.46 milliseconds for the 524-selected-glyph case, and 25,871.57 of 25,957.06 milliseconds for complete Inter. Those cases generate 71,341, 1,289,496, and 7,233,197 texels while visiting 1,694,576, 36,939,819, and 227,327,416 colored edges. Direct Wasm-to-host copies cost 0.69, 0.74, and 4.51 milliseconds; final Worker delivery costs 0.46, 0.61, and 0.47 milliseconds. Wasm linear-memory high-water marks are 6,488,064, 36,634,624, and 227,737,600 bytes; isolated Worker-process peaks are 109,002,752, 147,177,472, and 343,343,104 bytes. The corresponding artifacts are 595,752, 7,074,796, and 39,175,608 bytes. Direct Wasm and Worker identities are exact. The complete native-arm artifact is recorded separately because native and Wasm floating-point targets diverge there; it is not substituted for the shipped Wasm identity. Texel traversal is therefore the measured dominant phase and triggers the adjacent-texel scalar/SIMD comparison, while bounded runtime atlases remain the interactive path and complete faces remain explicit offline/stress work. - -Thin production artifacts use `pnpm build`; its MTSDF Wasm command is an optimized `--no-default-features --features artifact-baker` build, so the Rust `profiling` feature and phase observer do not exist in the module. `pnpm scripts run release:size:check` rebuilds those artifacts and enforces the JavaScript/Wasm diagnostic-exclusion boundary alongside exact size identity. An optimized diagnostic run is deliberate: `pnpm scripts run text:mtsdf-baker-profile -- --case=small` compiles the native binary with `--features=profiling` and imports the private TypeScript diagnostic entry; `--case=all --write` is reserved for refreshing retained phase evidence. The profiling entry is neither a public package export nor transitively imported by a production entry. The only remaining `performance.now` calls in package production source belong to the Node bake API's documented `timingsMs` result; that observable output is part of the maintained host contract rather than a hidden debug path. - -The retained SIMD decision evidence compares an equivalent four-texel scalar tile with a feature-gated adjacent-texel SIMD candidate. Both preserved every seven-case native-oracle hash, the complete 2,937-slot Inter checksum, rejection set, composite identity, warm allocation count, and steady-state Wasm memory. The candidate vectorized line-distance work across four neighboring texels while retaining the exact scalar quadratic and cubic solvers lane by lane. Adjacent SIMD improved the bounded Node corpus from 45.712 to 44.608 milliseconds (2.4%) and Chromium from 28.965 to 28.700 milliseconds (0.9%), but complete Inter was indistinguishable warm at 45.068 versus 45.066 seconds and its 50.110-second cold pass was slower than scalar's 44.089 seconds. Those bounded improvements did not justify a second target-specific artifact: optimized size grew from 52,633 to 63,549 bytes (20.7%) and Brotli size from 19,660 to 21,904 bytes (11.4%). The closed experiment runners are no longer maintained as product workflows; the accepted scalar kernel, independent native oracle, public baker tests, and package-size gate remain active. - -The shaper, Bitmap baker, and MTSDF generator/artifact boundary define every direct-memory header and table as a fixed-width `#[repr(C)]` Rust type. Build-only Rust generators derive each JSON size, alignment, and field offset from `size_of`, `align_of`, and `offset_of!`, then emit exact typed `as const` TypeScript modules from that JSON. Rust readers/writers and production TypeScript therefore consume one compiler-led truth; CI compares regenerated output byte-for-byte and fails on stale checked-in modules. Production Wasm embeds no duplicate JSON and exports no ABI pointer/length bootstrap. This keeps QuickType, JSON Schema, runtime JSON parsing, and binding-generator code out of the shipping graph. Wasm linear memory is guaranteed little-endian; serialized GLB, KTX2, SFNT, and extension records continue to follow their own portable format contracts. - -The first Rust text-engine foundation keeps that direct-memory rule and adds a renderer-neutral `no_std + alloc` policy -model to the existing shaper `rlib`, not a second Wasm module. Plain data and total validation functions bound program, -buffer, operation, register, field, vector-width, and store coverage before execution; technique variants share that one -verifier instead of owning parallel packers. The initial module is deliberately unreachable from the Wasm exports while -its fixed-width wire contract is built, and optimized dead-code evidence keeps the shipping shaper at 680,312 raw bytes. - -The next boundary slice exposes policy registration through compiler-derived `#[repr(C)]` request, program, buffer, and -operation records. TypeScript consumes the generated offsets directly; Rust performs one bounded registration-time -decode and retains typed policy state, so frame updates do not parse policy records. The decoder rejects forged lengths, -overlapping tables, nonzero reserved fields, noncanonical operation records, invalid register flow, and incomplete -physical outputs. Registration is idempotent only for an identical handle and policy, conflicting reuse and missing -disposal are distinct statuses, and retained state survives release of the request allocation. The optimized reachable -module measures 698,238 raw / 260,228 gzip / 203,760 Brotli bytes on the same Darwin arm64 toolchain, a delta of -17,926 / 6,660 / 4,395 bytes over the preceding shaper. This is cold registration infrastructure; frame-path admission -and performance remain unclaimed until the retained update and executor land. The existing 25,515-glyph TypeScript path -remains within measurement variance: baseline-to-current cold/font-size/layout-width/text medians are -55.28→52.48 / 12.02→11.92 / 8.42→8.23 / 38.66→38.73 milliseconds. - -The retained frame shell gives each engine session one 16-byte-aligned request arena and two 16-byte-aligned result -arenas. Cold creation and reservation may resize them; a warm update reads the already-pinned request and returns the -selected result pointer in that single call. The compiler-derived request header was 120 bytes in Stage 1 and is now 124 -bytes after adding renderer-fence acknowledgment. Its section offsets cover -text/style mutations, constraints, regions, exclusions, inline objects, and policy parameters; Stage 1 accepts only the -canonical empty transaction and rejects a nonempty section until its Rust consumer exists. The 144-byte aligned result -header fixes revisions, base requirements, capacity watermarks, output slot and generation, policy handle, capability -set, policy fingerprint, plus semantic, -resource, physical-buffer, patch, primitive, draw, retirement, and diagnostic table locations. Successful publication -alternates A/B slots; a failed parse or revision check writes the inactive slot without advancing or modifying the active -publication. The real optimized-Wasm test proves that a warm update preserves `memory.buffer`, while an 8 MiB cold -reserve detaches the prior fixed buffer and requires re-reading the aligned request pointer. This shell measures 725,302 -raw / 269,438 gzip / 210,867 Brotli bytes, adding 27,064 / 9,210 / 7,107 bytes to the policy-registration checkpoint. -Semantic tables remain empty, so no shaping or layout performance claim is attached to this stage. The current -25,515-glyph TypeScript path remains within run variance at 54.42/12.15/8.31/38.98 millisecond -cold/font-size/layout-width/text medians and 70.38/14.48/11.31/40.89 millisecond p95 values. The package's 186 -integration tests and six fuzz targets pass, as do the benchmark application's 117 tests, 20/20 warmed headless -conformance scenarios, and 172,156-byte packed-consumer proof -(`af7bfb85f04a6a63c6462735a6e8ec6d739576adb354c07ca51e744814db2f7b`). The aggregate benchmark script still stops -at its deliberately stale checked package-size snapshot; this stage records the actual measured size without rewriting -that unrelated historical evidence. - -The render-plan wire layer now gives those result tables concrete compiler-mapped records: semantic 44 bytes, resource -40, physical buffer 36, patch 36, primitive 64, draw 64, retirement 24, and diagnostic 24. Resource kind is independent -from create/update/retain action, and ordered-direct versus stable-indirect allocation is a dedicated buffer strategy. -Patch payload bytes live inside the same immutable publication and write records carry absolute rebased spans; allocate/ -resize, fill, copy, and retire records do not carry a payload address. Serialization is allocation-free, canonical -little-endian, and explicitly field-wise rather than a raw Rust-struct copy. Validation proves finite geometry, known -tags, bounded table ranges, and exact payload spans before touching the inactive arena. The result header publishes the -registered policy fingerprint with the plan, while failure headers expose neither that identity nor partial table state. -The current shipping update still emits an empty plan until retained semantic compilation lands; these records prove the -wire and publication contract, not incremental-layout performance. - -Policy registration now supplies the missing inputs to that compiler through the same compiler-mapped direct-memory -contract. Its 36-byte header addresses fixed 40-byte capability-set, 56-byte program, 16-byte buffer, and 16-byte -operation tables; registration decodes those bytes once into retained typed Rust state. Capability-set-specific lookup -validates backend flags, binding and draw limits, integer upload costs, resource-kind masks, independent storage/draw -keys, allocation strategy, and aligned padded buffer strides before a session revision can advance. The executor honors declared stride -without touching padding. Physical outputs remain disjoint independently bindable vector streams; policy operations -pack wider records instead of introducing aliased mutable interleaved fields. Thirty-six Rust unit tests and the focused -Node registration/frame tests pass. The optimized SIMD artifact is 739,647 raw / 272,532 gzip / 214,186 Brotli bytes, -14,075 / 3,272 / 3,173 bytes above the preceding executor artifact; this is registration/planner metadata, not a warm -layout performance result. - -The native renderer-neutral core now contains the first retained ordered-direct storage planner. It groups glyphs by -validated technique/program/resource, assigns stable physical buffer identities, and uses stable instance IDs plus -semantic content revisions to select aligned dirty records without comparing buffer bytes. The registered gap/call -cost, fragmentation budget, and whole-buffer threshold coalesce writes; consecutive changed records execute as SIMD -runs, while resource interleaving produces smaller scalar tails only where contiguity is genuinely absent. Preparation -is separately viewable, committable, and abortable: no committed CPU mirror changes before immutable plan serialization. -Tests cover exact first publication, a one-record update, ordered suffix movement, no-op zero output, metadata-only tail -deletion, retirement generations, abort preservation, compiler-wire validation, and unchanged warm scratch capacities. -Dirty transactions additionally publish complete compact resource/buffer bindings and ordered glyph-span/draw tables; -the physical buffer payload remains range-minimal. A primitive span represents consecutive compatible physical records -and carries one 16-bit record count, so the compiler splits only on ordering/binding identity, physical discontinuity, or -the 65,535-record wire limit instead of emitting a 64-byte primitive per glyph. Draws carry numeric material, clip, and -depth identities and exact table ranges, never a renderer object or callback. Different material IDs split draws under -the first-party policy while retaining one shared physical glyph buffer. An interleaved `A, A, B, A` resource test -produces three ordered spans over two deduplicated resources and buffers. The policy program is restricted packing -bytecode executed by Rust; the render plan itself is data. The planner remains unreachable from the shipping Wasm update -and is LTO-stripped. Only the reachable draw-wire and policy-key expansion changes the optimized SIMD artifact to -739,909 raw / 272,607 gzip / 214,288 Brotli bytes. No planner latency claim is attached until session wiring makes it -reachable. Stable-indirect storage now compiles complete resource, physical-buffer, patch, glyph-span, draw, and -retirement tables. Semantic identities retain physical record slots; content revisions select writes; fixed 64-entry -`u32` chunks carry logical order through the generated reserved binding ID 65,535. In the one-stream fixture, a localized -insertion writes one new 4-byte physical record and one affected 16-byte order range, while a pure reorder emits no -physical write. Removed slots/chunks remain quarantined until an explicit renderer-fence acknowledgment; applying a plan -is not proof that queued GPU work completed. When physical order spans would exceed the capability fragmentation budget, -the compiler transactionally rebases only the order buffer, retires its prior generation, and preserves glyph-buffer -generations. Tests cover no-op, abort, mixed resources, shared and material-partitioned storage, fence-gated reuse, wire -validation, bounded order fragmentation, and unchanged nested scratch capacities after warm settlement. Session wiring -and the ABI acknowledgment field remain open. The planner remains LTO-stripped: raw Wasm stays 739,909 bytes; the -reachable binding identity shifts gzip 272,607→272,624 and Brotli 214,288→214,395 bytes. No planner-latency or end-to-end -claim is attached yet. - -The allocation-strategy dispatcher now compiles one frame containing both ordered-direct and stable-indirect policy -programs without first copying glyphs or semantic fields into strategy-specific arrays. Homogeneous frames delegate -directly to one compiler; only mixed frames merge renderer-facing tables. Ordered buffers occupy the low `u32` ID half -and stable physical/order buffers the high half. The merge rebases patch payload offsets, validates/deduplicates shared -resources, removes resource retirements when another strategy keeps the same generation live, and restores the original -global draw order. Focused tests cover alternating strategies, allocation-strategy transitions, zero-output mixed -no-ops, and settled merge capacities. This dispatcher remains unreachable from `text_update`; it adds no end-to-end -timing claim before session integration. The required unchanged-path 25,515-glyph benchmark reports -57.18/12.44/8.58/39.28 ms median and 72.41/15.53/11.32/41.81 ms p95 for cold/font-size/width/text versus the prior -recorded 54.42/12.15/8.31/38.98 ms medians. The optimized Wasm remains 739,909 raw and 214,395 Brotli bytes, so the -dispatcher is still LTO-stripped and the table is baseline run variance rather than a planner performance result. - -Engine sessions now own the Rust allocation-strategy dispatcher. The current compiler-derived request header is 124 -bytes for `acknowledgedPublicationGeneration`, a renderer-fence field independent from `consumedPlanRevision`. Rust -requires acknowledgment to be monotonic and no newer than the last successful publication. The update path prepares and -views the session plan, stages it in the inactive result arena, commits planner and revision state only after successful -serialization, and aborts the planner on every failure. A session pins its first committed policy handle/fingerprint; -capability sets may change within that policy, but replacing the policy beneath retained buffers fails before mutation. -Focused compiled-Wasm tests cover accepted/future fences and unchanged A/B publication; host tests cover stale fences, -abort/retry, capability-set changes, and policy identity. A post-prepare Wasm abort cannot be induced until nonempty -semantic input exists, so that exact ordering remains an explicit test gap. The now-reachable planners increase the -optimized artifact from 739,909 / 272,624 / 214,395 to -822,443 / 308,033 / 242,447 raw/gzip/Brotli bytes. This is a measured shared-runtime cost and a pending optimization -target. Ordered UTF-16 replacements are now retained transactionally. Editorial constraints, regions, exclusions, and -inline objects now decode as borrowed one-call geometry; style upserts/removals are decoded and retained transactionally. -Sessions still publish an empty Rust plan because retained text is not yet shaped or laid out; there is no Rust -shaping/layout performance result yet, and the TypeScript layout table above remains baseline-only. - -The semantic request now has compiler-derived record layouts without a handwritten TypeScript mirror: 24-byte UTF-16 -text replacements, 88-byte stable style mutations, 52-byte constraints, 8-byte flow vertices, 56-byte regions, 48-byte -exclusions, and 56-byte inline objects. Region/exclusion rectangles use inline bounds, while bounded polygons reference -vertices inside the same request. Styles separate stable identity from authored cascade order and include current -shaping fields plus word spacing, target raster density, material/color, and decoration inputs. A field mask records -which values were authored so absent values inherit rather than being confused with zero-valued declarations. The -generated ABI and compiled-Wasm test pin every size, tag, and the inline-object -`baselineAlignment` offset. The generated engine vocabulary now also fixes axis, wrap, inline/block alignment, overflow, -writing, orientation, exclusion-side, and inline-object baseline tags rather than accepting renderer-local enum bytes. - -Style decoding is borrowed and allocation-free. Session creation pre-reserves two flat 64-style arenas, 512 language -bytes and 128 OpenType feature records per arena, plus reusable mutation, cascade-order, and nesting scratch. A style -update sorts mutations by stable ID, retains only the final operation for each ID, and merge-walks them with committed -styles into the inactive arena; language and feature payloads are compacted during the merge rather than retained as a -`Vec` per span or allowed to accumulate stale bytes. Validation covers canonical absent fields, finite/positive values, -language/tags, feature and UTF-16 boundaries, registered font stacks, one complete root, unambiguous equal-range cascade order, -nested rather than partially overlapping ranges, and cross-section payload aliasing. Commit swaps arenas; abort clears -only pending lengths. Once styles exist, a text edit must leave all retained ranges valid and cannot remove the sole -root. A real-font compiled-Wasm transaction proves the first combined text/root update and an invalid root removal do -not grow memory after session creation. Reachability changes optimized Wasm from 856,831 / 319,003 / 252,236 to -888,423 / 332,740 / 262,748 raw/gzip/Brotli bytes (+31,592 / +13,737 / +10,512). Plans remain empty, so this is retained -state evidence, not a shaping/layout latency result. - -Retained styles now resolve inside the same transaction into maximal flat segments. The resolver consumes the validated -containment order once, keeps inherited values in a pre-reserved scope stack, applies each stated field once when its -scope opens, restores its parent when it closes, and coalesces adjacent semantically equal results. The root must state -a font stack, logical font size, and raster density; line height may remain absent so later layout can use natural font -metrics, matching the existing public API. Language and feature results reference compact retained style storage rather -than copying payload per segment. A nested/equal-range Rust proof resolves five exact segments with per-property -inheritance and authored same-range precedence. Host and SIMD Clippy plus real compiled-Wasm lifecycle tests pass. -Reachability changes the optimized module from 888,423 / 332,740 / 262,748 to 895,593 / 335,396 / 264,355 -raw/gzip/Brotli bytes (+7,170 / +2,656 / +1,607). The next open connection is Unicode/script/bidi run intersection and -HarfRust shaping; plan output is still empty. - -The frame decoder now borrows ordered UTF-16 replacement records and their offset-addressed payloads directly from the -pinned request. It validates canonical empty offsets, opcode/encoding, reserved fields, bounds, alignment, arithmetic, -and record/payload non-overlap before the session transaction. Rust applies sequential replacements into retained -scratch, swaps them into committed text only after plan commit, and clears scratch on abort or a malformed later edit. -The compiled-Wasm test performs a cold reserve/re-pin, inserts text, edits a position that is valid only if the first -update was retained, rejects an out-of-bounds edit without changing the active A/B publication, and observes no memory -growth on the same-capacity edit. This is real Rust text retention, not shaping or layout; plan tables remain empty and no -latency result is claimed. Reachability changes optimized Wasm from 822,469 / 306,502 / 242,707 to -825,298 / 308,030 / 243,323 raw/gzip/Brotli bytes (+2,829 / +1,528 / +616 shared-runtime bytes). - -Session creation also prewarms both retained UTF-16 buffers to 1,024 units by default, and the cold create/reserve ABI -accepts an explicit text capacity for known large paragraphs. This removes the observed second-buffer lazy allocation -without giving every text object the 25K-glyph benchmark footprint. Production analysis/shaping/layout scratch will be -one synchronous engine-global 32,768-record workspace, reserved once when those arrays land and shared by every session. -The compiler-published `initialize()` export is invoked by the standard host immediately after Wasm instantiation. It -now reserves both the plan-glyph arena and HarfRust's actual 32,768-codepoint internal info/position allocation plus one -equally sized decoded-context array. Each segment returns the same allocation through `GlyphBuffer::clear`, including -restoration after fallible setup. Initialization grows linear memory from 1,245,184 to 4,980,736 bytes (57 pages), and -a repeated call preserves buffer identity. Policy-specific gather lanes settle during cold registration. The optimized -artifact is 847,814 raw / 315,809 gzip / 249,629 Brotli bytes, +2,234 / +524 / +408 over the policy-gather checkpoint. -The old three-export batch result still allocates its temporary output vectors, while bidi/layout arrays remain open; -this is HarfRust workspace evidence, not yet a complete allocation-free `text_update` or latency result. - -The frame path now accepts complete editorial geometry in the same update as text. Rust borrows constraint, region, -exclusion, polygon-vertex, and inline-object records directly from the pinned request; it validates request limits, -finite ordered bounds, enum/reserved fields, identity and region ownership, vertex containment, text anchors after -pending mutations, and cross-section non-overlap before session mutation. A placement-independent fingerprint omits raw -vertex offsets, so repacking equal geometry does not manufacture invalidation. The compiled-Wasm proof commits one -rectangle region with an exclusion and inline object, then rejects a forged region reference without advancing the -published A/B revision. Layout does not consume these records yet, plans remain empty, and no latency claim is attached. -The optimized module is 856,832 raw / 318,999 gzip / 252,620 Brotli bytes, +9,018 / +3,190 / +2,991 over the shaping -workspace checkpoint. The still-TypeScript 25,515-glyph baseline measures 54.02/12.29/8.50/39.12 millisecond medians -for cold/font-size/width/text; it does not execute this geometry decoder and remains target evidence only. - -Ordered font stacks now have cold Rust lifecycle operations independent from frame updates. A stack is nonempty, -duplicate-free, idempotent only for the same ordered handles, and retains its already registered shaping fonts until -stack disposal. A compiled-Wasm integration test uses the real baked Inter shaping payload to prove that a retained -member cannot be disposed and becomes disposable after the stack is released. Per-font technique/resource binding and -fallback shaping remain the next slices; this registry alone makes no layout or timing claim. The selected compact -vector registry produces an optimized 828,401 raw / 309,252 gzip / 244,402 Brotli-byte Wasm. A rejected generic tree-map -version measured 837,865 / 312,057 / 246,478, so cold linear lookup avoids 9,464 raw and 2,076 Brotli bytes. - -Render-policy registration now retains one exact input-source record per F32/U32 program field. Numeric scope tags -select semantic, glyph, resource, or strike lanes; source order is fingerprinted, so changing a gather recipe under an -existing policy handle fails atomically. The compiler-derived policy request/program/input layouts are 44/64/4 bytes, -and compiled-Wasm tests pin their offsets, tags, conflict behavior, and malformed-input rejection. The optimized module -is 829,906 raw / 309,646 gzip / 244,790 Brotli bytes, a +1,505 / +394 / +388 contract cost. - -Per-font render bindings now cross a separate cold compiler-mapped ABI and become owned Rust engine state. A binding -contains one technique/program variant, dense field-major glyph lanes, scalable or strictly ordered physical strikes, -dense strike×glyph resource addresses and lanes, and a resource directory with its own field lanes. This admits mixed -Bitmap/MSDF/Slug stacks without a universal union record or a technique repeated by `Text`. Selection is one bounded -nearest-strike pass with the lower exact tie, and MSDF/Slug take the one scalable-strike branch. Rust hostile-input tests -cover table shapes, overlap, reserved data, nonfinite floats, invalid resources, and selection; compiled Wasm uses the -real baked Inter glyph count to prove owned/idempotent registration, conflict, stack retention, and disposal. The -optimized module is 838,060 raw / 312,606 gzip / 246,732 Brotli bytes, +8,154 / +2,960 / +1,942 from the preceding -policy-source checkpoint. Policy-directed gather and frame use remain open, so this is a size/ownership result rather -than a layout-latency claim. - -The policy gather now resolves each glyph's program-specific recipe across semantic, font-wide glyph, selected-strike, -and selected-resource fields into shared field slots, then lends those lanes directly to the existing plan compiler. -F32/U32 lanes use 16-byte-aligned four-record blocks with scalar tails; programs with fewer fields receive zeroes only -in unused shared slots, avoiding a built-in-technique union. `initialize()` reserves the policy-independent 32,768 × -60-byte `PlanGlyph` arena: compiled Wasm memory moves from 1,245,184 to 3,342,336 bytes once and a repeated initializer -does not grow. Registering the one-F32-field integration policy then settles its exact lane from 3,342,336 to 3,538,944 -bytes; identical registration does not grow. Rust gathers all four source scopes through a nonempty ordered plan and -pins exact payload bytes without changing capacity. The production frame invokes the same gather but still supplies no -layout glyphs. The optimized artifact is 845,580 raw / 315,285 gzip / 249,221 Brotli bytes, +7,520 / +2,679 / +2,489 -from the binding checkpoint. Nonempty frame latency remains unclaimed. - -The asynchronous frame transport has a test-only, byte-opaque ownership proof. A functional worker-side state machine -copies the selected Wasm publication once into a capacity-classed `ArrayBuffer`, transfers it with a numeric ownership -token, and charges the actual transferred capacity against explicit outstanding-count and outstanding-byte bounds. A -missing return therefore produces observable backpressure instead of unbounded allocation. Renderer retirement -transfers the same storage back to the worker; only a valid token/capacity pair can re-enter the bounded best-fit pool, -and an over-limit return becomes unreachable on the worker for worker-side collection. Tests cover detachment in both -directions, exact bytes, reuse, missing returns, forged and duplicate returns, failed sends, oversize rejection, and -worker-side discard. The transport never decodes the compiler-defined frame layout, and it remains unwired from the -shipping asynchronous TypeScript path until the Rust semantic tables exist. - -The retained-engine kernel lab now fixes the first storage choices before semantic chunks land. It captures the real -25,515- and 100,602-glyph paragraph arrays, derives deterministic metric/advance lanes, and compares scalar, -compiler-vectorized, and selected hybrid artifacts through one direct-pointer interface. Node 24 and Chromium 149 -produce identical horizontal, vertical, partial-tail, and four-byte-aligned hashes with no warm allocation path or -memory growth. Compiler-vectorized straight-line source owns record packing because hand-written shuffle packing did not -beat it. Explicit `i8x16` break/bidi masks and integer-exact `i64x2` summaries pass the 20% phase threshold; the large -workload selects ABI-private 64-cluster, 16-byte-aligned SoA chunks over 32 and 128. The production policy executor -retains the validated program, resolves store-buffer indices once at registration, rejects structurally invalid calls -before writes, and executes four records per explicit SIMD bytecode dispatch with scalar tails. The representative -17-operation program over 25,515 glyphs measures 1.174→0.428 ms p95 in Node and 1.113→0.438 ms in Chromium; the 100,602- -glyph comparison measures 4.499→1.698 ms and 4.350→1.750 ms. Compiler auto-vectorization did not improve policy -execution. All compared artifacts preserve exact output bytes and warm memory identity. Direct regions must belong to a -live host allocation before the executor can borrow retained engine state. Binaryen output contains the intended vector -instructions. The selected lab artifact adds 4,185 raw / 1,096 Brotli bytes over scalar. The standard production -`+simd128` artifact measures 725,572 raw / 269,260 gzip / 211,013 Brotli bytes: 530 raw and 633 gzip bytes smaller, but -62 Brotli bytes larger, than the 726,102 / 269,893 / 210,951 scalar build. SIMD is the default build with no runtime -dispatch; `PMNDRS_TEXT_SHAPER_SIMD=0` produces the scalar release valve, whose disassembly contains no SIMD instructions. -Boundary search, native SIMD, and the complete hot-update contribution remain explicit open measurements until those -engine stages exist. - -Item 8.3 promotes `@pmndrs/text/raster/msdf` from an identity-only contract to the browser module and adds the isolated `@pmndrs/text/bakers/msdf/validate` entry. The standalone path layers the pinned Khronos validator, byte-identical Draft-04 schema, and semantic checks for reciprocal identity, descriptor-authenticated generation values, `planeUnitsPerEm = emSize`, view ownership, exact dense records, page bounds, embedded/external length and SHA-256 authentication, single-level linear RGBA8 KTX2 structure and data-format metadata, arithmetic limits, and a 256 MiB padded-base-array residency ceiling. Canonical Inter's ten legacy-default pages round-trip through both packaging forms; field deletion, record/page mutations, KTX2 and DFD corruption, missing/tampered external pages, and budget failures are named negative controls. - -The runtime repeats no parallel wire-format implementation. Bitmap and MTSDF renderers plus both standalone validators consume the same dependency-light KTX2 and dense-record rules; only the standalone layer imports Khronos/Ajv. The renderers also share the lossless-atlas adapter, unit quad, parallel-array checks, and resolved-paint lookup. The MTSDF resource uploads only its authenticated base levels into one padded texture array, samples them bilinearly, sizes reconstruction with screen derivatives, and owns one material per logical array; disposal releases materials and textures transactionally. - -One instanced batch family handles fill, outline, opacity, and translated hard shadow. The version-matched TSL graph reconstructs the fill edge from the RGB median and consumes alpha's true signed distance for effects. Shadow offsets expand each instance's geometric bounds and shift the same authenticated atlas sample, while clamped sampling and an explicit in-glyph mask prevent neighboring atlas cells from bleeding into the result. V0 outlines are bounded to half of the resource's authenticated full `pixelRange`; the exported `MTSDF_MAX_OUTLINE_ATLAS_PIXELS` is specifically the four-pixel limit for the default 64/8 configuration, while non-default resources derive their limit from their own authenticated range. A larger request fails instead of silently clipping. Paint updates reuse geometry and rewrite only owned instance attributes. The canonical Inter integration test decodes all ten real legacy-default pages, creates and repaints a live batch, verifies normalized effect attributes, and proves idempotent batch/resource cleanup without loading baker Wasm into the runtime graph. A real 32/4 artifact accepts its exact two-atlas-pixel boundary, normalizes it to half of that resource's field range, and rejects `2.0001`, distinguishing the configured limit from the exported default. - -The checked SIMD comparison builds scalar, compiler-auto-vectorized, and explicit-four-lane kernels from isolated target directories. Every variant preserves all seven corrected native-oracle hashes and the complete Inter result of 2,915 generated glyphs, 22 non-rendering rejected slots, checksum `a5a6aa6e`, and composite SHA-256 `f6381c2f…eef6`; an instrumented warm seven-call corpus records seven request allocations, zero reallocations, and seven deallocations, one owned output copy occurs per call, and Wasm memory does not grow after the cold corpus. On Node 24, scalar measured 46.462 milliseconds for seven warm calls versus 47.079 milliseconds for explicit SIMD. Chromium 149 measured 47.6 versus 48.1 milliseconds. Explicit SIMD improves the complete Inter warm pass from 48.13 to 45.38 seconds, a 5.7% stress/offline win, and saves 297 Brotli bytes. Because the supported runtime default is bounded interactive baking and the target feature would require an alternate artifact, scalar remains the only shipped baker kernel; item 8.6 retains the explicit variant as evidence for phase-led optimization rather than exposing a toggle now. The repository-local Vitexec capture and full-font request emitter preserve the experiment as repeatable evidence rather than product complexity. - -The Three-backed `Text` object is a real Three.js `Group` over the portable transaction contract rather than making Three part of that contract. It validates one complete candidate state before committing a patch, resolves every distinct root/span font through registry-scoped loader and HarfRust caches, shares decoded raster resources through `RasterRuntime`, and owns the resulting paragraph and raster batches as one generation. The first incomplete generation stays hidden; a later load keeps the prior complete generation visible until every raster target can swap atomically. React Suspense owns cold font, shaper, raster decode, and page preparation. The loader, shaper, and raster runtime additionally expose package-internal synchronous cache peeks: once those dependencies and layout-required pages are resident, `setProperties` shapes, lays out, plans paint, and stages synchronously without a consumer readiness wait. The committed generation remains live until `updateMatrixWorld` or `updateWorldMatrix` publishes the candidate before traversing raster children, and the React adapter invalidates its R3F root after changing core properties. Revision-scoped cancellation prevents stale work from publishing. Semantic no-op updates preserve an in-flight cold generation, abort signal, queued warm publication, and their original readiness observations instead of restarting work. Callback-only updates also retain that work and the latest `onLayout` observes the committed layout; after a genuine generation failure, the same semantic input explicitly retries rather than becoming permanently inert. Terminal font-disposal invalidation is distinct: a semantic no-op matching the invalidated generation's input preserves its rejected readiness state, while replacing that input may schedule recovery. The terminal state is scoped to the invalidated generation's own input, so an already-staged replacement remains recoverable if the superseded font is disposed; disposing that old font preserves the valid candidate and its readiness observation. Explicit disposal clears that saved terminal state along with pending and committed ownership. Every committed replacement releases its superseded font-disposal subscription while preserving a shared paragraph when only constraints changed. Paint-only updates stage through the same lifecycle while reusing the positioned layout, resident pages, and one glyph-to-span paint-index plan while text and normalized shaping ranges are unchanged. Validation and staging receive the same resolved `GlyphPaint` value, and repeated same-range updates reuse its `Uint16Array` index storage instead of rebuilding a code-unit map, palette-key map, and glyph-index array. One reusable Three color converter removes transient `Color` objects without weakening public color validation. Semantic no-op paint updates skip staging, width updates reuse paragraph shaping, shaping changes replace the paragraph, and disposal releases every owned batch and paragraph. MTSDF batches retain outline-width and shadow-offset structure per instance: color-only updates write only paint attributes, while structural paint changes take the full geometry/UV path. Direct scalar attribute writes avoid short temporary arrays on both paths. Runtime performance instrumentation does not ship in this package; the benchmark measures public scheduling externally. Integration evidence proves the previous layout remains live until object traversal, publication precedes retained-child traversal, React performs no consumer `ready` wait, same-range paint storage retains identity, asynchronous multi-font preparation aborts after a sibling failure, superseded-font disposal preserves a resident replacement, commit-contract faults cannot abort sibling traversal, and MTSDF color-only updates preserve origin/size/UV structure. A raw span font inherits the root raster definition but resolves its own font-local resource, preventing cross-font atlas reuse. - -The `@pmndrs/text/react` export now provides the thin runtime described by the accepted API. It flattens nested text nodes into one UTF-16 string plus ordered inherited spans, rejects nested object/layout props and non-text children, creates one core object only after React 19 dependencies resolve, forwards that object through its ref, and reconciles ordinary R3F transforms separately from core text properties. The forbidden source `text` and `spans` props remain explicit `never` fields because R3F v10's wider intrinsic-element types would otherwise weaken that public boundary. Semantic feature and inline-paint comparison prevents fresh-but-equal React values from scheduling layout or glyph-buffer work; a fresh `onLayout` callback updates ownership without repainting. `useFont`, `.preload`, `.clear`, and `lazyRaster` reuse the same loader, shaper, and raster dependencies as the core. A deterministic microtask-delayed disposal distinguishes React Strict Mode's setup/cleanup/setup cycle without sleeps or timer cushions. - -React Three Fiber 10.0.0-alpha.2 declares compatibility with the repository's React 19.2 and Three.js 0.185.1 pins, and repository code imports only `@react-three/fiber/webgpu`. Two narrow package patches own upstream prerelease gaps: the WebGPU entry no longer eagerly imports and auto-extends Three's browser-only Inspector during module evaluation, and test renderer 9.1.0 resolves both Three and R3F through their WebGPU entries. Upstream should make Inspector registration lazy and publish a v10-aware WebGPU test-renderer entry; no renderer implementation is forked. The Node reconciler harness installs a no-op animation request surface required by `frameloop: 'never'` and restores every global after the file, without timers or readiness polling. It proves resolved reconciliation, span flattening, identity retention, ref forwarding, update classes, invalid nesting, and disposal. The shared benchmark-registry target mounts public nested `` through a real R3F root backed by `WebGPURenderer`, matches pinned paragraph oracles across reflow, retains one core object, and submits a renderer frame. A live-browser Vitexec probe separately owns pending Suspense evidence; no application workaround is carried for the package test renderer's uncached-suspension behavior. - -The Node host rejects distinct source files that collapse onto one output path before any bake begins, reports mutually exclusive phase timings, and retries the lazily loaded default bitmap baker after a failed initialization instead of pinning a rejected promise. These rules keep batch publication deterministic and make measured phase totals honest. The loader also refuses URI-addressed external raster entries without SHA-256 authentication; resolver-only delivery remains explicit and hash-optional. - -The browser-safe `@pmndrs/text/raster/bitmap` subpath now owns bitmap generator/format constants, the exact `1..=1022` ppem V0 range, runtime validation of the non-empty static strike tuple, ascending canonical strike order, the complete generator-versioned descriptor, RFC 8785 serialization, and SHA-256 raster-key derivation. Equivalent strike sets therefore produce one identity regardless of caller order, while duplicate, non-integral, non-finite, non-positive, or out-of-range values fail before baking. The implementation uses Web Crypto and imports no Node built-ins.[^bitmap-identity] - -The optional `@pmndrs/text/bakers/bitmap` subpath wraps a `no_std + alloc` Wasm generator through its Rust-generated JSON ABI and direct linear-memory shim. The contract declares its sole `env.pmndrs_text_bake_progress(completed, total)` import so a Worker can report a long synchronous bake without polling, timers, or main-thread work. Fontations/Skrifa owns font and outline interpretation; a small pen bridge feeds Zeno's maintained antialiased rasterizer. The dependency-light `raster-artifact` crate owns the dense 20-byte record writer, channel-agnostic shelf atlas, lossless R8/RGBA8 KTX2 encoding, GLB framing, content hashing, and packaging enums shared by bitmap and MTSDF bakers. Bitmap output remains byte-for-byte identical after the extraction. Artifact and page filenames bind both `shapingHash` and `rasterKey`, preventing two fonts with the same raster configuration from overwriting one another. Glyph masks are placed as they are rasterized instead of retaining a second full-face bitmap set, fixed buffers reserve fallibly, and the atlas-compatible ppem bound rejects structurally impossible requests before font work. The bridge decodes borrowed response metadata while the allocation is live and copies only returned artifact ranges. Canonical path remapping plus Binaryen 129.0.0 `-Oz` produces a hardened distributed module of 621,645 raw bytes. The shared artifact boundary does not enter rendering or shaping bundles. - -The isolated `@pmndrs/text/bakers/bitmap/validate` entry reuses the core package's strict GLB framing and pinned Khronos validator, evaluates byte-identical Draft-04 bitmap/resource schemas, parses every declared page variant with `ktx-parse` 1.1.0, and enforces reciprocal identity, exact strikes, dense records, page bounds, KTX2 dimensions/format/levels, GPU-format/feature/quality mapping, external length/hash, arithmetic limits, and GPU budgets. Rust independently parses every native-test KTX2 through `ktx2` 0.5.0. Canonical Inter source/artifact/report/record/page identities, the fixed optimized Wasm size, embedded/external parity, 65,535-glyph boundaries, generated/published ABI identity, deterministic arbitrary-font Rust fuzz smoke, and fixed-seed artifact mutation fuzz smoke are executable fixtures. The source-remapped macOS arm64 and Ubuntu x64 modules have identical lengths and exact product output but different internal function-index order, so a release hash identifies the canonical builder output rather than pretending native code generators are cross-architecture byte canonicalizers.[^bitmap-baker] - -The internal generic composer authenticates every returned artifact, checks reciprocal shaping/glyph/raster identity, retains external companions and pages, and embeds package-owned companion data without interpreting its semantics. Integer glTF buffer-view references are rebased through the shared naming convention, so multiple distinct extension types compose without a closed registry. Exact Inter goldens cover combined embedded, combined external, and the identity-neutral empty raster set; both the core and bitmap validators round-trip the combined bytes. - -The Node-only `@pmndrs/text/bake` subpath closes roadmap item 2.4 around the item-2.1 TypeScript 7 AST/symbol discovery engine. `bakeFont` handles an explicit filesystem input/output pair and retains each selected raster package's exact option type. `bakeProject` finds composed tokens and statically visible core/React raw forms across TypeScript, TSX, JavaScript, and JSX; reduces immutable font/raster expressions; maps URL pathnames into canonical asset roots; groups identical sources; and dynamically imports only the exact verified raster-package ESM entry. It never executes application modules. One internal compiler adapter owns every unstable TypeScript import, project snapshot, symbol handle, alias, and declaration-resolution operation; an exact-version assertion and source-boundary test make compiler upgrades explicit. - -The native-ESM `pmndrs-text-bake` command is a thin `bakeProject` adapter. The host writes exclusive same-directory temporary files, backs up existing regular-file targets, publishes only after every artifact is staged, and restores all earlier targets if a later rename fails; process termination during the multi-file swap is not claimed as a filesystem transaction. It rejects lexical and filesystem-identity input/output overlap, non-regular existing targets, and unsafe package-owned filenames, then cleans temporary or backup files after success, cancellation, and ordinary failure. Its plugin type guard proves each required property with `in` checks before reading it rather than asserting a partial module shape. Discovery reports are sorted by source file and lexical AST offset after concurrent analysis. Its report adds phase/total timing, before/after RSS, explicitly process-lifetime peak RSS, output paths and hashes, and raw/gzip/Brotli transport sizes to the authoritative core/raster/container byte report.[^node-host] - -The public `FontLoader` and `FontRegistry` close item 3.1. They normalize every accepted input form into deterministic source/baked URLs, deduplicate request promises and validated shaping identities, and run the same hostile-input validator before registration. The large pinned Khronos/Ajv validation graph is cached behind a separate dynamic import: package import stays small, while the first actual registration still validates before publishing anything. Registration owns the bytes and retains the extracted reduced SFNT, glyph extents/availability, metrics, Unicode/source provenance, source candidates, and opaque raster directory required by later stages. Exact Inter fixtures compare those retained shaping views byte-for-byte with independent GLB validation. Embedded and external raster delivery variants merge by raster identity; companion attachment authenticates generic framing, ranges, reciprocal identity, and hashes before package-owned decoding. Streaming limits precede allocation, lifecycle handles are registry-scoped and invalidated on disposal, and a deterministic loader mutation corpus is part of the ordinary fuzz smoke.[^loader] - -Source inputs remain baked-first by default: a string, URL, or `{ source }` request derives and probes the canonical sibling asset. `{ source, baked: null }` is the explicit source/runtime form and performs no sibling request; omission and `null` have distinct React preload identities. Runtime raster generation authenticates the returned package against the caller-derived raster key, shaping hash, glyph count, glyph-ID width, extension, and version before attaching it to a source-only registered font. Public arbitrary companion attachment still requires a directory reference, so the runtime seam does not weaken the hostile-asset boundary. Integration tests cover both the fetch decision and the complete core-only font → generated bitmap → decoded raster path. - -Milestone 7.1 strengthens that lifecycle at the asynchronous raster publication boundary. If a decode completes after its runtime or owning font generation has been disposed, the decoded resource is released exactly once and the awaiting caller receives `AbortError`; it can never observe a resource that was already torn down. Named tests also prove stale raster handles, same-artifact re-registration with a fresh font handle, stale-handle shaping rejection, and independent shape-plan ownership. The packed tarball is exercised as the consumer artifact: every declared JavaScript and Wasm/JSON subpath resolves as ESM, build-only `.tsbuildinfo` state is excluded, the executable CLI answers through its installed path, the fallback runs as a real module Worker from an isolated packed Vite consumer, and CommonJS loading fails rather than discovering a hidden compatibility build. - -Paragraph instances retain only the 32 most recently used entries in each measurement, line-plan, positioning, geometry, and final-layout cache. Each registered font likewise retains at most 64 least-recently-used HarfRust shape plans. Equivalent hot calls still reuse the same results, while adversarial constraint or language/feature variation has a fixed retention ceiling; updating or disposing a paragraph and disposing a font release the respective caches immediately. Paragraph preparation indexes cluster starts plus spacing/space prefix sums once, and positioned fragments use binary bounds over monotone HarfRust clusters instead of rescanning the paragraph or complete shaped run at every glyph boundary. - -The `@pmndrs/text/runtime-bake` boundary closes item 3.2. It is dynamically imported only after a missing, invalid, or incompatible baked probe; creates one named module Worker; transfers provenance-preserving owned byte ranges; and runs the exact portable `@pmndrs/text-font-baker` wrapper plus its package-owned optimized Wasm. Offline and Worker hosts share dependency-light V0 descriptor, sole-artifact, successful-promise-cache, and owned-transfer rules while keeping filesystem and fetch behavior separate. The host owns a strict FIFO with one active bake: queued cancellation removes only that job, active cancellation replaces the Worker before resuming queued work, and the Worker entry independently serializes accepted messages. This bounds active CPU/Wasm memory without relying on async message ordering. The host predicate promises only the message fields it proves and consumes instead of overclaiming the complete baker report. A failed core initialization is retryable in both hosts. Canonical Inter fixtures execute the offline host and Worker entry, compare their complete artifacts byte-for-byte with the direct portable core, and then send the Worker result through loader provenance and hostile-input validation. The current independent size lanes report a 9,524-byte minified runtime host, 8,936-byte Worker JavaScript, and one 422,538-byte Wasm artifact; reviewed ceilings prevent heavy validation, Node, discovery, composition, or raster dependencies from entering those runtime graphs. - -Milestone 3 closes with browser-executed parity and cancellation. The benchmark product's public loader target first hashes the real module-Worker artifact against the canonical Node artifact, validates and registers it, then runs the complete missing-sibling fallback in Chromium. Shared loads now reference-count consumers: one abort detaches safely, the final abort reaches fetch/stream/Worker work, and an otherwise-idle Worker terminates immediately after the final success, failure, or cancellation and recreates on demand without timers. Stale events from a terminated Worker cannot settle requests owned by its replacement. The explicit queue keeps one active post under concurrent integration tests; two live Chromium evidence runs preserved the canonical hash while a three-font burst completed in 30.8–32.0 ms versus 68.3–88.6 ms for three separately initialized sequential Workers. These observations are recorded without a timing threshold. Shaping-identity deduplication retains source bytes only when their source hash matches the registered primary provenance; alternate URLs remain hash-qualified candidates. - -Milestone 4 closes the package-owned HarfRust runtime. The Rust 1.97.1 module uses HarfRust 0.12.0 and matching `read-fonts` 0.41.0 under `no_std + alloc`, exposes a compiler-described direct-memory C ABI, and keeps its allocator private. Build-only output publishes JSON for tools and typed TypeScript for the host without embedding either representation in Wasm. Its request registry owns zero-initialized, caller-sized buffers capped at 64 MiB and accepts only exact live pointer/length pairs, eliminating reconstructed raw ownership. The TypeScript bridge releases earlier allocations if a later registration copy fails. Canonical Inter contributes 147,192 SFNT bytes, 23,496 dense-extents bytes, and 368 availability bytes, or exactly 171,056 retained bytes. Registration is registry-scoped and idempotent; font/shaper disposal releases owned data and plans. - -The four parallel Bitmap, shaper, MTSDF, and Slug ABI producers share the font-baker package's causal command-capture boundary. Their successful process exit is not enough to publish a contract; the build waits for stdout EOF before validating and freshness-checking the complete compiler-generated JSON. - -One `shapeBatch` or `reshapeRanges` call packs validated UTF-16, run, feature, language, and range records through offsets from the generated ABI. It returns aligned borrowed SoA views with absolute UTF-16 clusters, glyph IDs, four positions, and mapped flags. Result layout and arena publication reserve fallibly before writing, so allocation exhaustion returns `RESULT_TOO_LARGE` rather than trapping after shaping. Every pinned Inter case passes bit-for-bit through the complete source → baker GLB → validator → registry extraction → Wasm chain for both calls; multi-run batching, plan reuse/disposal, surrogate boundaries, extents conversion, malformed records, and forged release metadata are executable. The fixed-seed raw-ABI mutation lane registers real validated Inter views first, so seed and surviving mutated requests reach HarfRust while malformed variants remain deterministic. The browser product batches all eight cases into one 97-glyph call with exact output hash `dc30c21c`. The hardened dynamic-Talc optimized module is 680,312 bytes raw, 253,568 bytes gzip, and 199,365 bytes Brotli; its JavaScript bridge is 32,778 bytes minified, 9,288 bytes gzip, and 8,257 bytes Brotli. - -Roadmap item 5.1 adds synchronous paragraph preparation and measurement. Unicode 17 Script/Script_Extensions tables are generated deterministically from the pinned UCD package; `unicode-segmenter` supplies extended grapheme boundaries and `@cto.af/linebreak` supplies line-break opportunities. The ordinary suite executes all 766 official grapheme vectors and all 19,338 official line-break vectors from hash-pinned gzip fixtures. Prepared text is split only at grapheme-safe style/script boundaries, shaped once through the existing GLB-retained HarfRust path, copied immediately out of its borrowed result arena, and measured into legal break clusters with explicit baselines. Equivalent width constraints reuse frozen measurement objects and width-only reflow performs zero Wasm calls. - -The target Rust frame path now derives the same line opportunities internally. Its generator resolves the pinned `@cto.af/linebreak` property trie into a compact Rust scalar partition, and a specialized allocation-reusing `no_std` evaluator ports the ordered UAX #14 rules while retaining the upstream MIT notice. The Rust lane independently passes all 19,338 unchanged official Unicode 17 line-break vectors at canonical UTF-16 offsets; its results are retained with the session's transactional Unicode analysis rather than serialized through the legacy shaping ABI. - -The same frame path aggregates final fallback glyphs into a retained grapheme-cluster SoA. It keeps ordered double-precision advances, compact shaping/line-break flags, style/source/font identities, and a UTF-16 offset index. Font UPEM and horizontal line metrics are parsed once at registration; glyph design-unit advances are scaled once per contributing final font, and authored letter/word spacing joins that accumulation without constructing per-cluster objects. Optional Unicode line opportunities survive only where the next cluster is safe according to HarfRust output. - -An allocation-free Rust line kernel advances that retained grapheme cursor for a supplied width while preserving word, character, no-wrap, required-break, over-wide-cluster, and trailing-hard-break behavior. It remains an internal proof until declarative region bands and exclusions drive it during a production frame update. - -Validated flow snapshots are now copied into transactional A/B Rust storage: constraints, ordered regions, exclusions, and rebased polygon vertices remain owned after request memory is reused. The rectangle band kernel subtracts intersecting exclusions into retained, bounded inline-slot scratch and fails explicitly if the declared per-band slot envelope is exceeded. Polygon intersection and line placement are not yet connected. - -The bounded simple-polygon kernel reuses critical-block, edge-crossing, section, and intersection arrays. Concave region cross-sections can yield multiple normalized inline slots; polygon exclusions conservatively project over the full margin-expanded line band before subtraction. Focused tests cover triangle, concave, exclusion, horizontal-boundary, and slot-limit behavior. Production line placement is still the next connection. - -Production horizontal frame updates now retain line and fragment arrays derived from those slots. Each band may carry multiple same-baseline fragments around holes, uses actual selected fallback-font metrics, and performs at most one conservative height retry. Sequential regions consume one cursor without balancing; an exact fixture overflows four lines through region IDs `[1, 2, 2, 2]`. Vertical placement and render-plan gather are still open. - -Stable render identity begins with a transactional ID parallel to every retained UTF-16 unit. Ordered replacements preserve IDs for unchanged units even when earlier insertions shift their offsets; inserted units receive monotonic nonzero IDs, and abort/retry restores the allocator deterministically. Graphemes inherit their first unit ID. Per-glyph identity and revision still follow before plan publication. - -Cluster construction now emits a flat logical-cluster-to-shaped-glyph adjacency alongside measured advances. One count, prefix-sum, and fill pass groups glyph indexes without changing HarfRust's run-local order, so an RTL-shaped `[2,1,0]` stream resolves to logical cluster slices `[2]`, `[1]`, `[0]`. Active and pending arrays reuse their high-water capacities. The optimized shaper is 1,043,289 raw, 394,074 gzip, and 304,902 Brotli bytes at this checkpoint. Stable glyph allocation, positioning, and nonempty plan publication remain open. - -The stable GPU slot pool's exact open-addressed identity lookup is now one reusable epoch-cleared component for both plan storage and the upcoming cluster/glyph reconciliation. Hashes select probe positions only; full `u32` equality decides every match. A collision fixture proves distinct keys remain distinct, duplicate insertion is rejected, and a same-capacity prepare clears logically without reallocating. The refactor alone measures 1,043,094 raw, 394,035 gzip, and 307,259 Brotli bytes; the compressed regression is retained because removing a second hot-path identity-table implementation is the stronger invariant. - -Glyph identities now reconcile transactionally through that shared index. A cluster retaining its stable text identity keeps each surviving glyph ordinal's monotonic ID; a new cluster or additional ordinal receives a new ID, and abort discards the pending allocator cursor. An exact insertion-and-growth fixture maps committed glyph IDs `[1,2,3]` to `[4,1,3,5]`, then repeats from the same pre-update state with identical IDs and unchanged scratch capacities. The production session commits the glyph allocator with its cluster A/B swap. Optimized size is 1,044,797 raw, 395,222 gzip, and 307,795 Brotli bytes. Exact positioned-content revisions and nonempty plan output remain open. - -Horizontal positioning now writes retained `LayoutGlyph` records and six F32/four U32 canonical semantic lanes entirely inside the frame transaction. UAX #9 L1 line resets feed allocation-reusing L2 cluster reordering; editorial slots apply start/center/end alignment or bounded space justification independently. Glyph origins use HarfRust offsets and actual selected-fallback metrics, accumulate in `f64`, include baseline shift, and narrow once. Baked font extents produce primitive bounds; absent extents still advance layout but emit no render instance. Exact float-bit and integer comparison assigns transactional content revisions through the shared identity index. A unit fixture preserves revisions `[1,2]` for a byte-identical rebuild and changes them to `[3,4]` after a one-pixel slot shift. A compiled Inter update publishes nonzero resource, buffer, patch, primitive, and draw tables through `text_update`; its identical warm successor preserves `memory.buffer` and emits zero patches. Optimized Wasm is 1,057,210 raw, 400,071 gzip, and 311,492 Brotli bytes. Complete 25,515-glyph latency is not yet measured, and vertical positioning, narrowed boundary shaping, truncation, decorations, and public renderer consumption remain open. - -Exact retained invalidation now stops at the earliest affected Rust stage. Font-size changes reuse Unicode, bidi, and HarfRust output; exact rectangle geometry reuses flow when no inline object needs retained comparison; unchanged ordered-direct frames publish an empty plan transaction without walking glyphs. A terminal hard-break cluster is skipped before visual-run lookup, matching its deliberate absence from shaping runs. At 25,515 laid-out glyphs with eight discarded warmups and 31 samples, the one-F32 diagnostic path measures 13.693/0.001/4.090/3.374/13.927/13.986 millisecond medians for cold/no-op/font-size/full-column-resize/suffix-edit/localized-edit, with corresponding p95 values of 14.111/0.001/4.236/3.706/14.511/14.381 milliseconds. The unchanged TypeScript comparison measures 55.25/11.90/8.36/38.55 millisecond medians for cold/font-size/width/suffix-edit. - -The full-policy benchmark validates real baked Inter artifacts and compiles all three first-party GPU shapes: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec4 buffers totaling 112 bytes, and five Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. Absent raster records are omitted exactly as `RasterTechnique.select` omits them, leaving 21,805 renderable instances from the unchanged 25,515-positioned-glyph stress text. Derived linear color channels and inverse font size materialize only in requested gather lanes; they add no retained per-glyph arrays. SIMD execution transposes four-record SoA arithmetic into tightly packed vec2/vec4 output and uses contiguous 128-bit stores. - -Policy registration now propagates semantic dependencies to every physical buffer. Positioning records exact six-F32/four-U32 change bits in a compact side lane while preserving the 60-byte `PlanGlyph`; both ordered-direct and stable-indirect planning publish only buffers whose dependencies intersect. A full-column resize over 21,805 instances writes 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug rather than their 1,022.1/2,384.9/2,384.9 KiB cold-plan payloads. Font-size writes 340.7/340.7/681.4 KiB. Five-warmup/11-sample resize medians remain 4.599/4.916/6.057 milliseconds, so the exact payload reduction does not support a packing-dominance or general latency-speedup claim; layout remains above the sub-4 ms gate. The optimized module is 1,065,394 raw / 399,111 gzip / 317,830 Brotli bytes. A 97.19 MiB sequential-process high-water mark remains unresolved process evidence, not an accepted session budget. - -Order-preserving reflow now compares retained positioned glyphs directly by stable-ID slot and uses the exact identity index only after a reorder. A symbolized temporary Wasm profile identifies positioning as the largest sampled resize function and policy gather as the second. The canonical benchmark supports case isolation and an explicit profiling module while retaining its unchanged defaults. Current Bitmap/MTSDF/Slug resize medians are 4.414/4.984/5.196 milliseconds; inter-run variance prevents a precise speedup attribution and the sub-4 ms gate remains open. Optimized Wasm is 1,065,543 raw / 399,248 gzip / 318,131 Brotli bytes. - -Trivially LTR positioning now proves all retained bidi levels are even and no run is direction-overridden once, then walks logical clusters without filling line-level or visual-order scratch. Odd levels and overrides preserve the complete UAX #9 L1/L2 path. Two adjacent eight-warmup/31-sample Bitmap baselines measured 5.197 and 5.162 milliseconds; two optimized runs measured 4.849 and 4.878 milliseconds with the same 170.4 KiB patch. Post-change MTSDF and Slug medians are 5.355 and 6.001 milliseconds. The optimized module is 1,065,857 raw / 403,525 gzip / 318,137 Brotli bytes, and the sub-4 ms gate remains open. - -Policy validation now also compiles which source lanes and straight-line operations can reach each physical buffer. -Position-only updates gather zero placeholders for unreachable inputs and skip unreachable scalar/SIMD operations; -checkpoints, inserted glyphs, and non-positioning updates still evaluate every declared input and output. The gather -loop caches only consecutive font-binding and immutable policy-program resolution, while glyph selection and resource -selection remain per glyph. A factorial measurement rejected either optimization in isolation: selective gathering -measured 5.027/5.685/6.443 ms for Bitmap/MTSDF/Slug resize, and operation liveness measured -4.880/5.329/5.985 ms. Combined they measured 4.207/4.833/5.615 ms; adding resolution caching measured Bitmap at -3.981 and 4.120 ms in two canonical full-sequence runs, MTSDF at 4.646 ms, and Slug at 5.622 ms. A case-isolated -Bitmap run measured 4.799 ms, exposing material Node/Wasm tiering sensitivity, so the sub-4 ms target is approached but -not reproducibly closed. Final optimized size is 1,069,973 raw / 405,888 gzip / 319,558 Brotli bytes. - -The production `RuntimeShaper` and retained text engine now share the same initialized Wasm module and registered-font -state. A package-internal host owns policy, font-binding, font-stack, and session lifecycles, performs cold reservation -before pinning, copies only request bytes into the retained staging arena, and exposes each A/B render-plan publication -as a borrowed direct-memory view. The host does not decode typography or copy plan payloads. A compiled-Wasm integration -test publishes alternating slots and proves the preceding slot remains byte-stable. This is the production ABI seam; -the public runtime and Three adapter still use the legacy paragraph-batch path until request/policy compilation and GPU -plan lowering are connected. - -The first production Three policy registers Bitmap, MTSDF, and Slug together rather than assigning a synthetic -per-benchmark technique number. Its storage key includes technique, program, and raster resource; its draw key adds -`material_id`, clip, depth, and order. A renderer can therefore retain one physical glyph buffer across material -changes while emitting the draw partitions its backend requires. Production font-binding compilers lower validated -bitmap strikes, MTSDF glyph records, and Slug bands directly into one field-major request allocation. Exact integration -tests compare every emitted lane with the established real-font renderer-parity tables. Public string technique and -resource IDs lower through deterministic UTF-8 FNV-1a into the wire's nonzero `u32` namespace, and one runtime-scoped -registry rejects any collision before Rust registration. First-party policy and binding compilation share their exact -wire compiler with the public Three plan-program registry. A third-party program supplies static validated policy data, -one cold font-binding compiler, and one renderer material factory; it cannot insert a hot JavaScript callback into Rust -planning. Three render-plan consumption is the public imperative and R3F rendering path. - -Frame-request serialization is also production code rather than a benchmark helper. One final `Uint8Array` carries -text replacements; root and span style mutations; language and OpenType feature payloads; material, paint, decoration, -letter/word spacing and baseline shift; multiple constraints and sequential rectangle or polygon regions; exclusion -holes; inline objects; policy parameters; and publication-fence state. The compiler only validates fixed-width host -values and writes the generated ABI—it does not shape, lay out, batch, or pack text. Its current rectangle request is -byte-identical to the established benchmark helper, including surrogate-pair UTF-16. A broad structural fixture proves -all variable tables and payload offsets; acceptance of normalized rich public state by a real Rust session remains an -open Three-cutover gate. - -Rust now distinguishes a loaded-font render binding from the shaping font whose retained SFNT, plans, metrics, and -extents it reuses. Engine font stacks contain binding handles; each binding points to one shaping handle and carries its -own technique/resources. Fallback records preserve both identities through shaping and cluster aggregation, positioning -uses the shaping identity, and policy gather uses the binding identity. This permits one face to register several raster -techniques without duplicating shaping data and permits a fallback glyph to emit a different technique in the same -render plan. Compiled-Wasm tests prove both cases. The additional retained `u32` cluster lane changes optimized Wasm to -1,070,580 raw / 402,114 gzip / 319,662 Brotli bytes. Current 8-warmup/31-sample resize medians are -4.217/4.791/5.633 milliseconds for Bitmap/MTSDF/Slug; their 6–7% RSD does not distinguish the small movement from the -preceding 4.120/4.646/5.622-millisecond checkpoint. - -First-party engine ownership remains in the Three integration rather than `TextRuntime`, preserving the -renderer-neutral core graph. A lazy runtime-scoped coordinator registers the complete Three policy once, assigns -binding and session handles, and reference-counts ordered stack handles. Exact binding-handle sequences share a stack; -reversed fallback order does not. Last release disposes the Rust stack, and monotonic allocation avoids immediately -reusing a retired identity. A real-fixture integration binds Bitmap and MTSDF to one retained Inter shaping font and -proves these lifecycle rules. This coordinator is not imported by the public Three entry until the batch/session -render-plan cutover, so this checkpoint alone changes no shipping entry graph. - -Three render-plan consumption begins with a reusable validated view over the borrowed Wasm publication. The view reads -fixed Rust resource, buffer, patch, primitive, and draw records without converting glyphs or tables into JavaScript -objects. Its `DataView` spans the complete Wasm memory and is replaced only when the memory buffer identity changes; -ordinary A/B publication swaps update only the base offset. A real compiled-Wasm fixture shapes and lays out Inter in -one frame and proves every renderer table is nonempty and directly addressable. GPU buffer and draw realization remain -open, so this checkpoint claims neither public Three cutover nor end-to-end rendering performance. - -The public Three batch semantics require multiple independent paragraphs in one Rust session and one plan publication. -Multiple flow constraints inside one paragraph remain sequential/alternate regions for that prose; they are not a -substitute for separate `Text` state. The Rust gather workspace now supports a one-reservation `begin` followed by -allocation-free appends of independent positioned SoA inputs. A focused test proves two layouts retain exact order and -semantic fields in the combined plan input. Text, style, constraint, and inline-object records now carry an explicit -nonzero paragraph ID through the generated Rust/TypeScript ABI. The transitional single-paragraph session rejects mixed -IDs within a transaction and rejects rebinding across transactions, so keyed records cannot silently mutate one shared -state before the retained-child cutover. Text replacements reuse their former reserved word; style, constraint, and -inline-object records each grow by four bytes. The optimized Wasm measures 1,070,673 raw / 402,177 gzip / 319,722 -Brotli bytes, versus 1,070,685 / 402,154 / 319,914 at the preceding batching checkpoint; this does not establish a -material size change. Retained child state, session-wide stable ID allocation, and atomic shared-plan commit remain in -progress; no per-text-session shortcut is shipped. Paragraph identity is deliberately separate from presentation -order. A compact 12-byte paragraph-control record now declares an upsert's explicit batch order or removes a retained -paragraph; the decoder rejects duplicate IDs, duplicate declared orders, noncanonical removals, forged overlaps, and a -count above the frame's paragraph limit. The production frame compiler emits this table before all paragraph-owned -semantic records. The reachable validation path changes optimized Wasm to 1,073,123 raw / 403,431 gzip / 320,917 -Brotli bytes (+2,450 / +1,254 / +1,195 over the keyed-record checkpoint). The retained-state consumer is the next -checkpoint, so removal is currently rejected before mutation rather than falsely accepted. Adjacent -session ownership is now split without changing behavior: batch revision, policy binding, and render-plan compilation -remain on `EngineSession`, while the full text/style/Unicode/bidi/shaping/cluster/flow/positioning transaction lives in -one `ParagraphState`. This preserves the existing single child while making the next map conversion explicit and -testable. All 124 Rust unit tests pass. The optimized Wasm is 1,073,179 raw / 403,475 gzip / 321,149 Brotli bytes, -+56 / +44 / +232 over the paragraph-control checkpoint; no latency claim is attached to this ownership-only move. -Stable glyph IDs and semantic content revisions now allocate from transaction-local counters rooted in the owning -`EngineSession`, then commit only after shared-plan publication succeeds. `ParagraphState` retains its identity indexes -but no longer owns counter namespaces, preventing equal child-local ordinals from aliasing in one planner. The -single-child behavior remains byte-identical; the optimized Wasm is 1,073,074 raw / 403,537 gzip / 321,046 Brotli bytes -(-105 / +62 / -103 versus the ownership split), which is compression/code-layout noise rather than a size claim. -Validated text, style, constraint, and inline-object tables now expose borrowed per-paragraph span cursors. Each cursor -advances only across the current paragraph's contiguous fixed records, returns an empty borrowed slice when that -paragraph has no records, and lets the transaction reject any unclaimed tail. The current single child consumes these -views in production, so the multi-child loop will not need per-record maps, record copies, or a second decode. An exact -fixture consumes present and absent spans across every keyed semantic table. Optimized Wasm is 1,074,464 raw / 404,058 -gzip / 321,156 Brotli bytes (+1,390 / +521 / +110 over the session-identity checkpoint). -Paragraph creation and capacity growth now share one `ParagraphState` initializer/reserver. It prewarms the paired style -arenas and reusable mutation/resolution scratch once, then reserves every active/pending text-through-positioning arena -from one capacity policy. New map children can therefore reuse the proven setup without duplicating lifecycle code or -silently omitting a scratch lane. Optimized Wasm is 1,074,774 raw / 404,030 gzip / 321,343 Brotli bytes (+310 / -28 / -+187 over the borrowed-span checkpoint). -Paragraph finalization now has one complete ordered path: every preparation failure and explicit abort calls -`abort_all`, while successful shared-plan publication calls `commit_all`. This is the rollback boundary required before -one frame can prepare several child paragraphs. A fresh optimized-Wasm rebuild exposed stale pre-control-record fixtures; -the compiled integration lane now asserts the 136-byte request header and supplies explicit paragraph IDs on text, -style, constraint, and inline-object records. Focused compiled-Wasm integration and all 125 Rust unit tests pass. -Optimized Wasm is 1,073,248 raw / 404,463 gzip / 321,189 Brotli bytes (-1,526 / +433 / -154 from the centralized -capacity checkpoint); the mixed compression movement supports no size or latency claim. -Adjacent -8-warmup/31-sample Bitmap column-resize medians are 4.083 ms before and 4.078 ms after the rebuilt module, with 5.8% and -6.1% RSD; this supports no speedup claim and exposes no material regression. Optimized Wasm is 1,070,685 / 402,154 / -319,914 raw/gzip/Brotli bytes, +105 / +40 / +252 from D-205. - -The canonical integration lane derives its natural width directly from the checked-in HarfRust glyph advances, then compares exact natural, 720 px, and 360 px measurements after source TTF → baker GLB → validator → registry → Wasm shaping. A second paragraph invalidates the shaper's borrowed arena before the first is measured, proving paragraph ownership rather than accidental view lifetime. Chromium repeats the same three measurements with deterministic hash `79874b9d`, one preparation shape, zero reflow calls, and no positioned glyph arrays. - -Item 5.2 implements final positioned `ParagraphLayout`. It caches line plans independently from full constraint results, materializes paragraph-owned typed arrays only when requested, scales the exact HarfRust advances/offsets through retained GLB metrics, and emits parallel glyph and line SoA arrays in top-left/positive-down coordinates. Boundary-sensitive line fragments are gathered into one `reshapeRanges` call per changed width with full shaping context and line BOT/EOT flags. The canonical fixture fixes every glyph ID, UTF-16 cluster, flag, line range, baseline, advance, x/y placement, and normalized byte hash for natural, wide, and narrow layouts. The live Chromium aggregate is 3,786 bytes with hashes `bb15bbcc:4f111a3f:e8c0e9d5`, one broad shape, and two reshape calls total. Registry-scoped handles are validated separately and deliberately excluded from the portable hash. - -Item 5.3 now has a conformant Unicode 17 bidi foundation. The package-owned shaper reuses `unicode-bidi` 0.3.18's maintained post–Unicode-15 UAX #9 algorithm under `no_std + alloc`, disables its Unicode 16 tables, and supplies generated Unicode 17 `Bidi_Class` and normalized paired-bracket data through the crate's custom data-source seam. The Rust-generated JSON ABI describes one direct-memory UTF-16 analysis call and borrowed SoA levels/classes/paragraph arrays; no browser ICU, WASI, binding generator, or ambient Unicode version participates. Hash-pinned official inputs cover `DerivedBidiClass.txt`, `BidiTest.txt`, and `BidiCharacterTest.txt`. Ordinary integration tests expand the generic corpus to all 770,241 requested paragraph-direction cases and execute all 91,707 character-specific cases, comparing paragraph level, every specified resolved level, and complete visual order. Wasm integration separately proves supplementary-plane code units and explicit/automatic paragraph directions. - -Item 5.3 completes paragraph-level bidi and line policy. Preparation resolves nested span properties through the shared per-property cascade, which folds covering spans from the outermost inward in one boundary sweep, then intersects the resulting style segments with UAX #24 script and precomputed UAX #9 runs in one interval pass rather than rescanning every cross-product. It shapes each run in its resolved direction, copies borrowed analysis/shaping data, applies line-specific L1 reset and L2 visual ordering, and batches only unsafe changed boundaries. Boundary validation occurs once while copying/normalizing public input; normalized shaping and layout loops do not repeat generic object checks. A pinned Amiri 1.002 fixture covers joining, combining marks, lam-alef forms, Arabic numbers, and Latin: HarfRust over the source font equals HarfRust over the reduced SFNT extracted from the validated GLB exactly, and pinned HarfBuzz 13 independently agrees on every glyph field. - -The generated `paragraph-bidi-layout-v0.json` contract owns complete SoA values for two mixed-direction Amiri layouts plus exact start/center/end/justify, clip, max-lines, and width/height ellipsis policies over Inter. Alignment-only and height-only compatible layouts share cached boundary shaping; every changed boundary is reported as one batched reshape. Ellipsizing a line ending in a mandatory break removes that control cluster before inserting the ellipsis, so the visible range never crosses into the hidden line. Fixed-seed fuzzing mutates Unicode text—including expected malformed UTF-16 rejection—axis modes, widths/heights, wrapping, alignment, truncation, letter spacing, line height, and direction twice, requiring finite, internally consistent, deterministic output. - -The current-uikit-shaped fixture lives in the benchmark application rather than core. It derives `CustomLayouting` intrinsics, maps Yoga Undefined/AtMost/Exactly modes, ignores the numeric `NaN` payload of undefined axes, preserves uikit's 1/100-point upward rounding, skips measurement for two definite axes, subtracts padding/border from the authoritative resolved box, and translates content-local positions into centered host coordinates. Twenty repeated measurements materialize no glyph arrays. Text and shaping-policy updates dirty layout; paint and raster updates do not. Chromium 149 fixes the twelve-layout aggregate hash at `8859ef19:8d5b98a3:e492fa7d:19a5a03e:32f8722c:0691e0de:e492fa7d:0132eed7:0ddc10b5:0ddc10b5:00f73fd9:c1a7730c`, with 8,098 output bytes, four broad shapes, and five reshape crossings; the GPU Vitexec lane repeats it with WebGPU active. - -Roadmap item 5.4 completes this same bake → retained-SFNT → HarfRust → paragraph path with Noto Sans CJK JP Regular 2.004 at the 65,535-glyph V0 limit. Thirteen Simplified/Traditional Chinese, Japanese, Korean, supplementary-Han, SVS/IVS, punctuation, ideographic-space, and mixed-script cases match source/reduced HarfRust and authenticated HarfBuzz 13 field-for-field. Contextual Script_Extensions avoid assigning shared punctuation arbitrarily; valid language tags survive the Wasm boundary, malformed tags fail explicitly, and natural-width overflow uses the same Float32 geometry as final layout. - -Four public-pipeline paragraphs produce twelve exact natural/wide/narrow contracts with grapheme- and UTF-16-safe runs, clusters, and lines, one broad shape per paragraph, and zero reshapes for the fixed corpus. Fixed-seed CJK mutations cover malformed surrogates, variation selectors, language tags, and constraints twice. Node, Chromium 149, and GPU-enabled Vitexec report one composite hash, 10,622 output bytes, 1,539,372 retained bytes, and 4,587,520 Wasm-memory bytes. The item adds no raster paging, rendering, fallback, or vertical layout. - -Roadmap item 6.1 completes the optional bitmap runtime module. It validates reciprocal font/raster identity, exact dense 20-byte records, absent-glyph sentinels, square strikes, embedded lossless linear R8 KTX2 dimensions and format, and page references before publishing a resource. Decode is transactional across pages and strikes, so any later failure disposes every `DataTexture` already created. Public `Text.rasterPixelRatio` defaults to one and is supplied explicitly by a rendering integration; it is raster-only state, so changing it rebuilds draw batches while reusing the paragraph and preserving logical CSS geometry. Bitmap targets the maximum run CSS size multiplied by that ratio, chooses the nearest strike with deterministic lower-strike ties, exposes the selected `strikePpem` on each zero-copy draw batch, uploads each page once, preserves glyph order through contiguous page runs, and emits instanced position, size, UV, and linear-color attributes. The core never reads browser DPR or installs input listeners. The baker records Zeno's integer mask placement in strike-pixel units; at native density every quad dimension therefore equals its atlas rectangle dimension. The shared TSL vertex graph snaps each projected edge to a physical framebuffer pixel before the same graph emits WGSL or fallback GLSL. The package-owned `@types/three` patch corrects `modelViewProjection` to its runtime `Node<'vec4'>` type and replaces the pathological node conditional tree; the compile fixture guards both upstream gaps. Independently fetched density strikes and external page residency remain explicitly deferred to Milestone 13. +Status: foundation cutover in progress; publishing-feature stacks follow after merge -Roadmap item 7.2 adds an optional presentation seam to this bitmap subpath without changing core `Text`, React, paragraph layout, or the generic raster contract. A snapshot copies rendered glyph identities and their currently displayed origins but retains no renderer resources. A transition matches font handle, glyph ID, UTF-16 cluster, exact font-size bits, and occurrence ordinal, then updates only the target batch's existing origin attributes. Unmatched shaping changes stay at their newly committed positions; sizes, UVs, paint, shaping, line breaking, and the authoritative `ParagraphLayout` remain discrete. Target-origin storage is lazy, per-frame progress updates are allocation-free, stale batches reject mutation, and the unchanged TSL vertex graph applies the final physical-pixel snap. Integration tests cover exact midpoint interpolation, mid-transition continuation, topology changes, invalid progress, idempotent finish, and disposal. +## Ownership -The canonical composed Inter fixture proves GLB → registry → public `Text` → HarfRust → paragraph layout → bitmap decode → GPU upload → instanced draw in the benchmark product. The five-lane benchmark ipsum produces 120 visible glyphs, zero missing glyphs, and one draw on both backends. Density fixtures carry 16 and 32 ppem strikes; exact-strike rendering keeps public geometry at 16 CSS px while selecting 16 device pixels at 1× and 32 device pixels at 2×. A record-level Rust invariant proves atlas and native plane dimensions are identical. The benchmark independently CPU-composes decoded atlas texels at snapped placements and requires every normalized GPU byte to match for both the full frame and a resized, intentionally clipped frame; WebGPU and WebGL2 produce the same full-frame hash at each DPR. Bitmap accepts fill and opacity but rejects outline and shadow through the optional raster paint-validation seam instead of silently discarding them. Hinted grayscale and four-phase coverage packing remain measured research, while LCD/ClearType rendering is an explicit non-goal. The [roadmap](../roadmap/roadmap.md) remains the only completion ledger. +The package owns five runtime layers: -The five-line, 120-glyph text above is the bounded conformance specimen. The separate live benchmark ipsum exceeds 1,000 characters and renders 1,150 glyphs through the same one-draw public `Text` path. +| Layer | Owner | Responsibility | +| --- | --- | --- | +| Font and raster loading | TypeScript core | Validate portable GLB assets, register shaping payloads, decode selected raster resources, and retain font identity. | +| Shaping and layout | Rust/Wasm | Unicode analysis, bidi, font fallback, shaping, line composition, positioning, ellipsis, and semantic query state. | +| Policy and render plan | Rust/Wasm | Interpret a validated renderer policy, pack canonical technique records, coalesce dirty ranges, and emit a compact command buffer. | +| Three.js integration | `@pmndrs/text/three` | Compile policy programs, resolve font/material resources, apply command-buffer deltas, upload dirty ranges, and maintain draw proxies. | +| React integration | `@pmndrs/text/r3f` | Reconcile React values into the same imperative `Text` and `TextGroup` objects. | -One engine session now retains an ordered set of stable-ID paragraph states and publishes one shared Rust render plan. -Paragraph lifecycle records create, reorder, or remove children transactionally; semantic records are consumed as -forward-only borrowed paragraph spans, and absent spans retain prior child state. The final child order feeds one -pre-sized allocation-free policy gather. Each child's retained geometry compacts only its referenced regions, -exclusions, and vertices instead of keeping unrelated global table prefixes. A compiled-Wasm coordinator fixture -creates two independent paragraphs, observes adjacent material groups `[7, 8]`, then sends only lifecycle records and -observes `[8, 7]` in the next publication. The publication is a retained renderer-neutral command-buffer delta: it -describes resources, physical buffers, dirty patches, primitives, draws, and retirement, but it is neither a native -`GPUCommandBuffer` nor a TypeScript object batch. Public Three GPU realization remains open. The optimized shaper is -1,082,551 raw / 407,787 gzip / 324,499 Brotli bytes at this checkpoint; all 128 Rust unit tests and the focused compiled- -Wasm fixture pass, with no end-to-end renderer latency claim yet. +Rust remains `no_std + alloc` with the package allocator contract. It uses the existing compile-time direct-memory mapping +for font registrations and the single `text_update(requestOffset, requestLength)` export for retained engine sessions. +TypeScript does not independently shape, lay out, or pack paragraphs. -The Three coordinator retains a reverse first-party resource registry keyed by the exact numeric `referenceId` emitted -in Rust resource records. Validated Bitmap pages, MTSDF atlas data, and Slug analytic pages enter it once when their font -binding is first registered. Command-buffer execution can therefore resolve the authenticated renderer resource with -one map lookup rather than searching fonts or re-partitioning glyphs. The registry rejects one numeric identity crossing -techniques. Buffer patches and draws are not yet realized by this checkpoint. +## Public package surfaces -The first Three command-buffer executor realizes Bitmap buffer declarations, minimal write/fill/copy patches, resource -bindings, glyph primitives, ordered draws, and buffer retirements from the Rust publication. Transform handling is a -program policy rather than a global split: a transform-keyed program receives a nonzero draw `transformId`, while the -first-party indexed programs pack one `u32 transformIndex` per instance in policy buffer 15 and publish draw-level zero. -Three owns the compact matrix sidecar and may update its dirty matrix ranges without calling Wasm or invalidating -layout. Flow regions carry the stable sidecar slot independently from local geometry; visible overflow carries no clip -identity. A compiled-Wasm fixture keeps distinct materials as two draws, then changes both retained paragraphs to one -material and observes one six-instance draw over transform slots `[2,2,2,1,1,1]`. MSDF uses the same executor: Rust -packs seven exact `vec4` streams plus transform indices, Three resolves the validated atlas directly and feeds those -streams to the canonical `msdfShader`, and a retained Bitmap-first → MSDF-first stack change produces one six-instance -program-2 draw without resending text or geometry. The executor is not connected to the public `Text` lifecycle, so -Slug uses the same executor with five float and two integer `vec4` streams plus transform indices. Its validated curve, -header, and packed-reference textures are retained once. The indexed matrix drives both final placement and the -canonical analytic dilation graph through an exact per-instance MVP, while the existing row-based shader interface -remains valid for transform-split targets. The compiled-Wasm fixture republishes the same six retained instances as one -program-3 draw without resending text or geometry. Browser shader compilation/pixels, retirement-bounded caches, -material factories, public cutover, and end-to-end latency remain open. The optimized shaper remains 1,083,255 raw / -411,409 gzip / 324,539 Brotli bytes; all 129 Rust tests and the focused compiled-Wasm/Three integration test pass. +| Subpath | Purpose | +| --- | --- | +| `@pmndrs/text` | Font/raster contracts, loading, fallback stacks, formatting helpers, paragraph inputs, layout-query values, and portable bakers. | +| `@pmndrs/text/three` | Three `FontLoader`, `Text`, `TextGroup`, material factories, profiling, and policy registration. | +| `@pmndrs/text/three/bitmap` | Bitmap technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/three/msdf` | MSDF technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/three/slug` | Slug technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/r3f` | React Three Fiber ``, ``, and `useFont`. | +| `@pmndrs/text/raster/*` | Renderer-neutral Bitmap, MSDF, and Slug decoding and raster-technique contracts. | +| `@pmndrs/text/bakers/*` | Optional portable raster bakers and validators. | -The executor also realizes transform-split programs without an indexed sidecar. A direct first-party policy includes -transform in its draw key, so Rust emits nonzero draw-level IDs and omits policy buffer 15. Three retains each resulting -mesh under the shared draw root, disables automatic local-matrix composition, and updates its relative matrix from the -corresponding scene object without a Wasm call. Bitmap and MSDF use the ordinary model transform; Slug supplies the -ordinary model-view-projection matrix to its dilation graph. The compiled-Wasm fixture proves direct IDs `[1,2]`, no -transform-index/table geometry attributes, exact initial matrices, and one changed retained matrix after a scene-only -transform update. Indexed and direct are policy program contracts; the target does not reinterpret or merge them. -One hybrid-policy publication further proves the modes compose: program-1 Bitmap carries draw transform zero and both -indexed attributes while program-2 MSDF carries draw transform two and neither attribute. Updating both corresponding -scene objects changes the shared table lane and direct retained mesh matrix in the same renderer synchronization. +`@pmndrs/text/typegpu`, the TypeScript paragraph engine, paragraph batches/attachments, direct shaping exports, and the +text-preparation Worker are removed. TypeGPU is a later adapter stack built against the Rust render plan; it is not a +compatibility wrapper over the removed batch model. -Three material definitions now have one public construction function and one runtime-scoped numeric identity registry. -The executor resolves Rust's `materialId` to a factory only when a compatible technique/program/resource material is -missing. The context carries the exact canonical shader output, the final renderer-local position after indexed -transforms, and a function that constructs the canonical default material. Factories must return fresh `NodeMaterial` -instances and Three owns their disposal. The compiled-Wasm fixture proves distinct material draws share physical glyph -storage, reorder and coalescing reuse cached materials, and the same selected factory is instantiated once for each of -Bitmap, MSDF, and Slug. Public `Text`, inherited `TextGroup`, and explicit span material properties now acquire those -numeric identities and flow through the same Rust publication; an unchanged frame retains both its draw and realized -material. Imperative Three and R3F expose only `material`; their obsolete `renderVariant` generic, properties, setters, -and span cascade are removed rather than remaining as no-op compatibility state. Fence-bounded retirement remains part -of the live backend gate. +## Retained frame transaction -The imperative Three binding no longer constructs a TypeScript `ParagraphBatch` or drives the attachment -`prepare`/`commit` state machine. One `TextGroup` owns one retained Rust session for all descendant text paragraphs; -standalone `Text` owns the same path with a private session. Rust receives paragraph/text/style/constraint mutations, -publishes one command-buffer delta, and compatible group children become one indexed draw under the group root. The -executor retains only renderer resources needed to apply later deltas. It does not receive paragraph layout arrays; -Three's old implicit `layout` and renderer-owned glyph snapshot surface is removed. Interaction and measurement APIs -query retained Rust layout state separately and on demand. Focused integration covers mixed-font spans, custom material -factories, two-child indexed batching, renderer-local transform updates, reparenting, and disposal. R3F, which constructs -the same imperative objects, is cut over as well. TypeGPU and the portable legacy core still own the remaining cutover -and deletion work. +One `TextGroup` owns one Rust engine session. A traversal sends only changed paragraph sections: -Third-party Three techniques use the same Rust planner instead of reviving the removed target transaction. Public -`registerThreeRasterPlanProgram` accepts a static policy descriptor, a cold compiler that lowers the package's validated -font data into one binding, and a material factory that receives exact policy buffers and transform authority. The -glyph-example package proves this contract without importing text internals or duplicating instance packing. Its -compiled-Wasm lifecycle retains mesh and geometry identity over a text mutation. Its live WebGPU and forced-WebGL2 -frames are deterministic and byte-identical across backends. Individual visibility does not split an indexed draw or -cross into Wasm: the renderer zeros only that text's matrix-sidecar slot; direct-policy draws mirror object visibility. +- text replacement sends text plus any dependent style/geometry state; +- font, spans, shaping style, paint, raster ratio, or material send style state; +- content-box changes send geometry; +- transform and visibility changes update Three's renderer-local sidecar without calling Wasm; +- an empty or normalized-equal update sends nothing. -Semantic measurement is now the first explicit query over that retained Rust state. `Text.measureLayout()` requests one -paragraph summary and its line records through the existing frame ABI only when the committed result is absent from its -cache. A normal rendering update requests no semantic records, and the command-buffer executor never reads the query -sidecar. A semantic edit clears the cache; repeated measurement of the same committed layout returns the same frozen -object without another crossing. Rust tests cover exact and at-most box resolution plus overflow, and the compiled-Wasm -Three fixture proves measurement retains the existing mesh while the removed `Text.layout` arrays remain absent. +Rust publishes one revision containing: -`Text.inspectLayout()` is a separate explicit mask for consumers that actually need lines and semantic glyphs. It copies -stable glyph IDs, font handles, glyph IDs, clusters, sizes, flags, and shaped origins—including non-rendering spaces—but -none of those arrays enter an ordinary render publication. Presentation motion is instead a directed policy -augmentation: the first-party programs write one stable `u32` glyph ID per renderable record in buffer 14, and Three -pairs that stream with the technique's existing origin buffer. `snapshotGlyphOrigins`, `setGlyphOrigins`, and -`clearGlyphOriginOverrides` operate only on renderer-local displayed values guarded by the exact inspection object. -Before any later Rust plan is applied, the executor restores its captured target values, allowing the authoritative -patch/retirement transaction to proceed without a parallel candidate/current target state machine. A shared two-text -fixture proves session-global IDs isolate overrides inside one indexed draw; a semantic resize publishes a new inspection -and clears the old presentation override. The complete optimized shaper at this checkpoint measures 1,089,889 raw, -414,204 gzip, and 325,805 Brotli bytes on the canonical Darwin arm64 host; deletion of the remaining legacy TypeScript -path and a deliberate Rust size pass remain required before release acceptance. +- engine and plan revision headers; +- physical-buffer allocation and retirement commands; +- coalesced per-buffer dirty byte ranges; +- resource bindings; +- ordered draw commands with technique/program, resource, material, transform, and clip identity; +- optional semantic measurement or inspection sections only when explicitly demanded. -The canonical Rust benchmark now derives its glyph workload from the published plan's glyph primitive record counts and -rejects an undersized run; `primitiveCount` is the number of primitive-table rows, not glyphs. With the unchanged -`--glyphs 22000` fixture, five warmups, and 11 samples, Rust produces 21,805 renderable records from the TypeScript -fixture's 25,515 positioned glyphs. Bitmap/MTSDF/Slug column-resize medians are 3.779/4.345/5.082 ms and p95 values are -4.156/4.951/5.679 ms. The same TypeScript width path measures 8.33 ms median. Rust therefore beats TypeScript, but no -technique yet passes the required sub-4 ms p95 gate; the measurement also stops before Three patch application and GPU -submission. Isolated warm-session Wasm high-water marks of 64.75/77.75/78.56 MiB likewise remain an optimization gate. +The Three executor does not infer paragraph layout from GPU records and does not maintain a parallel candidate/current +target state machine. It applies the Rust command buffer transactionally and retains only renderer resources required by +future deltas. -Cold result growth is now a negotiated frame-ABI path rather than a renderer failure. Three declares the engine's 64 MiB -output safety ceiling independently from the smaller retained A/B arenas. If a publication does not fit, Rust aborts the -prepared state and returns the exact required watermark; the host reserves once, re-resolves and recopies the request -after possible Wasm-memory detachment, and retries without advancing a revision. A compiled-Wasm fixture begins with only -the result header and publishes a nonempty plan. In the product benchmark, this changes MTSDF paragraph stress from status -7 at 1,382,592 required bytes to one live draw containing 11,510 glyphs. Three settled hardware-WebGPU A/B samples retain -the seven-`vec4` MTSDF policy: splitting origin and size did not improve CPU submission and trended from 0.748 to 0.767 ms -average median GPU time by adding an eighth storage binding. +## Renderer policy -Public font stacks no longer repeat a raster technique or require every fallback font to share one. Their generic type -is the union of the concrete loaded-font techniques, while runtime construction still requires one text-runtime domain, -unique font identities, and live font leases. Public Three `TextGroup` likewise has no authored technique: its retained -Rust session resolves each selected glyph's actual font binding and the policy partitions the plan by supported -technique, resource, program, material, and transform. A compiled-Wasm public lifecycle fixture uses one paragraph with -a Bitmap root stack and an explicit MSDF span, observes both canonical material contexts, and realizes two draws from -one Rust publication. The older renderer-neutral `ParagraphBatch` remains deliberately single-technique until it is -removed; its type and runtime boundary reject a heterogeneous stack rather than silently selecting the primary font's -technique. +Each Three technique registers a static policy descriptor and a cold font compiler. Rust validates and interprets the +compiled policy; it never invokes a JavaScript callback in shaping, layout, or packing. -The executor now bounds CPU/GPU realization residency from Rust retirement records. Retiring a physical buffer disposes -only materials that depend on its exact generation; retiring a plan resource disposes its technique texture only after -the final plan resource sharing that renderer reference leaves. Exact accounting in the compiled-Wasm fixture contains -only current policy storage, the transform sidecar, and the current Bitmap/MSDF/Slug resource after each technique -transition. Draw compatibility is range-independent: reorder retains exact meshes, geometries, and materials while -updating `recordIndex`, count, and render order; coalescing retains the one compatible draw and removes only the other. -Live WebGPU/WebGL2 submission still owns the final native-fence proof before public cutover. +The first-party policy can select indexed transform batching, direct per-draw transforms, or a hybrid. Indexed mode adds a +stable transform-table ID to each rendered glyph so compatible paragraphs may collapse into one draw. Direct mode splits +draws by transform for integrations that prefer ordinary object matrices. `TextGroup.compositing` determines whether +Rust must preserve authored ordering or may reorder independent work. -`TextGroup.compositing` now makes the batch's ordering contract explicit. The default `ordered` mode preserves authored -draw order. `independent` declares that compatible descendants may be reordered, allowing both Rust plan strategies to -coalesce interleaved technique/resource groups without moving that decision into Three. The value is carried as one -frame flag, is fixed when a group is constructed, and is exposed through the R3F wrapper. Rust tests cover interleaved -resources in ordered-direct and stable-indirect plans. The optimized shaper at this checkpoint is 1,101,079 raw, -417,984 gzip, and 328,164 Brotli bytes. +`materialId` is explicit through the frame ABI and render plan. Three maps it to a `defineTextMaterial()` factory. Material +identity may split draws without forcing a second copy of the canonical glyph buffers. -Three now sends only the semantic sections a property update can invalidate. Text replacement sends text, style, and -geometry; font/span/style/paint/raster/material changes send style; content-box changes send geometry; an empty update -does nothing. A demanded measurement or inspection mask rides on the same pending frame instead of issuing a second -`text_update`, and one returned semantic publication populates every paragraph in the session. Cached committed queries -remain crossing-free. The focused two-paragraph compiled-Wasm lifecycle proves empty-update no-op behavior, one-call -geometry and text mutation plus measurement, all-paragraph measurement retention, and exact five-instance command-buffer -output after a text replacement. Optional Three phase profiling is inactive by default and can emit User Timing spans -for frame preparation, Rust update, plan application, semantic readback, transform synchronization, and total time. +## Font fallback and techniques -Rust now owns ellipsis shaping and positioning rather than treating truncation as a TypeScript-only layout artifact. -Only a flow thread that leaves text unconsumed or whose complete no-wrap line exceeds its final slot enters this path. -The engine selects the ellipsis through the authored font stack, trims the final slot, and reshapes only the final -same-font source tail with context ending at the truncation boundary; ordinary reflow still performs zero boundary -reshapes. A retained boundary arena carries the replacement source and ellipsis glyphs directly into positioning, -preserves glyph identities across warm truncation updates, includes letter and word spacing, and commits or aborts with -the paragraph transaction. The public Three regression uses Amiri at a joining boundary where whole-run and narrowed -glyph IDs are provably different, then requires the Rust inspection output to match the narrowed result. The complete -package gate passes 204 tests and the Rust library passes 136 tests. Against detached commit `4adbbebc` on the same -machine, two 22,000-target-glyph Bitmap runs measured 4.041/4.056 ms median for baseline column resize and -4.102/4.189 ms for this checkpoint; cold medians were 15.490/15.569 ms and 15.212/15.358 ms respectively. The overlap, -run noise, and sub-0.15 ms differences support only a performance-adjacent claim. The complete checkpoint, including -the adjacent lazy-origin-index and Bitmap resource-selection fixes, changes optimized Wasm from -1,101,079 / 414,917 / 328,164 to 1,114,718 / 420,714 / 333,743 raw/gzip/Brotli bytes; that aggregate delta is not -attributed to ellipsis alone. +`createFontStack()` accepts fonts from one runtime in explicit fallback order. Members may use different techniques. The +font carries both shaping identity and raster binding, so `Text` has no redundant technique property. Rust resolves the +font for each cluster and partitions the render plan according to the active renderer's supported technique programs. -Dirty-range refinement begins with one stride-specific Rust coalescing primitive. It costs gaps, backend-call penalty, -fragmentation, and full-live promotion for one physical buffer and rejects zero-stride or overflowing arithmetic. -Focused tests prove that identical record ranges make different decisions for 16-byte and 64-byte streams. Ordered and -stable physical publishers now retain fixed per-buffer range scratch, derive liveness through exact semantic dependency -masks, align each stream independently, and pack only the selected physical buffer for its chosen spans. The stable -logical-order buffer uses the same cost model and copies committed gap bytes before publishing widened spans. Identical -range shapes regroup into one active-buffer packing job: the first ungrouped implementation regressed cold -Bitmap/MTSDF/Slug by roughly 1.2/2.2/2.4 ms through repeated program execution, while the corrected canonical run -measures 15.208/15.940/16.114 ms cold and 3.946/4.370/5.284 ms resize against detached `bbd87d3e` baseline ranges of -15.061–15.867 ms cold and 4.101–5.027 ms resize. These mixed results establish that the regression is closed, not a -speedup. The standard resize lanes still publish one unchanged-size patch; sparse-distribution and browser upload -evidence remain required. +This permits an MSDF or Bitmap prose font to fall back to a Slug emoji font while keeping third-party renderers safe: an +unregistered technique fails at the policy boundary instead of producing an unsupported draw. -Live Paragraph Stress profiling also found two renderer-integration defects independent of shaping invalidation. The -Three executor rebuilt a per-glyph origin lookup object graph after every plan application even though only presentation -queries use it; the index is now lazy and invalidated by a new plan. In the observed 11,510-glyph MTSDF run, -`plan.apply` moved from roughly 1.02 ms to 0.14 ms and the retained update from roughly 6.89 ms to 4.63 ms, but the sample -histories were not identical and this remains a scoped diagnostic rather than a universal speedup claim. Separately, -font-size or raster-density changes can select a different Bitmap strike and therefore a replacement physical batch. -Policy gather now retains every input lane for those selection changes so unchanged transform data initializes the new -batch; dependency-directed buffer writes remain selective when the resource does not change. A 16-to-32 ppem public -Three fixture proves the replacement transform stream is initialized before a following width-only reflow. +## Semantic queries -The replacement Rust engine now owns retained Unicode analysis for its frame transaction. The existing Unicode 17 generator emits both TypeScript and compact Rust Script/Script_Extensions partitions from one source. A no-std `unicode-segmentation` 1.13.3 iterator supplies extended grapheme boundaries; the engine maps them back to the public UTF-16 coordinate space, resolves contextual scripts in reusable flat arrays, and commits or aborts that derived arena with text and styles. Session reservation prewarms active and pending analysis storage, while unchanged text skips analysis. Retained UAX #9 products now form equal-level runs, and one interval sweep intersects them with resolved style and script items while skipping hard-break controls. Root direction remains paragraph-level state; nested stated directions carry a distinct override bit and force run parity. Primary-font HarfRust shaping consumes those runs inside `text_update` through borrowed retained language/features and writes glyph SoA directly into an A/B session arena; the legacy batch export shares the same prewarmed buffer and reusable feature scratch. A real-Inter compiled-Wasm test observes the shape-plan cache created by the frame call. The optimized shaper is 973,367 raw, 364,517 gzip, and 287,942 Brotli bytes at this checkpoint. Ordered fallback, layout, and nonempty plan output remain open, so this size evidence carries no complete-path frame latency claim. +Ordinary rendering requests no layout readback. `Text.measureLayout()` explicitly requests aggregate measurements and +counts; `Text.inspectLayout()` additionally copies line and glyph arrays. Query results are cached by committed revision. +If a query observes pending changes, it synchronizes the containing Rust session once and the following render traversal +reuses that publication. -Paragraph layout is tiered by what each product depends on, so a change enters at its own tier instead of rebuilding the paragraph. Text analysis follows the text and its base direction, shaping adds the fonts, spans, and style topology, metrics add font size and spacing, the line plan adds the content box, and geometry adds alignment. A retained layout session holds the prepared paragraph across updates, so a content-box change never enters preparation and reuses the caches the paragraph already keeps, while font fallback reads shaped glyph identity rather than laying the paragraph out to locate `.notdef`. Positioning writes its output into typed arrays sized from the shaped runs and resolves a text offset to a cluster through a table built once per preparation, replacing a lower-bound search that ran twice at every cluster boundary of every glyph. Both position axes accumulate in double precision and narrow once, because alignment and justification read an axis back after storing it. +The semantic values preserve information useful to callers: -`pnpm scripts run text:layout-benchmark` measures that path. It reports a median of warmed repetitions for each invalidation class separately, with the relative standard deviation beside it, because the classes invalidate different tiers and an average across them hides whichever one is slow. Boundary reshaping is gone: it requested the whole run as shaping context, which is the context the retained shape was produced with, so it returned the glyphs it already held on roughly every line of every layout. Measured on an identical workload against the pre-tiering commit at 25,515 glyphs, a reflow lays out in 8.12 ms against 110.40 ms, a resize in 11.98 ms against 103.54 ms, and a text edit in 31.85 ms against 109.66 ms, with the pinned layout hashes unchanged. Phase attribution came from opt-in spans that have since been removed once their evidence was recorded; reinstating them is a diagnostic change, not a shipped feature. +- resolved box dimensions remain distinct from intrinsic content extents; +- clipping does not discard off-viewport semantic layout; +- semantic truncation retains visible positioned lines while reporting intrinsic overflow; +- glyph/font identity, UTF-16 clusters, stable IDs, flags, line membership, and positioned origins remain available on + explicit inspection; +- presentation origin overrides never mutate authoritative Rust layout. -## Package scripts +## Wasm memory and copying -| Script | Purpose | -| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `build` | Check generated Unicode data, derive portable ABI contracts, emit ESM/declarations, compile the no-WASI Bitmap, MTSDF, Slug, and shaper Wasm modules, and optimize them with pinned Binaryen. | -| `check` | Run the complete test, main and Slug/TSL type-check, lint, and format gates. | -| `test` | Build once, then run compile-only public API fixtures, Rust/Wasm unit and integration gates, MTSDF evidence, Unicode 17 conformance, package/registration/golden tests, malformed artifacts, and deterministic fuzz smoke. | +The host pins request/result staging views and re-pins after any `memory.grow()`, because growth detaches existing views. +Growth is permitted only at the `text_update` boundary. Result capacity is negotiated and retried without publishing a +partial revision. -Run `pnpm scripts list text` from the workspace root to discover Unicode, fixture, bake-evidence, and explicit MTSDF diagnostic workflows. +WebGPU may alias compatible Wasm-backed typed arrays. Three's WebGL2 PBO path owns a padded array and therefore requires +one retained copy. The architecture does not add complexity to pretend WebGL2 can preserve a Wasm alias it replaces. -The [API contract](../planning/api-shapes.md) remains authoritative for public behavior; this concept explains the package that implements its current loading, baking, shaping, and paragraph surfaces. The [canonical roadmap](../roadmap/roadmap.md) alone owns program-wide completion status. +Asynchronous Worker execution is a follow-on host concern. Transfer buffers must return to the Worker when retired so +their final collection occurs in the owning realm. It does not restore the deleted TypeScript shaping Worker. -[^bitmap-identity]: Raster-specific descriptor fields remain owned by this subpath and never enter a closed core union. +## Current correctness evidence -[^bitmap-baker]: Artifact generation, validation, and generic composition are complete; GPU resource creation is deliberately deferred to the renderer milestone. +The foundation currently has: -[^node-host]: The Node host trusts selected installed baker code but authenticates every returned artifact; hostile baked assets are independently revalidated at the loader boundary. +- 139 passing Rust engine tests after the semantic query corrections; +- the package JavaScript/integration gate passing through the single-path public exports; +- exact retained Amiri bidi, policy, ellipsis, clipping, UIKit-layout, and CJK contracts exercised by the browser + `paragraph-contracts` target through public `FontLoader`, `Text`, `TextGroup`, `measureLayout()`, and `inspectLayout()`; +- source-font SHA-256, registered shaping hashes, and HarfRust/HarfBuzz oracle identities authenticated independently of + the browser behavior check; +- byte-identical Bitmap, MSDF, and Slug packing/consumer gates retained elsewhere in the benchmark suite. -[^loader]: Raster-package schema and payload semantics remain in each module's `decode`; the generic registry validates only package-neutral container and reciprocal identity invariants. +The browser paragraph target is not yet fully green: the UIKit fixture exposes one-ULP baseline differences caused by +layout-sensitive style values narrowing to `f32` before Rust performs `f64` accumulation. That issue must be resolved at +the ABI contract rather than hidden with fixture regeneration or benchmark-only tolerance. -[^slug-contract]: Slug V0 identity is fixed independently from the optional baker and renderer modules. +## Current size and performance evidence -[^slug-validator]: Standalone validation authenticates embedded and external resource forms before runtime ownership begins. +The latest checked package-size record before final cleanup reports: -[^slug-baker]: The Rust baker owns outline conversion, exact curve/band packing, and deterministic package construction. +| Graph | Raw | gzip | Brotli | +| --- | ---: | ---: | ---: | +| Core JavaScript plus shaper Wasm | 1,211,173 B | 440,875 B | 349,703 B | +| Three adapter plus core and shaper Wasm | 1,454,561 B | 479,863 B | 381,897 B | -[^slug-baker-host]: The TypeScript host owns direct-memory transfer, progress, errors, and cleanup without entering the renderer graph. +Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are +measured independently and then summed because browsers transfer them as separate assets. -[^slug-runtime]: The public runtime subpath owns resource upload, batching, paint admission, and lifetime. +A five-sample smoke run of the newly ported end-to-end Node layout benchmark realized 29,160 glyphs and measured 6.93 ms +for font-size updates and 6.48 ms for width updates, while cold creation measured 24.11 ms and text replacement 18.65 ms. +This run proves the benchmark now drives `Text`/`TextGroup`, Rust, render-plan packing, and Three application; its small +sample count is not release evidence. The `<4 ms` target and same-work comparison against the retained TypeScript baseline +remain open. -[^slug-shaders]: The internal shader directory preserves the copied analytic algorithm while adapting its storage and node graph to the package contract and installed Three.js release. +## Merge gates still open -[^slug-outline-research]: The planning concept preserves the rejected mechanism, measurements, external implementation survey, uncertainty, and go/no-go criteria. +Before the foundation stack is publishable: -[^typescript-go-node-variance]: The upstream issue identifies stable type ordering and variance computation over Three's augmented `Node` as the runaway checker path. +- resolve the layout-style precision contract and make the public paragraph browser target exact; +- finish the stale-code and stale-documentation audit; +- regenerate affected ABI, optimized Wasm, package-size records, and package digests from source; +- run package checks, strict Rust checks, benchmark conformance, packed consumers, WebGPU and forced-WebGL2 live rendering, + and the full repository gate; +- run the unchanged 25k-glyph comparison with enough samples and report cold, font-size, width, and text-update tables; +- profile and reduce any path that misses the target without weakening correctness; +- run a read-only Claude adversarial review if the CLI is available, then address supported findings; +- commit and push the coherent stack with a clean worktree. -[^definitelytyped-node-extras]: The upstream patch replaces the nested conditional with a keyed lookup map while retaining the same extension intersections. +The query/candidate-adoption API and the two publishing-feature stacks remain follow-on work after this foundation merge. +They must reuse retained Rust paragraph state and the same render-plan architecture rather than reintroducing a second +layout path. diff --git a/docs/planning/core-api.md b/docs/planning/core-api.md index 69ce3994..f316567e 100644 --- a/docs/planning/core-api.md +++ b/docs/planning/core-api.md @@ -1,74 +1,56 @@ --- type: API Specification title: Core text API -description: Canonical API and rationale for loading fonts, composing ordered same-technique font stacks, editing paragraphs, synchronizing shaping, and producing renderer-ready glyph batches. +description: Reference for renderer-neutral font loading, raster selection, mixed-technique fallback, paragraph inputs, and explicit layout-query values. documentation_type: reference -tags: [api, fonts, shaping, paragraphs, batching, rendering, async] +tags: [api, fonts, shaping, paragraphs, layout, rendering] status: stable sources: - id: decision-register resource: decision-register.md title: Accepted architectural decisions - - id: engine-contract - resource: engine-integration-contract.md - title: Engine integration contract - - id: raster-technique - resource: raster-technique-api.md - title: Raster technique and engine resource API - - id: extraction-plan - resource: engine-integration-boundary.md - title: Renderer-neutral extraction plan - - id: three-api - resource: three-api.md - title: Three.js text API - - id: typegpu-api - resource: typegpu-api.md - title: TypeGPU raster programs and text engine - - id: gpucat-integration - resource: gpucat-integration.md - title: External gpucat integration fitness plan - - id: current-api - resource: api-shapes.md - title: Existing API migration fixture - - id: current-shaper - resource: ../../packages/text/src/shaper.ts - title: Current synchronous shaper - - id: current-paragraph - resource: ../../packages/text/src/paragraph.ts - title: Current paragraph implementation - - id: current-raster - resource: ../../packages/text/src/raster.ts - title: Current raster transaction contract + - id: rust-engine + resource: rust-layout-engine.md + title: Rust text engine and render-plan ABI + - id: current-runtime + resource: ../../packages/text/src/text-runtime.ts + title: Current text runtime + - id: current-font-selection + resource: ../../packages/text/src/loaded-font.ts + title: Loaded-font ownership and fallback + - id: current-properties + resource: ../../packages/text/src/text-properties.ts + title: Current paragraph properties + - id: current-layout-query + resource: ../../packages/text/src/layout.ts + title: Current layout-query values + - id: current-three-api + resource: ../../packages/text/src/three.ts + title: Current Three.js exports generated: by: openai-codex/gpt-5.6 - at: '2026-08-07T04:31:24Z' + at: '2026-08-09T18:30:00Z' --- # Core text API -This is the canonical public API and the authority for implementation. +`@pmndrs/text` owns portable font loading, raster-technique selection, font fallback, paragraph input types, and layout +query result types. Rust owns shaping, bidi, line composition, positioning, instance packing, and the renderer-directed +command buffer. A renderer integration owns synchronization and GPU realization. -```ts -fontFile - -> bakeFont() // optional build-time work - -> runtime.loadFont() // explicit asynchronous loading - -> createFontStack() // optional ordered missing-glyph resolution - -> runtime.createParagraphBatch()// one intentional render phase - -> paragraph.text = next // cheap desired-state mutation - -> runtime.update() // synchronous synchronization point - // or runtime.updateAsync() // asynchronous synchronization point - -> PreparedGlyphBatch[] // core-partitioned GPU instance data - -> PreparedGlyphRun[] // ordered text runs with resolved render intent - -> engine draw compiler // compatible pipelines, effects, and final draws -``` +Applications using Three.js normally import scene objects from `@pmndrs/text/three` or React components from +`@pmndrs/text/r3f`; they do not drive the Rust engine directly. -## The complete API +## Runtime and font loading ```ts +interface TextRuntimeOptions { + readonly registry?: FontRegistry; + readonly wasm?: BufferSource | WebAssembly.Module; +} + interface TextRuntime { - readonly current: TextRuntimeRevision; - readonly hasPendingChanges: boolean; - readonly isPreparing: boolean; + readonly registry: FontRegistry; readonly disposed: boolean; loadFont( @@ -76,111 +58,34 @@ interface TextRuntime { options?: { readonly signal?: AbortSignal }, ): Promise>; - createParagraphBatch( - options: ParagraphBatchOptions, - ): ParagraphBatch; - - update(): TextRuntimeRevision; - - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - - subscribe(listener: (revision: TextRuntimeRevision) => void): () => void; dispose(): void; } -interface TextRuntimeOptions { - readonly registry?: FontRegistry; - readonly shaper?: RuntimeShaper; - readonly async?: Readonly<{ - readonly worker?: TextPreparationWorker; - readonly createWorker?: () => TextPreparationWorker; - }>; -} - -interface LoadedFontRequest { - readonly input: - | { readonly baked: string | URL } - | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; - readonly raster: { - readonly technique: Technique; - readonly options?: RasterOptionsOf; - }; -} - -interface RuntimeFontBakeRequest { - readonly source: Uint8Array; - readonly sourceUrl: string; - readonly bakedUrl?: string; - readonly signal?: AbortSignal; -} - -type RuntimeFontBake = (request: RuntimeFontBakeRequest) => Promise; - -interface TextPreparationWorker { - postMessage(message: unknown, transfer?: readonly Transferable[]): void; - addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - terminate(): void; -} - declare function createTextRuntime(options?: TextRuntimeOptions): Promise; ``` -Runtime options provision capabilities. They do not choose whether every update is synchronous or asynchronous. That -choice belongs to each `update()` or `updateAsync()` call. `createTextRuntime()` takes exclusive lifecycle ownership of an -injected registry, shaper, worker, or worker produced by `createWorker`; callers must not share those objects with another -runtime or dispose them independently. - -`AnyRasterTechnique`, `RasterDataOf`, `RasterBindingOf`, and `GlyphBatchStorageOf` come from the portable -[raster technique API](raster-technique-api.md). A technique owns artifact decoding, physical glyph-resource selection, and -canonical CPU instance packing without importing a rendering engine. - -## Bake and load explicitly +The default runtime instantiates the packaged `text_shaper.wasm`. Supplying `wasm` is intended for controlled builds, +tests, and compile-time SIMD variants. A runtime owns its Rust registration domain and all fonts loaded through it. ```ts -import { rasterBake } from '@pmndrs/text'; -import { bakeFont } from '@pmndrs/text/bake'; -import mtsdfBaker from '@pmndrs/text/raster/mtsdf/baker'; +type LoadedFontInput = + | { readonly baked: string | URL } + | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; -await bakeFont({ - input: new URL('./Inter-Regular.ttf', import.meta.url), - output: new URL('./Inter.font.glb', import.meta.url), - font: { fontFaceIndex: 0 }, - rasters: [ - rasterBake(mtsdfBaker, { - packaging: { artifact: 'embedded', pages: 'embedded' }, - options: undefined, - }), - ], -}); +interface LoadedFontRequest { + readonly input: LoadedFontInput; + readonly raster: { + readonly technique: Technique; + readonly options: RasterOptionsOf; + }; +} ``` -`@pmndrs/text/raster/mtsdf` is the intentional target-v1 name. The merged v0 package still exports the historical -`@pmndrs/text/raster/msdf` spelling even though its artifact is MTSDF; migration removes that alias when the v1 surface -lands. +`baked` loads a portable GLB artifact. `source` requires an explicit runtime baker and never silently adds a baker to the +consumer graph. The request selects Bitmap, MSDF, Slug, or a third-party raster technique independently for each loaded +font. -Baking produces font metrics, glyph records, and technique resources before the application runs. Runtime fallback may -perform the same bake in a Worker, but loading remains explicit in either case. - -```ts -import { createFontStack, createTextRuntime, span, txt } from '@pmndrs/text'; -import { mtsdf } from '@pmndrs/text/raster/mtsdf'; - -const runtime = await createTextRuntime({ - async: { - createWorker: () => new Worker(new URL('./text-worker.js', import.meta.url)), - }, -}); - -const inter = await runtime.loadFont({ - input: { baked: '/fonts/Inter.font.glb' }, - raster: { technique: mtsdf }, -}); -``` +## Loaded fonts and fallback ```ts interface LoadedFont { @@ -192,242 +97,28 @@ interface LoadedFont { readonly disposed: boolean; dispose(): void; } -``` - -`loadFont()` completes after shaping data and the selected technique data are decoded into renderer-neutral CPU state. It -does not create textures, buffers, pipelines, materials, meshes, entities, or scene objects. - -## Compose one logical font with fallback - -A `FontStack` is one immutable logical font choice. Its first concrete font is primary; later fonts resolve missing glyphs -in order. A single loaded font already satisfies the same text-facing contract and needs no wrapper. - -```ts -const noto = await runtime.loadFont(notoMtsdfRequest); -const amiri = await runtime.loadFont(amiriMtsdfRequest); -const iconMtsdf = await runtime.loadFont(iconMtsdfRequest); - -const uiFont = createFontStack(inter, noto, amiri); -const iconFont = iconMtsdf; -``` - -```ts -type FontSelection = LoadedFont | FontStack; interface FontStack { - readonly technique: Technique; readonly fonts: readonly [LoadedFont, ...LoadedFont[]]; } -declare function createFontStack( - primary: LoadedFont, - ...fallback: readonly LoadedFont>[] -): FontStack; -``` - -Every concrete font must use the same technique. TypeScript rejects a mixed stack through `NoInfer`; runtime validation -provides the same guarantee to JavaScript and untrusted boundaries. The immutable stack owns no font lifecycle. Adding a -paragraph acquires a lease on every concrete font in its selection until that paragraph or its owning batch is disposed. -`LoadedFont.dispose()` fails while any live paragraph lease remains, so disposal can never silently turn fallback into a -missing glyph. A stack containing a successfully disposed member is rejected when used to create or update a paragraph. - -```ts -createFontStack(interMtsdf, iconBitmap); // compile-time error and runtime rejection -``` - -A renderer that combines Bitmap and Slug data is a new technique with its own artifacts, instance schema, resource -bindings, and shader. It is not a font stack that mixes the existing Bitmap and Slug techniques. - -## Create an intentional paragraph batch - -A paragraph batch contains paragraphs that the application permits core to order and submit as one render phase. - -```ts -const worldText = runtime.createParagraphBatch({ - technique: mtsdf, -}); -``` - -```ts -interface ParagraphBatchOptions { - readonly technique: Technique; - readonly capacity?: GlyphBufferCapacity; - readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; -} - -interface GlyphBufferCapacity { - readonly size: number; - readonly policy: 'grow' | 'chunk' | 'fixed'; -} - -interface ParagraphBatch { - readonly runtime: TextRuntime; - readonly technique: Technique; - readonly capacity: GlyphBufferCapacity; - readonly current: PreparedParagraphBatchRevision; - readonly paragraphCount: number; - readonly hasPendingChanges: boolean; - readonly preparationError: TextPreparationError | undefined; - readonly disposed: boolean; - - rasterPixelRatio: number; - renderVariant: Variant | undefined; - - add(properties: ParagraphProperties): Paragraph; - setCapacity(capacity: GlyphBufferCapacity): void; - has(paragraph: Paragraph): boolean; - subscribe(observer: ParagraphBatchObserver): () => void; - attach( - target: ParagraphBatchTarget, - ): ParagraphBatchAttachment; - dispose(): void; -} - -interface ParagraphBatchObserver { - next(revision: PreparedParagraphBatchRevision): void; - complete(): void; -} +declare function createFontStack[]>( + primary: LoadedFont, + ...fallback: Fallback +): FontStack>; ``` -`subscribe()` synchronously replays `current`, then reports each later published batch revision exactly once. Disposing the -batch calls `complete()` exactly once; unsubscribing is idempotent and prevents later `next()` or `complete()` calls. This -public observation contract is sufficient to build renderer coordination without access to shaping, allocation, or other -batch internals. - -`attach()` is the retained convenience for that coordination. It validates technique compatibility, records published -source revisions, exposes explicit renderer-owned `prepare()` and `commit()` boundaries, and couples attachment disposal -to batch disposal. It is policy built on the -same public revisions and lifecycle events, not a second shaping or batching API. The exact target contract is specified in -the [engine integration contract](engine-integration-contract.md). +Fallback order is explicit. Every member must belong to the same runtime, but members may use different raster +techniques. This permits, for example, an MSDF prose font followed by a Slug color-emoji font. The active renderer must +have a policy program and material implementation for every selected technique; the maintained Three integration ships +Bitmap, MSDF, and Slug support. -### Use the default or preallocate explicitly +A loaded font retains its registered font, raster resource, and runtime until disposed. Live `Text` objects lease their +fonts; disposing a leased font throws `FontLeaseError` instead of invalidating retained Rust state. -Omitting `capacity` uses `{ size: 4_096, policy: 'chunk' }`. Core allocates storage lazily when the first glyph resolves to -a physical font resource. Paragraph handles and paragraph metadata grow normally; only glyph-instance storage has a -capacity policy. +## Paragraph input ```ts -const denseText = runtime.createParagraphBatch({ - technique: mtsdf, - capacity: { size: 20_000, policy: 'chunk' }, -}); -``` - -`size` applies independently to every physical technique/resource buffer produced beneath the logical paragraph batch. It -is not a total glyph limit for the paragraph batch. Under `chunk`, core preserves existing storage and allocates another -`size`-slot buffer when one fills. Under `grow`, core transactionally replaces a full buffer and doubles its capacity until -the pending glyphs fit. Under `fixed`, exceeding `size` fails preparation and preserves the last published revision. -Ordered glyph runs make cross-buffer paragraph and fallback-font order explicit. - -`ParagraphBatch.add()` cannot reject a capacity overflow because fallback, shaping, wrapping, and later mutations determine -the physical per-resource glyph demand. `update()` or `updateAsync()` discovers overflow after shaping but before -publication. Fixed overflow returns a typed `capacity-exceeded` preparation failure with the batch, configured limit, the -maximum per-resource requirement, and every overflowing physical resource. One resize to `error.required` -therefore satisfies the complete shaped generation rather than revealing overflows one at a time. The complete prior -runtime revision remains current; on a first update no partial revision becomes visible. Desired state remains available -for correction or an explicit capacity change. - -The first failing synchronization throws or rejects and records the error on `batch.preparationError`. That exact failed -desired generation is then latched rather than remaining eligible work: `batch.hasPendingChanges` is false when its only -unpublished state is the unchanged failure, and later runtime updates may publish other dirty batches. Any relevant -paragraph or membership mutation clears the latch and schedules a new attempt. Successful publication clears -`preparationError`. Calling `setCapacity()` with a different normalized capacity also clears the latch and schedules one -new attempt while the last committed revision remains live. - -`runtime.hasPendingChanges` and `batch.hasPendingChanges` report unpublished desired work that the next synchronization may -attempt. A latched unchanged failure reports false; its retained `preparationError` is the observable state. `isPreparing` -is true only while an asynchronous candidate is actively shaping or awaiting its Worker result. It becomes false on -publication, failure, abort, or supersession and is independent of a latched error. - -Resize a batch when an application wants to replace a fixed allocation explicitly: - -```ts -worldText.setCapacity({ size: 40_000, policy: 'fixed' }); -runtime.update(); - -worldText.has(label); // true: batch and paragraph identity did not change -``` - -`setCapacity()` validates and records the normalized requested capacity synchronously but does not mutate published -canonical storage. The next `update()` or `updateAsync()` reuses compatible shaping and layout results, stages replacement -storage, and atomically publishes it only when complete. Failure preserves the previous revision and every handle. -Existing attachments record the new source revision. Each target stages replacement engine buffers when its owner next -calls `prepare()`, commits at its safe frame boundary, and retires old buffers after its fences. The `ParagraphBatch`, its `Paragraph` handles, -subscriptions, attachments, desired state, order, glyph overrides, and font leases never change identity. - -Changing from `fixed` to `grow` or `chunk`, growing a fixed size, and deliberately shrinking are all explicit capacity -changes. A shrink that cannot hold the desired generation reports `capacity-exceeded` at synchronization and retains the -previous complete revision. Passing the current normalized capacity is a no-op and does not retry a latched failure. - -Every non-no-op capacity change creates a new physical-allocation generation at synchronization. Core repacks all live -slots, retires every old `GlyphBatchKey`, increments `GlyphBatchKey.generation`, and interns fresh keys. This -gives targets one unambiguous replacement signal. `chunk` numbers are reassigned densely from zero per resource in the new -generation; semantic paragraph, batch, subscription, and attachment identities remain unchanged. - -Create another paragraph batch when text must be rendered in another phase, even if it uses the same technique. - -```ts -const overlayText = runtime.createParagraphBatch({ - technique: mtsdf, -}); -``` - -Core never merges `worldText` and `overlayText`. The application may place non-text draws between them or give them -different depth, stencil, clipping, compositing, lifetime, or render-pass policies. - -`renderVariant` is the batch's optional inherited render intent. Core treats it as an opaque, exactly typed value: it does -not know whether the value represents an effect graph, material binding, palette entry, clipping mode, or application -state. Paragraph and span values may override it. `undefined` means inherit; an integration that needs an explicit “no -effect” choice defines that as an ordinary member of its own variant type. A variant never changes the declared raster -technique and does not by itself require another physical glyph buffer, pipeline, or draw. - -Core retains an opaque variant value and compares replacements with `Object.is`; it cannot clone or inspect integration -objects. Treat ordinary variant records as immutable snapshots and assign a replacement to change run identity. A stable -binding object may expose integration-owned mutable parameters, but changing those parameters does not mark core dirty; -the owning program must update its sidecar/uniform storage directly. Disposing a binding still referenced by a live batch, -paragraph, or span is an integration lifecycle error. - -## Everything added is a paragraph - -A paragraph is one independently shaped and laid-out sequence. A multiline block, a one-line label, and a font-backed -icon use the same API. - -```ts -const body = worldText.add({ - font: uiFont, - text: 'A paragraph resolves missing glyphs through its FontStack.', - contentBox: { - width: { mode: 'at-most', size: 480 }, - wrap: 'word', - }, -}); - -const label = worldText.add({ - font: inter, - text: 'Player 1', -}); - -const icon = worldText.add({ - font: iconFont, - text: '\uf013', -}); -``` - -Every paragraph owns a concrete `Font` or `FontStack`. A Bitmap font cannot appear in an MTSDF paragraph batch. Supporting -both resource types in one paragraph requires a technique expressly designed to render both. - -```ts -interface ParagraphBaseProperties { - readonly font: FontSelection; - readonly contentBox?: ParagraphContentBox; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly rasterPixelRatio?: number; - readonly order?: number; - readonly renderVariant?: Variant; -} - type ParagraphAxisConstraint = | { readonly mode: 'unconstrained' } | { readonly mode: 'at-most'; readonly size: number } @@ -442,711 +133,103 @@ interface ParagraphContentBox { readonly overflow?: 'visible' | 'clip' | 'ellipsis'; } -type LinearRgba = readonly [number, number, number, number]; -type ColorInput = string | LinearRgba; - -interface GlyphPaintInput { - readonly color?: ColorInput; - readonly opacity?: number; - readonly outline?: { readonly color: ColorInput; readonly width: number }; - readonly shadow?: { readonly color: ColorInput; readonly offset: readonly [number, number] }; -} - -type ParagraphContentProperties = - | Readonly<{ - text: string; - spans?: readonly ParagraphSpan[]; - }> - | Readonly<{ - text: FormattedText; - spans?: never; - }>; - -type ParagraphProperties = ParagraphBaseProperties< - Technique, - Variant -> & - ParagraphContentProperties; - -interface ParagraphSpan { - readonly start: number; - readonly end: number; - readonly font?: FontSelection; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; -} - -type FormattedText = TextLiteral | TextLiteral; - -type TextInput = string | FormattedText; - -declare const textLiteralTechnique: unique symbol; - -interface TextLiteral { - readonly [textLiteralTechnique]: (technique: Technique) => Technique; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; -} - -declare const textSpanFragmentTechnique: unique symbol; - -interface TextSpanFragment { - readonly [textSpanFragmentTechnique]: (technique: Technique) => Technique; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; - readonly properties: Omit, 'start' | 'end'>; -} - -type TextTemplateValue = - | string - | number - | TextLiteral - | TextLiteral - | TextSpanFragment - | TextSpanFragment; - -type SpanStyle = Readonly; - -type SpanFormat = FontSelection | SpanStyle; - -interface SpanTag { - (strings: TemplateStringsArray, ...values: readonly TextTemplateValue[]): TextSpanFragment; +interface ParagraphStyle { + readonly fontSize?: number; + readonly lineHeight?: number; + readonly letterSpacing?: number; + readonly language?: string; + readonly direction?: 'auto' | 'ltr' | 'rtl'; + readonly features?: readonly FontFeature[]; } - -interface UnboundSpanTag { - ( - strings: TemplateStringsArray, - ...values: readonly TextTemplateValue[] - ): TextSpanFragment; -} - -declare function txt( - strings: TemplateStringsArray, - ...values: readonly TextTemplateValue[] -): TextLiteral; - -declare function span(...styles: readonly [SpanStyle, ...SpanStyle[]]): UnboundSpanTag; - -declare function span( - font: FontSelection, - ...formats: readonly SpanFormat>[] -): SpanTag; -``` - -The paragraph font and every explicit span font must match the paragraph batch technique. A span without `font` inherits -the paragraph selection. A `FontStack` resolves missing glyphs in its own stored order; batch membership never changes a -paragraph's shaping semantics. - -The renderer-neutral `txt` and `span` tags compose the same string-plus-range representation without parsing an embedded -markup language. `span()` accepts a `SpanStyle` by itself, or a concrete `Font` / `FontStack` followed by any number of -same-technique font selections and styles. A style-only tag inherits the surrounding paragraph or span font. A `SpanStyle` -flattens paragraph style and glyph paint for concise authoring; the helper normalizes it back into the canonical nested -`ParagraphSpan.style` and `ParagraphSpan.paint` snapshot. - -A fragment or literal containing no font-bearing value carries `never` as its technique marker and is explicitly accepted -by `TextTemplateValue` and `FormattedText`. The first font-bearing fragment fixes the literal -technique; until then the composition remains neutral and inherits its eventual paragraph font. - -Formats merge from left to right. When a font is supplied it is the first argument, allowing that selection to fix the -technique; a later same-technique font replaces it. Later style fields replace earlier fields. Nested values such as -`features`, `outline`, and `shadow` replace as complete values; the helper does not deep-merge them. `NoInfer` makes -TypeScript reject mixed-technique later fonts in addition to unknown properties and invalid value types. Core snapshots -the formats when `span()` is called, then computes UTF-16 ranges and offsets for nested fragments. - -```ts -const importantStyle = { - color: '#ffddff', - fontSize: 18, -} satisfies SpanStyle; - -const important = span(amiri, importantStyle); - -const title = txt`Fast ${important`accurate`} text`; - -label.text = title; -label.text = 'Plain text'; // replaces the source and clears spans ``` -The returned `SpanTag` is reusable. When format inputs must remain independently composable, keep them in a readonly tuple -and bind them later: +`ParagraphContentBox` is layout-system-neutral. Omitted axes are unconstrained. `exact` fixes the resolved box dimension; +`at-most` clamps it. `contentWidth` and `contentHeight` in query results still report the intrinsic laid-out requirement. -```ts -const importantFormat = [amiri, importantStyle] as const; -const importantAmiri = span(...importantFormat); -``` +Text may be a string plus explicit spans, or a `FormattedText` value created with `txt` and `span`. Styles cascade at +extended-grapheme boundaries. Paint and `material` are rendering values; they do not alter shaping or line composition. -Assigning a `TextLiteral` replaces text and spans atomically. Passing a formatted literal together with separate `spans` -is a type error. Manual `{ text: string, spans }`, `setSpan()`, and `removeSpan()` remain available when an integration -already owns explicit UTF-16 ranges. +The foundation stack currently implements horizontal text, font size, line height, letter spacing, language, direction, +OpenType features, wrapping, alignment, clipping policy, line limits, and ellipsis. The publishing-feature stages in the +[Rust engine plan](rust-layout-engine.md) own vertical writing, decorations, editorial regions/exclusions, and the +remaining admitted typography features; this reference does not claim those future inputs as shipped. -`SpanStyle` deliberately contains portable layout and paint only. Set an opaque `renderVariant` through explicit -`ParagraphSpan` values or `setSpan()`; an integration such as React Three Fiber may normalize its own nested variant props -into those spans. The renderer-neutral `txt` tag never captures an engine object accidentally. +## Layout query values -## Mutate handles; synchronize later - -`add()` returns the retained interface for that paragraph. Setters change desired state and mark the paragraph dirty; they -do not shape immediately. +Rendering does not carry layout arrays in every command buffer. An integration may expose explicit, demand-driven Rust +queries using these public result types. ```ts -interface Paragraph { - readonly id: ParagraphId; - readonly batch: ParagraphBatch; - readonly disposed: boolean; - readonly committed: PreparedParagraph | undefined; - - font: FontSelection; - get text(): string; - set text(value: TextInput); - spans: readonly ParagraphSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - order: number; - renderVariant: Variant | undefined; - - set(properties: ParagraphUpdate): void; - setSpan(index: number, span: ParagraphSpan): void; - removeSpan(index: number): void; - - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - snapshotProperties(): ParagraphSnapshot; - - dispose(): void; +interface ParagraphMeasurement { + readonly width: number; + readonly height: number; + readonly contentWidth: number; + readonly contentHeight: number; + readonly firstBaseline: number; + readonly lastBaseline: number; + readonly overflowed: boolean; } -declare const paragraphIdBrand: unique symbol; -type ParagraphId = number & { readonly [paragraphIdBrand]: true }; - -interface ParagraphSnapshot { - readonly font: FontSelection; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; - readonly contentBox: ParagraphContentBox; - readonly style: ParagraphStyle; - readonly paint: GlyphPaintInput; - readonly rasterPixelRatio: number; - readonly order: number; - readonly renderVariant: Variant | undefined; +interface ParagraphLayoutSummary extends ParagraphMeasurement { + readonly glyphCount: number; + readonly lineCount: number; + readonly missingGlyphCount: number; } - -type ParagraphUpdate = - | (Partial> & - Readonly<{ - text?: string; - spans?: readonly ParagraphSpan[]; - }>) - | (Partial> & - Readonly<{ - text: FormattedText; - spans?: never; - }>); ``` -```ts -label.text = 'First value'; -label.text = 'Second value'; -label.text = 'Player 2'; -label.contentBox = { width: { mode: 'exact', size: 320 } }; -``` +`width` and `height` are the resolved paragraph box. `contentWidth` and `contentHeight` are the intrinsic extents required +by the complete paragraph before box clamping. Viewport clipping does not destroy layout outside the viewport. `maxLines` +and ellipsis are semantic truncation: positioned output contains the retained visible result, while intrinsic extents and +`overflowed` continue to report that additional content existed. -Those mutations create one dirty paragraph. The next synchronization shapes only `Player 2` with the final content box. - -Nested configuration values are immutable snapshots. Replace `paragraph.contentBox` or call `paragraph.set()`; mutating -`paragraph.contentBox.width.size` is not observable and is unsupported. - -Creation and disposal are staged in the same way: - -```ts -const pending = worldText.add({ font: inter, text: 'Not shaped yet' }); -pending.dispose(); - -runtime.update(); // coalesces the add and removal to no work -``` - -## Paragraph handles never move between batches - -A `Paragraph` belongs permanently to the `ParagraphBatch` that created it. Core has no detach, reparent, or handle-transfer -operation. Snapshot desired properties, create a destination handle, then dispose the source handle: - -```ts -const desired = label.snapshotProperties(); -const movedLabel = overlayText.add(desired); -label.dispose(); - -runtime.update(); // destination addition and source removal publish atomically -``` - -`snapshotProperties()` returns immutable normalized desired state, not membership, prepared glyph storage, target -attachments, or ownership. The snapshot itself acquires no font lease. The destination must belong to the same runtime, -must use the same technique, and must receive still-live fonts. Glyph-origin snapshots remain topology-bound and are -reapplied separately after the destination has a compatible shaped topology. - -Disposing a paragraph releases only that handle, its dirty work, cached paragraph state, and font leases. It marks -`paragraph.disposed`, removes it from `batch.has()`, and makes every method except idempotent `dispose()` fail. - -Disposing a paragraph batch is terminal and cascades only through objects it owns: - -```ts -const desired = label.snapshotProperties(); - -worldText.dispose(); - -worldText.disposed; // true -label.disposed; // true: the batch owned this core handle - -const replacement = overlayText.add(desired); // a new handle; never the old label -``` - -`ParagraphBatch.dispose()` cancels its pending work, disposes every owned paragraph handle, releases their font leases, -removes the batch from future runtime revisions, and retires its canonical storage and target attachments. It does not -dispose the runtime or loaded fonts. `add()` and subscriptions on a disposed batch fail; `dispose()` remains idempotent. -Any snapshots required for recreation must be taken before disposal. - -## Synchronize now - -```ts -label.text = 'Ready for this frame'; -body.contentBox = { width: { mode: 'at-most', size: 360 }, wrap: 'word' }; - -const revision = runtime.update(); -``` - -```ts -interface TextRuntimeRevision { - readonly revision: number; - readonly paragraphBatches: readonly PreparedParagraphBatchRevision[]; -} -``` - -`update()` snapshots every currently dirty paragraph across every paragraph batch in the runtime, performs the required -shaping and layout synchronously, updates prepared glyph batches, publishes one atomic runtime revision, and returns it. -When nothing is dirty it returns `runtime.current` without allocating or notifying subscribers. - -Runtime and paragraph-batch revision numbers advance only when a new complete revision publishes. A clean call, a failed -preparation, an aborted request, and a superseded asynchronous candidate do not consume a published revision number. - -Public `add()` and mutation methods reject invalid values, disposed handles, and technique incompatibility immediately. -All data required by synchronous shaping must also have been loaded already. Missing preparation data, fixed-capacity -overflow, or another preparation failure throws from `update()` before publication and leaves the prior revision current. - -## Synchronize asynchronously - -The same runtime can choose Worker preparation for any update. - -```ts -label.text = 'Prepare this away from the caller'; -const outcome = await runtime.updateAsync(); - -if (outcome.status === 'published') { - useRevision(outcome.value); -} -``` - -Promise-free callback form: - -```ts -label.text = 'Avoid a Promise for this hot path'; - -runtime.updateAsync({ signal: controller.signal }, (result) => { - if (!result.ok) { - handleUpdateError(result.error); - return; - } - - if (result.value.status === 'published') { - publish(result.value.value); - } -}); -``` - -```ts -interface AsyncTextUpdateOptions { - readonly signal?: AbortSignal; - readonly priority?: 'background' | 'normal' | 'urgent'; - readonly onProgress?: (progress: TextUpdateProgress) => void; -} - -interface TextUpdateProgress { - readonly revision: number; - readonly preparedParagraphs: number; - readonly totalParagraphs: number; - readonly stagedGlyphs: number; -} - -type TextUpdateCallback = (result: TextUpdateResult) => void; - -type TextUpdateResult = - | { readonly ok: true; readonly value: TextUpdateOutcome } - | { readonly ok: false; readonly error: TextPreparationError }; - -type TextUpdateOutcome = - | { readonly status: 'published'; readonly value: TextRuntimeRevision } - | { readonly status: 'superseded'; readonly revision: number; readonly byRevision: number } - | { readonly status: 'aborted'; readonly revision: number; readonly reason?: unknown }; - -type TextPreparationError = - | { - readonly kind: 'capacity-exceeded'; - readonly batch: ParagraphBatch; - readonly capacity: number; - readonly required: number; - readonly overflows: readonly GlyphCapacityOverflow[]; - } - | { - readonly kind: 'preparation-failed'; - readonly cause: unknown; - }; - -interface GlyphCapacityOverflow { - readonly resourceKey: GlyphBatchKey; - readonly required: number; -} -``` - -The callback form constructs no public Promise and runs exactly once asynchronously. Supersession and cancellation are -handled synchronization outcomes, not errors. The Promise resolves them and the callback returns them through its `ok` -branch. The Promise rejects only for an actual preparation failure; the callback reports the same failure through its -`error` branch. - -An asynchronous executor may stream completed paragraph work into unpublished staging storage and report bounded progress -through `onProgress`. Streaming never publishes a partial runtime or paragraph-batch revision; every affected batch becomes -current together only after the complete synchronization succeeds. - -Both forms snapshot dirty state when called. Later property mutations remain dirty for the next synchronization: - -```ts -label.text = 'A'; -const preparingA = runtime.updateAsync(); - -label.text = 'B'; // pending for the next update; not folded into A -``` - -A newer synchronization supersedes any older asynchronous candidate that has not published: - -```ts -label.text = 'A'; -const preparingA = runtime.updateAsync(); - -label.text = 'B'; -runtime.update(); // publishes B before returning - -const outcomeA = await preparingA; -// { status: 'superseded', revision: A, byRevision: B } -``` - -`B` is the correct final state. The superseded result only explains why the older request did not publish; callers may -ignore it when they do not need update diagnostics. - -## Dirty state selects the work - -```ts -type ParagraphDirtyChannel = - | 'text' - | 'font' - | 'features' - | 'content-box' - | 'paint' - | 'raster-pixel-ratio' - | 'origins' - | 'order' - | 'variant'; -``` - -```ts -const WorkByChannel = { - text: 'shape-layout-partition', - font: 'shape-layout-partition', - features: 'shape-layout-partition', - 'content-box': 'reflow-and-boundary-reshape', - paint: 'rewrite-instance-paint', - 'raster-pixel-ratio': 'reselect-resources-and-repack', - origins: 'rewrite-instance-origins', - order: 'rebuild-glyph-runs', - variant: 'rebuild-glyph-runs', -} as const; -``` - -Core keeps a dirty set rather than scanning every paragraph. Repeated writes to the same field coalesce. Paint, origin, -order, and render-variant changes do not reshape text. - -## Core produces real glyph batches - -One paragraph can resolve glyphs through several fonts. Those fonts use one technique but may bind different GPU -resources. Core partitions and packs them before the renderer sees the revision. - -```ts -interface PreparedParagraphBatchRevision { - readonly paragraphBatch: ParagraphBatch; - /** Contiguous and monotonic within this paragraph batch. */ - readonly revision: number; - readonly technique: Technique; - readonly paragraphs: readonly PreparedParagraph[]; - readonly glyphBatches: readonly PreparedGlyphBatch[]; - readonly glyphRuns: readonly PreparedGlyphRun[]; -} - -interface PreparedGlyphBatch { - readonly key: GlyphBatchKey; - readonly technique: Technique; - readonly font: LoadedFont; - readonly capacity: number; - readonly instanceCount: number; - readonly binding: RasterBindingOf; - readonly storage: GlyphBatchStorageOf; - readonly dirtyRanges: readonly GlyphRange[]; -} - -declare const rasterTechniqueIdBrand: unique symbol; -type RasterTechniqueId = string & { readonly [rasterTechniqueIdBrand]: true }; - -declare const rasterResourceIdBrand: unique symbol; -type RasterResourceId = string & { readonly [rasterResourceIdBrand]: true }; - -interface GlyphBatchKey { - readonly technique: RasterTechniqueId; - readonly resource: RasterResourceId; - readonly pipelineVariant: number; - readonly generation: number; - readonly chunk: number; -} - -interface PreparedGlyphRun { - readonly batch: GlyphBatchKey; - readonly paragraph: ParagraphId; - readonly renderVariant: Variant | undefined; - readonly start: number; - readonly count: number; -} -``` - -`rasterPixelRatio` is renderer-supplied physical density, not layout scale. It defaults to the batch value, which defaults -to `1`; a paragraph may override it. Changing it never reshapes, but techniques such as Bitmap may reselect a strike and -repack affected storage. Because selection is part of the prepared core revision, one paragraph batch cannot represent two -different density choices for the same paragraph and revision across two attached targets. Paragraph overrides may still -partition one batch across several strikes. Render the same logical paragraph simultaneously at different target densities -with separate batches, or update the value before the synchronization that prepares that render phase. - -Spans do not override `rasterPixelRatio`. Density describes the target-space realization of one laid-out paragraph, while -spans describe source-local shaping and paint. A visual subsection that truly needs another density is a separate paragraph -(and, when it belongs to another render target, a separate batch). - -`RasterTechniqueId` and `RasterResourceId` are opaque branded strings whose values are stable and unique within a runtime. -Core interns and freezes one `GlyphBatchKey` object for each live physical glyph batch and reuses that object in -`PreparedGlyphBatch.key`, `PreparedGlyphRun.batch`, and adjacent revisions until the physical batch retires. Integrations -may therefore use the object as a `Map` key. The tuple `(technique, resource, pipelineVariant, generation, chunk)` is also its stable -diagnostic and deterministic ordering value; consumers must not manufacture keys. - -Given the resolved font sequence `Inter -> Noto -> Inter`, core may retain one Inter buffer and one Noto buffer while -emitting three ordered glyph runs: - -```ts -revision.glyphRuns = [ - { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 0, count: 8 }, - { batch: notoBatch.key, paragraph: label.id, renderVariant: warning, start: 0, count: 3 }, - { batch: interBatch.key, paragraph: label.id, renderVariant: plain, start: 8, count: 5 }, -]; -``` - -Core resolves batch → paragraph → span variant inheritance, then segments the ordered glyph sequence whenever the physical -batch, paragraph, or effective variant changes. The renderer does not inspect glyphs to rediscover technique, -raster-resource, capacity, source order, or variant boundaries. Each glyph batch also carries the technique-defined -`binding` that selects the required pages, buffers, or other decoded font data from `glyphBatch.font.data`. - -`PreparedGlyphRun` is not a promised draw call. It is the smallest ordered core-authored range an integration may need to -classify. The target may split a run, or coalesce adjacent compatible runs, when compiling engine draws. It must preserve -the supplied order and compositing semantics unless its documented depth/blend policy proves another ordering equivalent. -It may not redo shaping, fallback, resource selection, or slot allocation. Array position is the authoritative run order; -there is no duplicate numeric run-order field. Every live physical glyph slot appears in exactly one run. A technique that -needs several passes for one run expands them in its program and keeps those passes adjacent unless equivalent ordering is -proven. - -Render variants remain on the calling thread. `updateAsync()` snapshots immutable text/span input and its resolved variant -table under one candidate generation ID before posting shaping/layout input to a Worker. The Worker never receives renderer -objects or variants. On return, core maps source clusters against that same candidate's span table—not current desired -state—then publishes only if the candidate is still current. A newer synchronous or asynchronous publication supersedes -and discards the older candidate before variant mapping can become visible. A -variant boundary does not split a shaping cluster or ligature. The cluster receives the variant of the span containing its -first UTF-16 code unit. Exact partial-ligature styling requires an authored shaping boundary or a shader masking technique. - -## Core retains canonical instance storage - -Core must retain paragraph input, shaping/layout results, glyph allocation metadata, shaped origins, and optional origin -overrides. It also owns one canonical packed CPU representation for each prepared glyph batch. - -```ts -interface PreparedGlyphBatch { - readonly storage: GlyphBatchStorageOf; - readonly dirtyRanges: readonly GlyphRange[]; -} -``` - -`dirtyRanges` is the coalesced delta from the immediately preceding revision of this paragraph batch. When a target has -that exact predecessor, it uploads only those ranges. A newly attached target, or a target whose committed -`sourceRevision` is older than that predecessor, initializes every range referenced by `glyphRuns`; those are the live -instance ranges for the current revision. It may coalesce overlapping or adjacent upload ranges without altering the -ordered run sequence. - -The technique defines the canonical structure-of-arrays fields and writes changed slots into them. Those arrays are the -portable synchronization boundary. They remain available for multiple targets, late attachment, inspection, Worker result -integration, target recovery, and deterministic tests. - -Published array contents remain readable until the next revision of that paragraph batch publishes. A target must consume -or copy its selected ranges during its synchronous `stage()` call; pending engine work cannot retain a canonical typed-array -view and read it after that call returns. Core can therefore reuse its CPU shadow without allocating an immutable full-buffer -snapshot for every publication. - -On an adjacent revision, an integration synchronizes only `dirtyRanges`. When its engine layout matches, this is a direct -range copy or upload. When its layout differs, it maps only those canonical fields and ranges into its own interleaved or -technique-specific buffer. First and gapped synchronization use the live glyph-run ranges described above. The integration -still performs no shaping, source sorting, raster-resource partitioning, or slot allocation. It does compile the ordered -runs into its own minimum compatible draw sequence because only the integration knows its program, variant, pass, and -material compatibility. - -This CPU copy deliberately decouples core publication from inaccessible or in-flight GPU memory. The target owns its engine -buffers, upload commands, double/triple buffering, frame publication, fences, and retirement. - -## Move glyphs without reshaping +Baselines are distances from the paragraph box's top edge. Summary counts include retained non-rendering glyphs such as +spaces; `missingGlyphCount` counts positioned `.notdef` glyphs. ```ts -declare const glyphTopologyBrand: unique symbol; -type GlyphTopology = number & { readonly [glyphTopologyBrand]: true }; - -interface GlyphSnapshot { - readonly topology: GlyphTopology; - readonly glyphIds: Uint32Array; +interface ParagraphLayoutInspection extends ParagraphLayoutSummary { + readonly fontHandles: Uint32Array; + readonly glyphFontSlots: Uint16Array; + readonly glyphIds: Uint16Array; + readonly glyphStableIds: Uint32Array; readonly clusters: Uint32Array; - readonly fontSlots: Uint16Array; - readonly shapedX: Float32Array; - readonly shapedY: Float32Array; - readonly displayedX: Float32Array; - readonly displayedY: Float32Array; -} - -interface GlyphOriginUpdate { - readonly topology: GlyphTopology; - readonly start: number; - readonly x: ArrayLike; - readonly y: ArrayLike; -} -``` - -```ts -const snapshot = label.snapshotGlyphs(); -const x = snapshot.displayedX.slice(); -const y = snapshot.displayedY.slice(); - -simulateGlyphs(x, y, delta); - -label.setGlyphOrigins({ - topology: snapshot.topology, - start: 0, - x, - y, -}); - -runtime.update(); // writes origins only -``` - -`topology` identifies the committed glyph sequence to which indices apply. It changes whenever shaping, fallback, glyph -count/order, or font-slot assignment changes; paint, order, variant, transform, and origin-only updates preserve it. -`setGlyphOrigins()` rejects a stale topology synchronously and leaves desired state unchanged. A later reshape preserves an -override only when the resulting topology is identical; otherwise core clears the override and publishes the newly shaped -origins. - -Clear the override to return to the current shaped positions: - -```ts -label.clearGlyphOriginOverrides(); -runtime.update(); -``` - -Reshaping updates the authoritative target positions. The application may snapshot them again and interpolate from its -current displayed positions. - -## Three.js is a separate public surface - -Three.js applications use `FontLoader`, `TextGroup`, and `Text` from `@pmndrs/text/three`. That integration owns these core -objects privately and synchronizes them during Three's render lifecycle; it never asks an application to create core -paragraphs and wrap them in adapter objects. - -See the authoritative [Three.js text API](three-api.md). The mapping is intentionally direct: - -```ts -FontLoader -> cached TextRuntime/shaper initialization + loaded fonts -TextGroup -> technique-specific ParagraphBatch + Three renderer target -Text -> desired paragraph state + late-bound Paragraph + Object3D transform -``` - -## Implement another engine - -The engine consumes already partitioned storage and ordered glyph runs: - -```ts -for (const glyphBatch of revision.glyphBatches) { - const gpuBatch = target.ensureBatch({ - key: glyphBatch.key, - technique: glyphBatch.technique, - font: glyphBatch.font, - binding: glyphBatch.binding, - capacity: glyphBatch.capacity, - storage: glyphBatch.storage, - }); - - const ranges = isAdjacentTargetRevision - ? glyphBatch.dirtyRanges - : liveGlyphRunRanges(revision.glyphRuns, glyphBatch.key); - gpuBatch.upload(ranges); - gpuBatch.setCount(glyphBatch.instanceCount); + readonly glyphFontSizes: Float32Array; + readonly x: Float32Array; + readonly y: Float32Array; + readonly glyphFlags: Uint16Array; + readonly lineTextStarts: Uint32Array; + readonly lineTextEnds: Uint32Array; + readonly lineGlyphStarts: Uint32Array; + readonly lineGlyphCounts: Uint32Array; + readonly lineBaselines: Float32Array; + readonly lineAdvances: Float32Array; } - -const draws = program.compileRuns(revision.glyphRuns, revision.glyphBatches); -for (const draw of draws) target.draw(draw); ``` -Core owns shaping, fallback, layout, sorting, resource partitioning, slot allocation, overflow chunking, instance packing, -dirty ranges, and the ordered variant-bearing text runs. The engine owns compatible-run coalescing/splitting, final draw -planning, transforms, visibility, scene composition, GPU objects, render-pass placement, command encoding, frame -publication, fences, and resource retirement. +Inspection preserves font fallback identity, glyph IDs, UTF-16 cluster offsets, stable glyph identities, line membership, +and positioned geometry. It is a copied semantic view for measurement, hit testing, selection, and directed presentation +augmentation—not GPU instance storage. Repeated unchanged queries may reuse the same result object. -## Dispose +## Synchronization boundary -```ts -worldText.dispose(); -overlayText.dispose(); -inter.dispose(); -runtime.dispose(); -``` +There is one engine update export, `text_update(requestOffset, requestLength)`. The TypeScript host writes a complete frame +request into retained Wasm staging memory; Rust applies mutations, shapes and lays out affected paragraphs, packs canonical +instance records, and emits the render-plan command buffer plus coalesced dirty ranges. Renderer policy is compiled data, +not a JavaScript callback executed from Rust. -Dispose from the narrowest retained owner outward: paragraphs when individually finished, paragraph batches when a render -phase is finished, fonts after their paragraph leases are gone, and the runtime last. A successful dispose is idempotent; -using a disposed handle otherwise fails. +The low-level engine session and wire format are package-internal during this foundation stack. This prevents applications +from binding to an unstable ABI while the maintained Three implementation proves the policy and command-buffer model. +The [Rust engine plan](rust-layout-engine.md) is the authority for the ABI, memory-growth discipline, SIMD layout, and +follow-on publishing features. -`TextRuntime.dispose()` is the one intentional cascade root. It cancels asynchronous preparation and unpublished staging, -disposes every remaining paragraph batch and paragraph, releases loaded fonts after those leases are gone, notifies -attachments, and disposes the runtime-owned registry, shaper, and Worker. It invalidates every handle created by that -runtime and does not publish another revision. Targets release GPU resources only after their engine knows no in-flight -frame still references them. +## Removed pre-cutover surfaces -## Why these boundaries exist +The following experimental V0 surfaces are not part of the current API: -```ts -const Decisions = { - oneParagraphAPI: 'A label or icon is still a paragraph.', - explicitBatchTechnique: 'The technique fixes canonical buffer layouts and rejects incompatible text before shaping.', - fontStacksAreFonts: 'A FontStack is one ordered font selection with missing-glyph behavior.', - explicitParagraphBatches: 'Only the application knows where text render phases must remain separate.', - coreOwnedPhysicalBatching: 'Every target would otherwise duplicate grouping, sorting, packing, and dirty tracking.', - handleOwnedMutation: 'Repeated writes debounce naturally before a synchronization call.', - perUpdateScheduling: 'The same runtime must switch between immediate and Worker preparation.', - canonicalCpuStorage: 'Targets synchronize adjacent deltas or live ranges from one stable portable representation.', - orderedGlyphRuns: 'Fallback and render variants preserve source order without pretending every run is a draw.', -} as const; -``` +- `createParagraphEngine` and standalone JavaScript paragraph layout; +- `TextRuntime.createParagraphBatch`, `runtime.update`, and `runtime.updateAsync`; +- `analyzeBidi`, `shapeBatch`, and `reshapeRanges` exports; +- the text-preparation Worker protocol; +- `@pmndrs/text/typegpu` and its duplicate batch executor. -The old public `createParagraphEngine()` path, runtime-wide sync/Worker mode, mutation callback passed to `update()`, mixed- -technique logical batch, and renderer-owned reshaping or physical glyph repartitioning are explicitly not part of this API. +TypeGPU will be rebuilt against the Rust render plan rather than retaining the removed TypeScript batch model. Use the +[Three.js API](three-api.md) for the maintained renderer and `@pmndrs/text/r3f` for React. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 8a2d0125..75413206 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -218,8 +218,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-140 | `renderVariant` is a generic core value resolved by batch → paragraph → span inheritance and copied onto ordered glyph runs after shaping. Core never imports TSL, TypeGPU, material, or effect types. Variants do not split shaping clusters, physical glyph storage, pipelines, or draws by definition; a cluster receives the variant covering its first UTF-16 code unit. Variant changes rebuild run planning without reshaping, while stable integration-owned parameter bindings may update outside core. | Superseded by D-167 | | D-141 | The reusable GPU seam is a backend-specific `RasterShader` containing the complete canonical Bitmap, MTSDF, or Slug stage algorithm—including required vertex expansion/snapping/dilation, resource access, and fragment evaluation—followed by a `RasterProgram` that owns resources, application variants, pipeline/material compatibility, and final draw compilation. Custom programs normally call the canonical shader and alter resolved output; full shader replacement is only a low-level escape hatch. Concrete shader and program helpers infer exact associated types; heterogeneous boundaries widen to `unknown`, never `any`, and require narrowing. | Accepted | | D-142 | `TextEffect` is optional Three/TSL convenience layered over the default `ThreeRenderVariant`, not a core API and not the only customization surface. Effect definitions compose in order after canonical raster evaluation; definition identity determines graph compatibility while parameter values live in text/span-local sidecar bindings. A custom `ThreeRasterProgram` may define another variant type and batching policy while reusing the same technique shader. Native TSL effects remain Three-specific; TypeGPU-authored pure WebGPU math may adapt only within the exact capabilities proven for the pinned `toTSL()` bridge. | Superseded by D-167 | -| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Accepted | -| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Accepted | +| D-143 | The direct TypeGPU engine accepts a caller-owned `TgpuRoot` and owns one hidden core runtime, paragraph-batch attachments, canonical-range synchronization, transform/visibility buffers, program revisions, and draw encoding. It owns no adapter/device request, canvas, scene graph, RAF, render-pass creation, queue submission, or device-loss recovery. Applications mutate retained TypeGPU paragraph handles, explicitly call `update()` or `updateAsync()`, and call `encode(pass, frame)` on any live batch. TypeGPU programs expose typed canonical technique shaders, exact variant codecs, resources, pipelines, and draw compilers; another WebGPU host may reuse them only after its device/pass interop and lifecycle are source-inspected and executed. | Superseded by D-234 | +| D-144 | Target v1 maintains Three.js, React Three Fiber, and TypeGPU inside `@pmndrs/text` as the `/three`, `/r3f`, and `/typegpu` subpath exports. Those integrations may use package-owned implementation modules while preserving the one-way dependency from renderer-neutral core into no engine. The gpucat fitness fixture alone remains an external package that must consume documented public exports and reject deep imports. The reviewed gpucat surface fits core revisions, typed buffers, dirty ranges, textures, transforms, and ordered instanced draws without a core change; canonical Slug shader reuse remains a separate executable shader-package gate. | Superseded by D-234 | | D-145 | Explore TypeGPU as the authoritative complete-stage raster implementation without adding GPU-framework types to core. The experiment begins with the cheapest `@typegpu/three` capability gate and may end in one of three documented outcomes: TypeGPU authority across hosts, TypeGPU authority for WebGPU with native engine fallbacks, or an authoritative semantic raster/resource/stage specification with separately verified TypeGPU, TSL, and gpucat implementations. The currently documented Three bridge is WebGPU-only; native TSL cannot retire while the flagship Three package promises WebGL2. A reusable TypeGPU shader/program package is valuable independently of a full direct scene engine. | Experiment | | D-152 | Portable raster identities remain branded strings, but public authors construct them without casts: `defineRasterTechnique()` validates and brands its literal technique ID, while `defineRasterResourceId()` validates and brands technique-authored physical resource identities. The implementation proof found that branded input-only declarations were otherwise impossible for an external package to satisfy safely. Heterogeneous canonical storage is a partial `PropertyKey` record because finite named-field interfaces cannot satisfy a total index signature; the concrete self-mapped storage constraint still requires every declared field to be an `ArrayBufferView`. | Proposed | | D-153 | `RasterGlyphInput` carries the paragraph-local displayed `originX` and `originY` used for the candidate revision, and `RasterGlyphWriteInput` carries the exact technique binding already selected by core for that physical batch. The built-in technique implementation found that storage writers otherwise could not author renderer-ready origins or resource-relative UV/address fields without repeating layout and resource selection. Core remains responsible for layout, glyph overrides, and partitioning; the technique only packs the supplied candidate into its canonical storage. | Proposed | @@ -302,11 +302,11 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-221 | Three executes the transform realization selected by each policy program. Indexed packets have zero draw-level transform, require the Rust-packed `u32` buffer 15, share a renderer matrix sidecar, and may merge compatible instances across scene objects. Direct packets have a nonzero Rust draw `transformId`, omit the index/table attributes, and retain one mesh whose relative matrix follows that scene object without another Wasm call. Bitmap, MSDF, and Slug all select their matching position/MVP graph; the target neither invents a transform boundary nor merges across one. A compiled-Wasm fixture runs the same two-paragraph input through indexed and direct first-party policies and observes `[0,0]` versus `[1,2]`, exact attribute presence/absence, and scene-only matrix updates. A hybrid policy then publishes indexed Bitmap `[program 1, transform 0]` and direct MSDF `[program 2, transform 2]` together and updates both renderer transform realizations without Wasm. A public per-text batching switch is not introduced: policy/program selection remains renderer-integration authority. Live backend pixels and public `Text` cutover remain open. | Accepted | -| D-222 | Public Three `TextGroup` and standalone `Text` now render through one retained Rust engine session and the Rust command-buffer executor rather than constructing a TypeScript `ParagraphBatch`, attaching a target, and negotiating `prepare`/`commit` revisions. A group owns one session for every descendant paragraph and realizes compatible children as one indexed draw beneath the shared group root; standalone text owns the same path with its own draw root. Authored `material` definitions are interned to `materialId` on root text, group inheritance, or spans and resolved only by the renderer. Scene-transform and group render-order changes remain renderer-local. Rendering requests no paragraph layout arrays, so Three's legacy `layout`, glyph snapshot, and glyph-origin override surface is removed; any future measurement, caret, selection, or hit-test surface must be a separate demand-shaped Rust query rather than a render-plan table. Focused compiled-Wasm integration proves shared two-paragraph batching, mixed-font span draws, retained custom-material realization, reparenting, and disposal. The old TypeScript paragraph/batch implementation remains only for non-cutover core/TypeGPU/R3F consumers and is scheduled for deletion after those public paths move. | Accepted | +| D-222 | Public Three `TextGroup` and standalone `Text` now render through one retained Rust engine session and the Rust command-buffer executor rather than constructing a TypeScript `ParagraphBatch`, attaching a target, and negotiating `prepare`/`commit` revisions. A group owns one session for every descendant paragraph and realizes compatible children as one indexed draw beneath the shared group root; standalone text owns the same path with its own draw root. Authored `material` definitions are interned to `materialId` on root text, group inheritance, or spans and resolved only by the renderer. Scene-transform and group render-order changes remain renderer-local. Rendering requests no paragraph layout arrays, so Three's legacy `layout`, glyph snapshot, and glyph-origin override surface is removed; any future measurement, caret, selection, or hit-test surface must be a separate demand-shaped Rust query rather than a render-plan table. Focused compiled-Wasm integration proves shared two-paragraph batching, mixed-font span draws, retained custom-material realization, reparenting, and disposal. D-234 completes deletion of the old TypeScript paragraph/batch implementation. | Accepted | -| D-223 | Raster technique is owned by each loaded font binding and is no longer repeated by `FontStack`, imperative Three `Text`, or `TextGroup`. `createFontStack` accepts unique live fonts from one runtime and preserves the union of their techniques in its type. The Rust engine resolves the actual binding per glyph; the renderer policy decides whether and how each technique is lowered, and the render plan supplies the resulting storage/draw partition. Three leases heterogeneous selections by runtime rather than enforcing one technique, while the legacy `ParagraphBatch` retains its explicit single-technique boundary and rejects a heterogeneous union. A compiled-Wasm public fixture realizes one Bitmap-root paragraph with an MSDF span as two policy-selected draws and invokes the same `material` factory under both canonical technique contexts. | Accepted | +| D-223 | Raster technique is owned by each loaded font binding and is no longer repeated by `FontStack`, imperative Three `Text`, or `TextGroup`. `createFontStack` accepts unique live fonts from one runtime and preserves the union of their techniques in its type. The Rust engine resolves the actual binding per glyph; the renderer policy decides whether and how each technique is lowered, and the render plan supplies the resulting storage/draw partition. Three leases heterogeneous selections by runtime rather than enforcing one technique. A compiled-Wasm public fixture realizes one Bitmap-root paragraph with an MSDF span as two policy-selected draws and invokes the same `material` factory under both canonical technique contexts. | Accepted | -| D-224 | The Three and R3F command-buffer surfaces complete D-167's naming cutover: `material` is the only authored renderer customization property from group/text/span input through numeric Rust `materialId` and renderer factory realization. Their `ThreeRenderVariant` generic, `renderVariant` properties, setters, comparison logic, and no-op binding hook are deleted. The legacy portable core and TypeGPU variant state remains only until those implementations move to the Rust plan; it is not re-exported through the cut-over renderer APIs. | Accepted | +| D-224 | The Three and R3F command-buffer surfaces complete D-167's naming cutover: `material` is the only authored renderer customization property from group/text/span input through numeric Rust `materialId` and renderer factory realization. Their `ThreeRenderVariant` generic, `renderVariant` properties, setters, comparison logic, and no-op binding hook are deleted. D-234 deletes the remaining legacy portable-core and TypeGPU variant state. | Accepted | | D-225 | A third-party Three raster integrates with the Rust command buffer through one declarative `registerThreeRasterPlanProgram` registration. Its static policy descriptor is compiled and validated before the engine session exists; it contains input, physical-buffer, scalar-operation, and batching-key data but no hot JavaScript callback. Its cold font compiler lowers validated package-owned raster fields and resource references into one Rust binding, while its renderer factory realizes a material from exact plan buffers only when a draw requires it. The external glyph-example package no longer owns a `ParagraphBatchTarget`, candidate revision, slack planner, dirty-range copier, mesh transaction, or renderer-side layout loop. Compiled-Wasm lifecycle coverage proves Rust-packed buffers and retained mesh/geometry identity. A hardware-browser proof produces two deterministic samples on both WebGPU and forced WebGL2 with the same RGBA SHA-256 `817495c4afe3a8f88d2af85d972f43be88b9f834ed0268d0d0b2e3de86ba9d46`. Indexed visibility remains renderer-local: hiding one public `Text` zeros only its matrix-sidecar slot and preserves the shared draw; direct-policy draws mirror that object's mesh visibility. | Accepted | @@ -318,6 +318,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-231 | Browser workflows are successful only when both the Vitexec process and its injected page/module execution are successful. The root runner forwards captured output but rejects `[error]` and `[page error]` even when Vitexec exits zero. Presentation readiness is causal and may wait up to 60 seconds for a cold product-scale workload, but every cell must report positive glyph and draw counts plus zero missing glyphs. Grouped workload telemetry traverses the realized command-buffer root once because executor meshes are siblings of authored entry nodes. The complete 27-cell WebGPU sweep now reports nonzero work and exposes rather than hides Icon Grid's 476-draw, 30.8–47.0 FPS batching-policy gap. | Accepted | | D-232 | A renderer batch declares its compositing freedom once through `ordered` or `independent`; it does not provide a hot callback. `ordered` is the default and preserves authored draw order. `independent` permits the Rust planner to coalesce compatible interleaved technique/resource groups, and both ordered-direct and stable-indirect strategies implement that policy before emitting the renderer-neutral command buffer. Three and R3F expose the same `compositing` name on `TextGroup`; the benchmark icon grid selects independent mode while prose workloads remain ordered. The optimized shaper is 1,101,079 raw / 417,984 gzip / 328,164 Brotli bytes. | Accepted | | D-233 | Three classifies desired-state changes by the retained Rust semantic section they can invalidate: text replacement sends text/style/geometry, style-family changes send style, content-box changes send geometry, and an empty update is a no-op. A requested semantic view mask rides on a pending mutation frame so layout measurement/inspection and render-plan publication use one `text_update`; one returned sidecar populates all retained paragraphs, while cached committed queries remain crossing-free. On identical old Rust, Chromium 149/WebGPU/DPR-2 Paragraph Stress changes from 14.295 ms committed-baseline median to 13.615 ms with measurement piggyback alone and 7.450 ms after semantic dirty tiers, at 11,510 glyphs and one draw. The full candidate measures 6.885 ms; these 13–16-sample telemetry histories support isolation and direction, not a portable threshold. Focused integration proves zero calls for empty updates, exactly one call for mutation plus measurement, and exact command-buffer output. | Accepted | +| D-234 | The Rust render-plan cutover has one blessed executable path. The TypeScript paragraph engine, paragraph batch/attachment transaction, preparation worker, direct shaping/bidi/reshape ABI and readback, and first-generation `/typegpu` target are deleted rather than retained as compatibility implementations. Public `TextRuntime` owns the internal shaper; package-owned hosts alone access its direct-memory engine exports. Three and R3F consume the Rust command buffer. TypeGPU is a later from-scratch consumer of the same render-plan/policy contract and may not restore renderer-side layout or candidate/current target state. After cleanup, optimized SIMD Wasm is 1,113,113 raw / 422,035 gzip / 333,171 Brotli bytes. With `three`, React, and R3F external as optional peers, renderer-neutral JS + Wasm totals 1,211,173 / 440,875 / 349,703 raw/gzip/Brotli bytes and the complete Three adapter JS + Wasm totals 1,454,561 / 479,863 / 381,897. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 248ec1b8..2d5f8af6 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -18,12 +18,6 @@ sources: - id: layout-benchmark resource: ../../packages/text/scripts/benchmark-paragraph-layout.mts title: Paragraph layout benchmark, workflow text:layout-benchmark - - id: paragraph - resource: ../../packages/text/src/paragraph.ts - title: TypeScript paragraph preparation and layout - - id: paragraph-batch - resource: ../../packages/text/src/paragraph-batch.ts - title: Current canonical packing and dirty-range implementation - id: shaper-crate resource: ../../packages/text/rust/shaper/src/lib.rs title: HarfRust Wasm shaper crate @@ -267,8 +261,9 @@ TypeScript owns: - lowering the renderer-neutral plan through the one Three/TSL policy used by both WebGPU and forced WebGL2; and - renderer-owned upload staging, command encoding, fences, and transfer-buffer retirement. -TypeGPU product integration is outside this stack. The Three/TSL adapter and a minimal native plan consumer prove the -display-list and policy boundaries without adding another renderer dependency or product surface. +TypeGPU product integration is outside this stack. Its former paragraph-target adapter is deleted and will be rebuilt +from scratch against the Rust-emitted render plan and policy contract. The Three/TSL adapter and a minimal native plan +consumer prove those boundaries without adding another renderer dependency to the core engine. It does not decide bidi runs, break lines, position glyphs, synthesize decorations, or rebuild dirty ranges. diff --git a/docs/planning/three-api.md b/docs/planning/three-api.md index 6c202325..af1e50ea 100644 --- a/docs/planning/three-api.md +++ b/docs/planning/three-api.md @@ -1,1219 +1,243 @@ --- type: API Specification title: Three.js text API -description: Target v1 API for the package-owned Three.js integration subpath that loads fonts, declares scene-local text batches, retains transform-bearing Text objects, and synchronizes hidden core work inside the Three.js render lifecycle. +description: Reference for loading fonts, batching Text objects, querying committed Rust layout, and defining Three.js materials. documentation_type: reference -tags: [api, threejs, fonts, text, batching, lifecycle, rendering] +tags: [api, threejs, fonts, text, batching, materials, layout] status: stable sources: - id: core-api resource: core-api.md title: Core text API - - id: engine-contract - resource: engine-integration-contract.md - title: Engine integration contract - - id: effect-composition - resource: text-effect-composition.md - title: Optional Three.js effect composition - - id: typegpu-api - resource: typegpu-api.md - title: TypeGPU raster programs and text engine + - id: rust-engine + resource: rust-layout-engine.md + title: Rust text engine and render-plan ABI - id: current-loader - resource: ../../packages/text/src/loader.ts - title: Current font loader + resource: ../../packages/text/src/three/font-loader.ts + title: Current Three.js font loader - id: current-text resource: ../../packages/text/src/three/text.ts title: Current Three.js Text lifecycle + - id: current-material + resource: ../../packages/text/src/three/material.ts + title: Current Three.js material factory - id: three-object3d resource: https://threejs.org/docs/pages/Object3D.html title: Three.js Object3D - id: three-loader resource: https://threejs.org/docs/pages/Loader.html title: Three.js Loader - - id: three-group - resource: https://threejs.org/docs/pages/Group.html - title: Three.js Group - - id: three-buffer-attribute - resource: https://threejs.org/docs/pages/BufferAttribute.html - title: Three.js BufferAttribute generated: by: openai-codex/gpt-5.6 - at: '2026-08-07T04:31:24Z' + at: '2026-08-09T18:30:00Z' --- # Three.js text API -`@pmndrs/text/three` is the package-owned engine integration over renderer-neutral core contracts. Core never imports -Three.js; this subpath owns Three.js loading, scene objects, renderer resources, programs, and lifecycle. - -Three.js owns the core API internally. A Three.js application never creates a `TextRuntime`, -`ParagraphBatch`, `Paragraph`, prepared revision, or glyph run. +`@pmndrs/text/three` is the maintained renderer integration. `Text` and `TextGroup` are Three.js `Object3D` subclasses; +scene traversal collects desired mutations, calls the Rust engine, consumes its render-plan command buffer, uploads dirty +ranges, and updates draw proxies. ```ts -FontLoader - -> LoadedFont[] - -> TextGroup // explicit batch for one scene render phase - -> Text[] // transform-bearing Three.js objects - -> renderer.render(scene, camera) // membership, shaping, packing, and uploads synchronize here +import { FontLoader, Text, TextGroup, defineTextMaterial } from '@pmndrs/text/three'; +import { bitmap } from '@pmndrs/text/three/bitmap'; +import { msdf } from '@pmndrs/text/three/msdf'; +import { slug } from '@pmndrs/text/three/slug'; ``` -## The complete public surface - -```ts -import * as THREE from 'three/webgpu'; -import * as TSL from 'three/tsl'; -import type { - FontSelection, - FormattedText, - GlyphBatchKey, - GlyphRange, - ParagraphBatchTargetError, - PreparedGlyphBatch, - PreparedGlyphRun, - RasterBindingOf, - TextInput, - TextPreparationError, -} from '@pmndrs/text'; - -type TextError = TextPreparationError | ParagraphBatchTargetError; - -interface ThreeRenderVariant { - readonly effects?: readonly ThreeTextEffectBinding[]; -} - -type ThreeEffectParameterType = 'f32' | 'vec2f' | 'vec3f' | 'vec4f'; -type ThreeEffectParameterSchema = Readonly>; -type ThreeEffectParametersOf = { - readonly [Key in keyof Schema]: Schema[Key] extends 'f32' - ? ReturnType - : Schema[Key] extends 'vec2f' - ? ReturnType - : Schema[Key] extends 'vec3f' - ? ReturnType - : ReturnType; -}; - -interface ThreeTextEffectDefinition< - Shader extends AnyThreeRasterShader, - Schema extends ThreeEffectParameterSchema, -> { - readonly shader: Shader; - readonly parameters: Schema; - compose( - base: ThreeRasterFragmentOutputOf, - parameters: ThreeEffectParametersOf, - context: ThreeRasterFragmentContextOf, - ): ThreeRasterFragmentOutputOf; - bind(parameters: ThreeEffectParametersOf): ThreeTextEffectBinding; -} - -interface ThreeTextEffectBinding< - Shader extends AnyThreeRasterShader = AnyThreeRasterShader, - Schema extends ThreeEffectParameterSchema = ThreeEffectParameterSchema, -> { - readonly effect: ThreeTextEffectDefinition; - readonly parameters: ThreeEffectParametersOf; -} - -declare function defineTextEffect< - Shader extends AnyThreeRasterShader, - const Schema extends ThreeEffectParameterSchema, ->( - shader: Shader, - definition: Omit, 'shader' | 'bind'>, -): ThreeTextEffectDefinition; - -type ThreeProgramVariantKey = PropertyKey | object; - -declare const threeRasterShaderTypes: unique symbol; -interface ThreeRasterShaderTypeMap { - readonly vertexContext: VertexContext; - readonly vertexOutput: VertexOutput; - readonly fragmentContext: FragmentContext; - readonly fragmentOutput: FragmentOutput; -} - -interface AnyThreeRasterShader { - readonly technique: Technique; - readonly [threeRasterShaderTypes]?: ThreeRasterShaderTypeMap; -} - -interface ThreeRasterShader< - Technique extends AnyRasterTechnique, - VertexContext, - VertexOutput, - FragmentContext, - FragmentOutput, -> extends AnyThreeRasterShader { - readonly [threeRasterShaderTypes]?: ThreeRasterShaderTypeMap< - VertexContext, - VertexOutput, - FragmentContext, - FragmentOutput - >; - vertex(context: VertexContext): VertexOutput; - fragment(context: FragmentContext): FragmentOutput; -} - -type ThreeRasterShaderTypesOf> = NonNullable< - Shader[typeof threeRasterShaderTypes] ->; -type ThreeRasterFragmentContextOf> = - ThreeRasterShaderTypesOf['fragmentContext']; -type ThreeRasterFragmentOutputOf> = - ThreeRasterShaderTypesOf['fragmentOutput']; - -interface ThreeMtsdfVertexContext { - readonly localPosition: ReturnType; - readonly glyphIndex: ReturnType; - readonly viewport: ReturnType; - readonly modelViewProjection: THREE.Node; - readonly instance: ThreeMtsdfInstanceNodes; - readonly resources: ThreeMtsdfResourceNodes; -} - -interface ThreeMtsdfInstanceNodes { - readonly origin: ReturnType; - readonly fontSize: ReturnType; - readonly glyphRecord: ReturnType; - readonly paintIndex: ReturnType; -} - -interface ThreeMtsdfResourceNodes { - readonly atlas: THREE.Node; - readonly emSize: ReturnType; - readonly pixelRange: ReturnType; -} - -interface ThreeDerivativeNodes { - fwidth(value: THREE.Node): THREE.Node; -} - -interface ThreeRasterVertexOutput { - readonly position: ReturnType; - readonly techniqueVaryings: Readonly>; -} - -interface ThreeRasterFragmentOutput { - readonly color: ReturnType; - readonly coverage: ReturnType; -} - -interface ThreeMtsdfFragmentContext { - readonly localPosition: ReturnType; - readonly glyphIndex: ReturnType; - readonly paintIndex: ReturnType; - readonly screenScale: ReturnType; - readonly derivatives: ThreeDerivativeNodes; - readonly instance: ThreeMtsdfInstanceNodes; - readonly resources: ThreeMtsdfResourceNodes; -} - -interface ThreeProgramMaterialContext< - Technique extends AnyRasterTechnique, - Shader extends AnyThreeRasterShader, -> { - readonly renderer: THREE.WebGPURenderer; - readonly shader: Shader; - readonly font: LoadedFont; - readonly binding: RasterBindingOf; - readonly pipelineVariant: number; -} - -interface ThreeProgramVariantWriteContext { - readonly runs: readonly PreparedGlyphRun[]; - readonly ranges: readonly GlyphRange[]; -} - -interface ThreeProgramRunContext { - readonly glyphBatches: readonly PreparedGlyphBatch[]; - readonly glyphRuns: readonly PreparedGlyphRun[]; -} - -interface ThreeProgramDraw { - readonly object: THREE.Object3D; - readonly batch: GlyphBatchKey; - readonly start: number; - readonly count: number; -} - -interface ThreeRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Shader extends AnyThreeRasterShader = AnyThreeRasterShader, -> { - readonly technique: Technique; - readonly shader: Shader; - readonly cacheLimits: { - readonly pipelines: number; - readonly materializedVariants: number; - }; - supportsVariant(value: unknown): value is Variant; - variantKey(value: Variant | undefined): ThreeProgramVariantKey; - createMaterial(context: ThreeProgramMaterialContext): THREE.NodeMaterial; - writeVariants(context: ThreeProgramVariantWriteContext): void; - compileRuns(context: ThreeProgramRunContext): readonly ThreeProgramDraw[]; - dispose(): void; -} - -declare function defineThreeRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Shader extends AnyThreeRasterShader, ->(program: ThreeRasterProgram): ThreeRasterProgram; - -interface FontLoaderOptions { - readonly runtimeBake?: RuntimeFontBake; - readonly createWorker?: () => TextPreparationWorker; -} - -declare class FontLoader extends THREE.Loader, LoadedFontRequest> { - constructor(manager?: THREE.LoadingManager, options?: FontLoaderOptions); - - load( - request: LoadedFontRequest, - onLoad: (font: LoadedFont) => void, - onProgress?: (event: ProgressEvent) => void, - onError?: (error: unknown) => void, - ): void; - - loadAsync( - request: LoadedFontRequest, - onProgress?: (event: ProgressEvent) => void, - ): Promise>; +Import only the technique modules an application uses. Each module registers the matching Three policy program and +material implementation. - dispose(): void; -} - -declare class TextGroup extends THREE.Object3D { - constructor(options: TextGroupOptions); - - readonly technique: Technique; - readonly capacity: GlyphBufferCapacity; - readonly program: ThreeRasterProgram; - readonly textCount: number; - readonly disposed: boolean; - readonly error: TextError | undefined; - onError: ((error: TextError) => void) | undefined; - renderVariant: Variant | undefined; - - add( - ...children: CompatibleTextChildren - ): this; - setCapacity(capacity: GlyphBufferCapacity): void; - retry(): void; - clone(recursive?: boolean): never; - copy(source: THREE.Object3D, recursive?: boolean): never; - dispose(): void; -} - -declare class Text extends THREE.Object3D { - constructor(properties: StandaloneTextProperties); - - readonly textGroup: TextGroup | undefined; - readonly bound: boolean; - readonly disposed: boolean; - readonly layout: ParagraphLayout | undefined; - readonly error: TextError | undefined; - onError: ((error: TextError) => void) | undefined; - - font: FontSelection; - get text(): string; - set text(value: TextInput); - spans: readonly TextSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - renderVariant: Variant | undefined; - - set(properties: TextUpdate): void; - setSpan(index: number, span: TextSpan): void; - removeSpan(index: number): void; - - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - - setCapacity(capacity: GlyphBufferCapacity): void; - retry(): void; - dispose(): void; -} - -type SameType = [Left] extends [Right] ? ([Right] extends [Left] ? true : false) : false; - -type CompatibleTextChildren< - Technique extends AnyRasterTechnique, - Variant, - Children extends readonly THREE.Object3D[], -> = { - readonly [Index in keyof Children]: Children[Index] extends Text - ? SameType extends true - ? SameType extends true - ? Children[Index] - : never - : never - : Children[Index]; -}; - -export { txt, span } from '@pmndrs/text'; -export { defineTextEffect, defineThreeRasterProgram }; -export type { - FormattedText, - GlyphBufferCapacity, - SpanFormat, - SpanStyle, - SpanTag, - TextPreparationError, - UnboundSpanTag, -} from '@pmndrs/text'; -export type { ThreeRasterProgram, ThreeRasterShader, ThreeRenderVariant, ThreeTextEffectBinding }; -``` - -There is deliberately no universal four-field raster context. Each first-party shader exports its exact resource, -instance, vertex-context/output, and fragment-context/output types. Bitmap includes viewport/device-pixel snapping inputs; -MTSDF includes atlas access, `emSize`, `pixelRange`, derivatives, and screen scale; Slug includes curve/header/reference -resources, band bases, dilation inputs, and dependent-load accessors. The associated type map carries those exact types into -`createMaterial()` and `defineTextEffect()`. Adding a technique means defining those semantics, not widening a shared -context with optional fields. - -First-party programs keep bounded material/pipeline and materialized-variant caches. Factory options declare the limits, -eviction retires resources through renderer-safe disposal, and `program.dispose()` releases every remaining entry. A fresh -object-valued variant each frame therefore cannot grow the cache without bound. Custom programs own and document the same -policy. - -## Load fonts with the Three.js loader +## Load a font ```ts -import { createFontStack } from '@pmndrs/text'; -import { FontLoader } from '@pmndrs/text/three'; -import { mtsdf } from '@pmndrs/text/raster/mtsdf'; - const loader = new FontLoader(); - -const [inter, noto, iconFont] = await Promise.all([ - loader.loadAsync({ - input: { baked: '/fonts/Inter.font.glb' }, - raster: { technique: mtsdf }, - }), - loader.loadAsync({ - input: { baked: '/fonts/NotoSans.font.glb' }, - raster: { technique: mtsdf }, - }), - loader.loadAsync({ - input: { baked: '/fonts/Icons.font.glb' }, - raster: { technique: mtsdf }, - }), -]); - -const uiFont = createFontStack(inter, noto); -``` - -The first load in a Three font-cache domain lazily creates the single core text runtime and shaping engine. Concurrent loads -share that initialization Promise, and later loaders in the same domain reuse the resolved runtime and shaper. The cache -domain is integration-owned; Three users do not construct a core registry or runtime. `loadAsync()` does not resolve until -the font, selected technique data, and synchronous shaper are ready. Loading remains an explicit application wait; shaping -ordinary warm edits does not become a readiness Promise. - -The callback `load()` and Promise-returning `loadAsync()` follow the standard Three.js loader pattern and participate in the -provided `LoadingManager`. The loaded font is a Three-surface handle; it does not expose the hidden core runtime or core font -handle. - -Constructing a `Text` acquires a lease on every concrete font in its `Font` or `FontStack`, even while the object is -detached. Changing `text.font` acquires the complete replacement selection before releasing the old leases. -`LoadedFont.dispose()` fails while a live `Text` lease remains, so disposing a group or moving text can never silently drop -fallback data or replace a glyph with missing-glyph output. A `FontStack` value alone owns no lease; using a stack with a -successfully disposed member for a new `Text` is rejected. - -## Create an explicit batch with `TextGroup` - -```ts -import { TextGroup } from '@pmndrs/text/three'; - -const worldText = new TextGroup({ - technique: mtsdf, -}); - -scene.add(worldText); -``` - -```ts -interface TextGroupOptions { - readonly technique: Technique; - readonly program?: ThreeRasterProgram; - readonly capacity?: GlyphBufferCapacity; - readonly renderOrder?: number; - readonly renderVariant?: Variant; -} - -interface GlyphBufferCapacity { - readonly size: number; - readonly policy: 'grow' | 'chunk' | 'fixed'; -} -``` - -## Select a program and render variant - -`technique` remains construction-only because it fixes decoded resources and canonical glyph-buffer layout. `program` is -also construction-only because it fixes the accepted variant type, technique shader, Three attributes, node material, -pipeline compatibility, and final draw compiler. Fonts remain per `Text` and are never declared on the group. - -```ts -const gradientSlug = createThreeSlugProgram({ - fragment({ shader, context }) { - const base = shader.fragment(context); - return { ...base, color: gradient(base.color, context.localPosition) }; - }, -}); - -const labels = new TextGroup({ - technique: slug, - program: gradientSlug, - renderVariant: { gradient: 'ui-default' }, -}); - -const label = new Text({ - font: uiFont, - text: 'Warning', - renderVariant: { gradient: 'warning' }, +const font = await loader.loadAsync({ + input: { baked: '/fonts/inter-msdf.font.glb' }, + raster: { technique: msdf, options: { /* technique options */ } }, }); -labels.add(label); -``` - -The first-party default program is selected when `program` is omitted. A group, text, and manual span may each set a -variant; inheritance is group → text → span. The hidden core batch carries those exact values through ordered glyph runs. -The Three program decides whether adjacent variants use one material/draw with indexed sidecar parameters or require -separate draw proxies. A variant is not automatically a material and is not automatically a draw boundary. - -The standard programs accept `ThreeRenderVariant`, whose optional `effects` list is produced by the effect helpers: - -```ts -const chromatic = defineTextEffect(slugShader, { - parameters: { phase: 'f32' }, - compose(base, parameters, context) { - return { ...base, color: chromaticColor(base.color, context.paintIndex, parameters.phase) }; - }, -}); - -const animated = chromatic.bind({ phase: phaseUniform }); -const effectLabels = new TextGroup({ technique: slug }); // standard ThreeRenderVariant program -const effectLabel = new Text({ - font: uiFont, - text: 'Warning', - renderVariant: { effects: [animated] }, -}); -effectLabels.add(effectLabel); - -effectLabel.setSpan(0, { - start: 0, - end: 7, - renderVariant: { effects: [animated] }, -}); -``` - -Effects compose after the canonical Bitmap, MTSDF, or Slug shader has resolved coverage and base output. Definitions with -the same ordered graph identity share a material program; binding values are stored per text/span and do not create a new -pipeline. This is an optional TSL authoring convenience, not a core API and not a requirement for custom programs. A custom -program may define a completely different `Variant` type while still reusing the exported canonical technique shader. - -React Three Fiber expresses the same span variant through nested text: - -```tsx - - Normal animated - -``` - -An optional TypeGPU-authored pure-WebGPU function may enter a Three program only through capabilities proven for a pinned -`@typegpu/three` version. At the reviewed 0.11.0 bridge, `toTSL()` injects a nullary WGSL closure through Three's WebGPU -builder, has no WebGL2 path, and has not carried the real Slug resources. Three still owns accessors, material, blend/depth -state, render-list integration, draw compilation, and lifecycle. Native TSL remains the only specified complete Three -program; any adapted TypeGPU shader is an experimental program implementation, not a different core technique. - -### Use the default or preallocate explicitly - -An explicit `TextGroup` defaults to `{ size: 4_096, policy: 'chunk' }`. Storage is allocated lazily for each physical -font-resource buffer, so an empty group allocates no glyph arrays or GPU buffer. Text objects and their metadata are not -capacity-limited. - -```ts -const denseText = new TextGroup({ - technique: mtsdf, - capacity: { size: 20_000, policy: 'chunk' }, -}); -``` - -`size` counts glyph-instance slots per physical buffer, not texts and not total glyphs across the `TextGroup`. `chunk` -allocates another buffer without replacing published storage, `grow` transactionally replaces the full buffer with a -buffer whose capacity doubles until the pending glyphs fit, and `fixed` makes `size` a hard per-buffer limit. The readonly -`capacity` property exposes the normalized explicit or default value. - -`add()` validates text lifetime, font lifetime, and technique compatibility. It does not shape, so it cannot know whether a -fixed physical buffer will overflow. That check occurs during the owning group's pre-render synchronization, after fallback, -shaping, and layout reveal exact per-resource glyph counts. - -Resize explicitly when a fixed group needs a larger allocation: - -```ts -const overflow = labels.error; -if (overflow?.kind !== 'capacity-exceeded') throw new Error('No fixed-capacity overflow to resize'); - -labels.setCapacity({ size: overflow.required, policy: 'fixed' }); -``` - -`setCapacity()` preserves the public `TextGroup`, every nested `Text`, and every bound core `Paragraph`. It forwards the -normalized capacity to the existing hidden `ParagraphBatch`, clears an unchanged capacity-overflow latch, and schedules a -transactional canonical-storage and target-storage replacement for the next synchronization. The previous complete draw -objects remain live until the replacement commits; renderer fences then retire them normally. No scene reparenting, -listener transfer, ref replacement, or cleanup is required. - -`fixed` prevents automatic growth; it does not make the configured size permanently immutable. `setCapacity()` may also -switch policies or shrink deliberately. Passing the current normalized capacity is a no-op. A shrink that cannot hold the -desired generation reports the ordinary typed overflow while preserving the prior complete draw. - -`TextGroup.clone()` and `TextGroup.copy()` are unsupported and throw. A group owns identity-bearing text membership, -subscriptions, attachment state, and renderer resources that cannot follow ordinary recursive `Object3D` copy semantics -safely. Construct a separate group and add intentionally distinct `Text` objects when a second independently renderable -tree is required. - -A `TextGroup` is one author-declared text render phase and one hidden core paragraph batch. Its technique fixes the -canonical instance layout and shader family before any text is attached. Every `Text` owns its font selection, which must -use that technique. Core may produce several physical resource batches and ordered variant-bearing glyph runs beneath one -`TextGroup`; the selected program compiles those runs into Three draw objects. - -The `add()` override preserves normal `Object3D` children while conditionally rejecting any directly supplied -`Text` tuple member. Runtime ancestry validation remains mandatory for JavaScript, React reconciliation, -and text nested below arbitrary containers. - -`TextGroup` deliberately extends `THREE.Object3D`, not `THREE.Group`. Three carries the nearest real ancestor Group's -`renderOrder` through non-Group descendants as `groupOrder`; another Group would replace it, including with its default -value of `0`. The integration does not insert a hidden Group. - -`TextGroup.renderOrder` is the secondary render-order base for the batch. The integration maps the program's ordered -physical draws to consecutive native Three render orders beginning at that base. `Text.renderOrder` remains the paragraph -sorting value inside core; it cannot create a Three render-list boundary inside one GPU batch. - -```ts -parent.renderOrder = 100; -parent.add(textGroup); // physical draws use groupOrder 100 - -textGroup.renderOrder = 10; // physical draws begin at secondary order 10 ``` -A nested `TextGroup` starts a new batch and render-order domain; it never joins its nearest outer `TextGroup`. +`FontLoader` extends `THREE.Loader`, participates in its `LoadingManager`, and accepts an optional `AbortSignal` on the +request. Loaders sharing a manager share one text runtime. Disposing a loader releases its runtime only after every font +loaded through that domain has also been disposed. -Create separate `TextGroup` instances when text belongs to different scenes, render phases, or renderer lifetimes: +Source-font loading requires a caller-supplied runtime baker: ```ts -const mainSceneText = new TextGroup(worldOptions); -const minimapSceneText = new TextGroup(minimapOptions); - -mainScene.add(mainSceneText); -minimapScene.add(minimapSceneText); +const loader = new FontLoader(undefined, { runtimeBake }); ``` -One Three object can have only one parent, so one `TextGroup` cannot be present in two scenes simultaneously. The group binds -to the first renderer that draws it. Rendering it through a different renderer fails before drawing; create a separate -`TextGroup` so attributes, materials, upload ranges, fences, and retirement remain renderer-owned. Standalone implicit -batches follow the same rule. +No runtime baker is pulled into the default Three bundle. -## Add and remove text through the scene graph +## Create text ```ts -import { Text } from '@pmndrs/text/three'; - const label = new Text({ - font: inter, - text: 'Player 1', + font, + text: 'Hello world', + style: { fontSize: 32, lineHeight: 1.2, language: 'en' }, + contentBox: { + width: { mode: 'at-most', size: 420 }, + wrap: 'word', + overflow: 'clip', + }, + paint: { color: '#ffffff' }, }); -worldText.add(label); - -label.position.set(0, 2, 0); -label.rotation.y = Math.PI / 4; -label.scale.setScalar(2); +scene.add(label); ``` -There is no `TextGroup.allocate()` shortcut. Construction creates one retained, late-bound `Text`; inherited -`Object3D.add()` and `Object3D.remove()` are the only membership operations. Adding binds the object to the batch before -the next synchronization. Removing releases its internal paragraph membership without disposing the public object, so it -can be added elsewhere. - -A `Text` joins its nearest `TextGroup` ancestor. Ordinary `Object3D` containers may appear between them. A nested -`TextGroup` stops membership discovery: +A standalone `Text` owns an implicit batch of one. It binds lazily when attached to a traversed scene graph; construction +does not shape or allocate renderer buffers. -```ts -worldText.add(container); -container.add(label); // label still belongs to worldText - -worldText.add(overlayText); -overlayText.add(icon); // icon belongs to overlayText, never worldText -``` +`Text` accepts either a plain string with explicit `spans`, or a formatted value built with `txt` and `span`. Span values +may override font selection, shaping style, paint, and material. -## A standalone `Text` is a batch of one +## Batch text ```ts -const title = new Text({ - font: uiFont, - text: 'Standalone title', +const group = new TextGroup({ + capacity: { size: 4096, policy: 'grow' }, + compositing: 'ordered', }); -scene.add(title); +group.add(title, body, iconLabel); +scene.add(group); ``` -```ts -type StandaloneTextProperties = TextProperties< - Technique, - Variant -> & - Readonly<{ - capacity?: GlyphBufferCapacity; - }>; -``` - -When a render-attached `Text` has no `TextGroup` ancestor, it owns an implicit paragraph batch containing only itself. Its -required font selection supplies that implicit batch's technique. Adding that same object to a `TextGroup` retires the -implicit batch, validates its font selection against the explicit group technique, and creates new paragraph membership in -the group. - -An unattached or detached `Text` remains unbound and owns no implicit batch. `textGroup.remove(text)` therefore leaves only -the reusable public object and desired state. Adding it directly to a scene later creates its implicit batch before that -scene's first shaping and render; adding it to another `TextGroup` creates membership there instead. The public `Text`, -transform, desired properties, and glyph overrides remain the same object throughout. - -The standalone `capacity` value configures only that implicit batch and defaults to `{ size: 256, policy: 'grow' }` to -avoid reserving a full explicit-group chunk for every isolated label. While the object is inside a `TextGroup`, the parent -group's technique and capacity policy are authoritative; the `Text` always retains its own font selection. -`text.setCapacity()` changes the retained implicit-batch capacity without replacing the `Text`; while grouped, that setting -is retained but inactive until the text becomes standalone again. - -## Bind late; render on the first frame - -Constructing an unattached `Text` stores desired state only. It creates no core paragraph, performs no shaping, allocates no -glyph slots, and creates no GPU object. - -```ts -const score = new Text({ font: inter, text: '0' }); - -score.text = '1'; -score.text = '2'; -score.text = '3'; - -hudText.add(score); -renderer.render(scene, camera); // shapes and renders only "3" -``` - -Membership is resolved before the first shaping call. `Object3D` `added`, `removed`, `childadded`, and `childremoved` -events mark scene membership dirty synchronously. Because those events do not bubble through every arbitrary ancestor -change, `Text` and `TextGroup` perform a final ancestry reconciliation at the start of `updateMatrixWorld()`. - -The integration uses `updateMatrixWorld()` as its automatic synchronization hook. `TextGroup` owns a private -`ThreeTextBatchBinding`; this is the object that holds the core `ParagraphBatch`, its `ParagraphBatchAttachment`, the -`ThreeParagraphBatchTarget`, the internal draw meshes, and the map from each child `Text` to its core `Paragraph`. -It is implementation machinery, not another public API. - -The implementation sequence is: - -```ts -class TextGroup extends THREE.Object3D { - readonly #binding: ThreeTextBatchBinding; - - override updateMatrixWorld(force?: boolean): void { - this.#binding.reconcileMembership(this); - this.#binding.applyPendingMembership(); - - // Runtime-wide: shape, lay out, sort, partition, allocate, pack, and publish. - // Returns runtime.current without allocating when no desired state is dirty. - this.#binding.runtime.update(); - - // Stage only this observed renderer target from the latest core publication. - this.#binding.prepareCurrentRevision(); +All descendant `Text` objects that belong to the same runtime participate in one retained Rust engine session and one +render plan. Compatible Bitmap, MSDF, and Slug records may share backing storage while the plan emits the draw boundaries +required by technique, font resource, material, clipping, and compositing policy. - // Commit this target revision. This installs - // the exact internal meshes needed by the program-compiled draw sequence. - this.#binding.commitPreparedRevision(); +`compositing: 'ordered'` preserves authored draw order. `independent` allows Rust to reorder compatible work when the +application asserts that blending order is irrelevant. - // Three computes this group, every child Text, and every newly installed mesh. - super.updateMatrixWorld(force); +Capacity policy controls the instance arena: - // Core glyph origins are paragraph-local. Compose them with the now-current - // Text transforms, copy only changed transform slots, and mark those attribute - // ranges for WebGPURenderer. - this.#binding.writeGlyphTransforms(); - } -} -``` +| Policy | Behavior | +| --- | --- | +| `grow` | Grow retained storage to fit the group. | +| `chunk` | Use bounded chunks when the group exceeds the initial size. | +| `fixed` | Reject an update that exceeds the declared capacity. | -`applyPendingMembership()` is where scene membership becomes core membership. For each newly bound object it calls -`paragraphBatch.add(text.desiredState)` and records the returned `Paragraph`; for each departure it calls -`paragraph.dispose()`. It applies desired-state setters to already bound paragraphs before `runtime.update()`. No shaping -happens in `Text.text`, `Text.set()`, `Text.setSpan()`, or the scene-graph event handlers. +The default group capacity is 4,096 glyphs with `chunk` policy. A standalone `Text` defaults to 256 glyphs with `grow` +policy. `setCapacity()` changes the retained capacity policy without changing text semantics. -`runtime.update()` publishes one atomic `TextRuntimeRevision` and updates the attachment's latest source revision. It never -calls a target or allocates renderer resources. The currently traversed binding then calls: +## Update retained values ```ts -attachment.prepare(); -// coordinator calls threeTarget.stage(attachment.current, attachment.source) -``` - -`ThreeParagraphBatchTarget.stage()` performs the engine-layout work: it creates or reuses the required Three -`BufferAttribute` storage, selects `PreparedGlyphBatch.dirtyRanges` when its committed target revision is the immediate -predecessor, otherwise selects every live range for that batch from the prepared glyph runs, copies those ranges, sets the -corresponding Three update ranges and `needsUpdate`, and asks the selected `ThreeRasterProgram` to compile ordered -compatible runs into internal meshes or draw proxies. It does not shape, sort paragraph source order, repartition physical -storage, or upload directly to a GPU queue. A ready -stage is still unpublished until `commitPreparedRevision()` calls `attachment.commit()` at this render boundary and swaps -the binding's live internal draw objects. - -The standard Three target is intentionally synchronous: `stage()` must return `{ status: 'ready', stage }` before -`prepareCurrentRevision()` returns. Font bytes, raster pages, and optional program modules are loaded explicitly before a -`Text` can bind; Three `NodeMaterial` and buffer objects are created synchronously, while WebGPURenderer performs physical -pipeline compilation/upload later in its normal render path. A custom target that returns `pending` remains valid under the -core attachment contract, but cannot provide this integration's same-observing-frame guarantee and is not accepted by the -standard `TextGroup` binding. - -With Three's default `scene.matrixWorldAutoUpdate = true`, the complete WebGPURenderer 0.185.1 call chain is: +label.text = 'Updated'; +label.style = { ...label.style, fontSize: 36 }; +label.contentBox = { width: { mode: 'exact', size: 500 }, wrap: 'word' }; +label.paint = { color: '#ffd166' }; -```ts -renderer.render(scene, camera) - -> Renderer._renderScene(scene, camera) - -> scene.updateMatrixWorld() - -> TextGroup.updateMatrixWorld(force) - -> binding.reconcileMembership(textGroup) - -> binding.applyPendingMembership() - -> ParagraphBatch.add(...) / Paragraph.dispose() / Paragraph setters - -> TextRuntime.update() - -> publish TextRuntimeRevision - -> attachment records latest source revision - -> binding.prepareCurrentRevision() - -> ParagraphBatchAttachment.prepare() - -> ThreeParagraphBatchTarget.stage(previous, preparedBatch) // must be ready - -> binding.commitPreparedRevision() - -> ParagraphBatchAttachment.commit() - -> install the staged internal draw meshes - -> Object3D.updateMatrixWorld(force) - -> Text.updateMatrixWorld(force) // transform only when grouped - -> drawMesh.updateMatrixWorld(force) - -> binding.writeGlyphTransforms() - -> BufferAttribute.addUpdateRange(...) - -> BufferAttribute.needsUpdate = true - -> Renderer._projectObject(...) // build and sort the render list - -> drawMesh.onBeforeRender(...) - -> Renderer._renderObjectDirect(...) - -> Geometries.updateForRender(...) - -> Attributes.update(...) - -> WebGPUBackend.updateAttribute(...) // actual dirty-range GPU write - -> backend.draw(...) // one program-compiled draw +label.set({ text: 'Final value', paint: { color: '#ffffff' } }); ``` -Names beginning with `Renderer._` are shown to locate the integration in Three.js 0.185.1's implementation; they are not -APIs the package calls or overrides. The supported hook is the public `Object3D.updateMatrixWorld()` override. The internal -draw meshes use ordinary Three render-list and buffer-update behavior. - -If an application sets `scene.matrixWorldAutoUpdate = false`, Three deliberately skips `scene.updateMatrixWorld()` and -therefore skips this automatic text synchronization. That application has opted into manual scene updates and must call -`scene.updateMatrixWorld()` before `renderer.render(scene, camera)`; it does not call a text-specific update method. - -Publishing the target revision before `super.updateMatrixWorld()` ensures newly installed meshes receive a world matrix in -the same traversal. Writing transform attributes after it ensures they read completed `Text.matrixWorld` values. Both -happen before `_projectObject()` builds the render list, so resident text added immediately before `renderer.render()` is -present, transformed, uploaded, and drawn in that call; no preparatory frame is required. - -`super.updateMatrixWorld()` visits every child `Text` exactly once. A grouped `Text` still performs that normal transform -update but skips its standalone preparation branch because its nearest `TextGroup` owns the paragraph membership and draw -objects. Joining or leaving a group never changes `matrixAutoUpdate` or `matrixWorldAutoUpdate`; caller-authored Three matrix -policy survives unchanged. A detached text owns no preparation, while a directly rendered standalone text resumes its own -implicit-batch branch. That branch uses the same method order through a private one-paragraph `ThreeTextBatchBinding`. +Setters change desired state. The nearest `TextGroup` applies all pending descendant changes together on its next +`updateMatrixWorld()` traversal. Reassigning a value that normalizes to the current state is a no-op. Transform-only +changes update the transform buffer and do not reshape or recompose text. -## Moving between batches is remove plus add +One group traversal performs at most one mutating `text_update` transaction for that group's pending values. Calling a +layout query with pending mutations may perform that synchronization earlier; the following traversal observes the +committed revision and does not repeat the semantic work. -```ts -overlayText.add(label); -``` +Errors are retained on `text.error` or `group.error` and forwarded to `onError`. They do not escape Three.js scene +traversal. `retry()` reapplies a retained publication after a renderer-side failure. -Three.js removes `label` from its old parent before adding it to `overlayText`. The integration responds by staging two core -operations: +## Query committed layout ```ts -oldParagraph.dispose(); -const nextParagraph = overlayParagraphBatch.add(label.desiredState); +const summary = label.measureLayout(); +const layout = label.inspectLayout(); ``` -It does not move a core paragraph handle between batches. Pending removal and allocation publish in the same pre-render -synchronization, so the old batch cannot leave ghost glyphs while the new batch renders the object. Cached shaping and -layout may be reused when their inputs are unchanged, but the destination receives new batch slots. - -The old paragraph slot and glyph instances belong to the old batch, not to `label`. Removal makes those slots reusable and -updates the old batch's logical counts and glyph runs. It does not dispose or shrink a shared buffer merely because one -text left. The old `TextGroup` retains that capacity until a later transactional replacement or `TextGroup.dispose()`. -The destination group owns any new physical storage it needs. Moving from a standalone implicit batch also retires that -text-owned target storage according to the renderer's in-flight-frame rules. - -That standalone-to-group transition is transactional. The integration validates and stages destination membership first, -publishes the new complete group revision, then retires the previous implicit target only after no in-flight frame can use -it. It never destroys the old target first and risks a missing frame or unrecoverable destination failure. +`measureLayout()` requests an allocation-light `ParagraphLayoutSummary`. `inspectLayout()` additionally copies per-line +and per-glyph semantic arrays. Neither query is part of the ordinary render plan, and rendering never materializes layout +arrays merely to draw. -Removal marks old membership dirty synchronously. Slot recycling and the updated glyph-run list publish at the old -group's next render synchronization. If the old group remains visible, that synchronization occurs before Three builds the -next render list. If the entire group is removed and will never render again, the application disposes the group rather -than waiting for another synchronization. +Queries apply pending mutations for the containing group because the requested result must describe one coherent Rust +revision. Every paragraph updated by that transaction becomes reusable by the next render traversal. Repeating a query on +an unchanged committed layout returns the retained result object without another Wasm crossing. -Changing parents during an active Three.js traversal is unsupported, matching Three.js scene-graph expectations. Scene -membership changes must complete before `renderer.render()` enters world-matrix traversal. +The complete field semantics are defined by the [core layout-query reference](core-api.md#layout-query-values). -## Dispose a group; retain its text - -`TextGroup.dispose()` is terminal for the group, not recursive destruction of its scene children: - -```ts -groupA.add(label); -renderer.render(scene, camera); - -groupA.dispose(); - -groupA.disposed; // true -label.disposed; // false -label.bound; // false -label.textGroup; // undefined, even while label.parent is still groupA - -groupB.add(label); // Three reparents the same object -renderer.render(scene, camera); // new paragraph membership renders in groupB -``` - -Disposal synchronously invalidates `groupA` as a text-batch boundary, unbinds every direct or nested member `Text`, cancels -the group's unpublished preparation, and begins retirement of its core paragraph batch and renderer targets. Existing -children keep their transforms, desired state, glyph-origin override state, and font leases. The group contributes no -further text draws and rejects new text membership, but disposal does not mutate Three parent/child relationships. -While a live `Text` remains below the disposed group in the scene graph, that disposed group stays a terminal non-rendering -batch boundary: ancestry reconciliation must not fall through to an outer `TextGroup` or create an implicit standalone -batch. The caller moves the text explicitly when it should render elsewhere. - -`groupB.add(label)` validates the live text, every leased font, and technique compatibility before calling Three's -reparenting operation. Failure leaves `label` unchanged and unbound; success creates a new core paragraph handle and group-B -slots before its first render. No group-A paragraph handle or GPU allocation transfers to group B, and group-A resources -retire independently according to their renderer fences. - -## Three.js owns synchronization +## Define a material ```ts -renderer.setAnimationLoop(() => { - renderer.render(scene, camera); +const material = defineTextMaterial((context) => { + const value = context.createDefaultMaterial(); + // Customize the technique-specific TSL graph or material properties. + return value; }); -``` - -Applications do not call a core update and then copy the result into Three. The integration coalesces desired-state and -membership changes, invokes the shared runtime's `update()` from each encountered standalone `Text` or `TextGroup`, then -prepares only that encountered owner's attachment and continues normal matrix traversal. Core specifies that a no-op -`update()` returns the current revision without allocation or notification. The first call after a mutation therefore -prepares every dirty paragraph across every paragraph batch; later calls in the same frame, scene, or render pass are cheap -revision checks unless their own membership reconciliation introduced new dirty work. Publication alone never stages, -cancels, aborts, allocates, uploads, or commits another scene's or renderer's target. That attachment reconciles its stale -candidate and prepares the latest source only when its owner is actually traversed. - -`WebGPURenderer.render(scene, camera)` updates and projects only the supplied scene or object root. Three does not first -update every scene known to the application. When an application renders several scenes, each scene traversal naturally -encounters its own text owners and calls the same shared runtime. No application-level text update is required: the first -encounter after a mutation performs the work, and every later encounter observes the published revision. Warm edits are -current in the render call that observes them. Loading and raster-page misses remain explicit readiness work owned by -`FontLoader` and the loaded font handle rather than being silently started as ordinary shaping. - -## Change retained text at runtime - -```ts -label.text = 'First value'; -label.text = 'Second value'; -label.text = 'Player 2'; -label.contentBox = { - width: { mode: 'at-most', size: 360 }, - wrap: 'word', -}; +const label = new Text({ font, text: 'Custom', material }); ``` -Those writes update desired state only. The parent batch shapes the final values once during its next render-loop -synchronization. Nested records are immutable replacement values; direct deep mutation is unsupported. +The factory is renderer-owned. Rust carries a numeric `materialId` through style resolution and draw planning; it does not +execute the factory. Three invokes `create()` when it needs a material for a concrete Bitmap, MSDF, or Slug pipeline. -```ts -interface TextBaseProperties { - readonly font: FontSelection; - readonly contentBox?: ParagraphContentBox; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; -} - -type TextContentProperties = - | Readonly<{ - text: string; - spans?: readonly TextSpan[]; - }> - | Readonly<{ - text: FormattedText; - spans?: never; - }>; - -type TextProperties = TextBaseProperties< - Technique, - Variant -> & - TextContentProperties; - -type TextUpdate = - | (Partial> & - Readonly<{ - text?: string; - spans?: readonly TextSpan[]; - }>) - | (Partial> & - Readonly<{ - text: FormattedText; - spans?: never; - }>); - -interface TextSpan { - readonly start: number; - readonly end: number; - readonly font?: FontSelection; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; -} -``` - -`font` and every span font must match the effective batch technique. Changing `font` to another same-technique `Font` or -`FontStack` is a retained update. Assigning an incompatible selection throws without changing current desired or rendered -state. +`ThreeTextMaterialContext` is a discriminated union on `technique`. Each branch provides the concrete technique shader, +the final policy-selected position node, and `createDefaultMaterial()`. -## Compose typed spans +A material on a span overrides the text material; a text material overrides the group material. Equal material objects +share identity. Different materials may still share instance buffers—the render plan determines draw segmentation, while +the Three executor decides which GPU resources can be shared safely. -The Three entry point re-exports core's renderer-neutral `txt` and `span` tags. It does not add formatting methods to the -`Text` class or parse a markup language. +## Mix fallback techniques ```ts -import { Text, span, txt } from '@pmndrs/text/three'; - -const emphasis = span(noto, { color: '#ffddff' }); - -const label = new Text({ - font: uiFont, - text: txt`Fast ${emphasis`accurate`} text`, +const prose = await loader.loadAsync({ + input: { baked: '/fonts/inter-msdf.font.glb' }, + raster: { technique: msdf, options: {} }, }); - -label.text = 'Plain text'; -label.text = txt`Player ${span(noto)`Two`}`; -``` - -`txt` returns one immutable typed literal containing the flattened string and computed UTF-16 spans. `span()` accepts a -style by itself, or a `Font` / `FontStack` followed by styles and same-technique font overrides, merging left to right into -a reusable typed tag. TypeScript validates fonts, style and paint fields, property names, and technique. Assignment of a -plain string clears spans, while assignment of a literal replaces text and spans atomically. Explicit `spans`, `setSpan()`, -and `removeSpan()` remain the lower-level imperative form. - -React Three Fiber uses the same composer internally: - -```tsx - - Fast accurate text - -``` - -The nested React form and `txt` literal above must produce the same source string and span ranges. A nested React `` -is inline paragraph data; `label.add(new Text(...))` remains an ordinary spatial Three child and a separate paragraph. - -Three-native state remains Three-native: - -```ts -label.position.x += 1; -label.visible = false; -label.layers.set(2); -label.renderOrder = 10; -``` - -Transforms never reshape. `Text.renderOrder` maps to the paragraph ordering value inside the effective batch. Visibility, -layers, and transform changes update instance visibility/transform storage without changing shaping. `TextGroup.renderOrder` -sets the secondary Three render-order base for the batch's ordered physical draws. The nearest real Three Group owns -their primary `groupOrder`. - -## Structural, rebuilding, and hot changes - -### Construction-only batch identity - -Technique defines compatibility and has no setter: - -```ts -new TextGroup({ - technique, // canonical instance layout and shader family - program, // accepted variant type, attributes, material/pipeline, and draw compiler - capacity, // initial physical glyph-buffer size and overflow policy +const emoji = await loader.loadAsync({ + input: { baked: '/fonts/emoji-slug.font.glb' }, + raster: { technique: slug, options: {} }, }); -``` - -Changing technique or program requires a new `TextGroup`. Capacity is deliberately mutable through `setCapacity()` because storage -replacement must preserve the group, its text identities, and its core paragraph handles. - -For standalone `Text`, `setCapacity()` changes its implicit batch without changing the public object. Its font selection is -mutable; changing technique rebuilds the implicit batch. Inside an explicit `TextGroup`, changing to a different technique -is rejected and requires moving the retained `Text` to a compatible group. -The renderer identity becomes fixed on first draw and is also structural. `renderOrder` remains mutable. - -### Retained changes that rebuild internal storage -These operations retain public objects but may allocate new internal glyph slots, chunks, attributes, or materials: - -```ts -destination.add(text); // remove old paragraph allocation, add new allocation -textGroup.setCapacity(nextCapacity); // preserve handles; replace canonical and target storage transactionally -text.font = anotherFont; // reshape and possibly change physical resource batch -text.spans = nextSpans; // reshape and possibly change raster-resource glyph runs -text.rasterPixelRatio = next; // select resources and rebuild affected target storage -text.renderVariant = nextVariant; // rebuild run/draw compatibility without reshaping -``` - -Glyph overflow follows the owning group's `grow`, `chunk`, or `fixed` policy. All fallible replacement work stages before -publication; failure preserves the last complete revision. - -### Text errors do not escape rendering - -A synchronous core preparation failure is caught by the Three adapter before it can escape `renderer.render()`. An -asynchronous failure enters the same adapter state. The owner is the effective `TextGroup`, or the standalone `Text` for an -implicit batch: - -```ts -labels.onError = (error) => { - if (error.kind === 'capacity-exceeded') { - console.error(`Text needs ${error.required} glyph slots; the fixed limit is ${error.capacity}.`); - } -}; - -renderer.render(scene, camera); -labels.error; // typed preparation or target failure, or undefined after a successful revision -``` - -The integration sets `error` during synchronization and defers `onError` until after the active Three traversal. Core -preparation failure or retained `attachment.error` preserves the last complete target revision; a first-render failure -submits nothing for that owner. The failed desired generation stays retained, but Three does not retry an identical failure -every frame. A relevant text, font, content-box, membership, or explicit `setCapacity()` change schedules new core work; -`retry()` requests one explicit attempt against unchanged state. Successful publication clears `error`. Capacity recovery -means resizing explicitly, reducing demand, or removing or moving text. No failure can partially publish or escape the -render call. - -While a `Text` is grouped, the group is the synchronization owner: read `text.textGroup.error` and use the group's callback. -The `Text` properties report and observe only its implicit standalone batch and are inactive while grouped. One failed -generation schedules one deferred callback, not one callback per render frame. `text.retry()` delegates to that effective -group while grouped and to the retained implicit attachment while standalone; `group.retry()` retries only that group's -attachment. - -### Hot retained changes - -These never recreate the `Text` or `TextGroup`: - -```ts -text.text = nextText; -text.contentBox = nextContentBox; -text.style = nextStyle; -text.paint = nextPaint; -text.renderOrder = nextOrder; -text.position.copy(nextPosition); -text.visible = nextVisible; -text.setGlyphOrigins(nextOrigins); +const font = createFontStack(prose, emoji); +const label = new Text({ font, text: 'Status 🌍' }); ``` -Dirty channels determine whether the hidden update shapes, reflows, rewrites paint/origins/transforms, or only rebuilds the -glyph-run plan. +The font stack carries resource and technique identity. The user-facing text API does not repeat a technique selector. +Rust resolves missing-glyph fallback, and the render plan partitions the selected glyphs by the capabilities and resources +declared by the active Three policy. -## Manual glyph motion +## Directed glyph presentation ```ts -const snapshot = label.snapshotGlyphs(); -const x = snapshot.displayedX.slice(); -const y = snapshot.displayedY.slice(); - -simulateGlyphs(x, y, delta); - -label.setGlyphOrigins({ - topology: snapshot.topology, - start: 0, - x, - y, -}); -``` - -Clear overrides to return to shaped positions: - -```ts -label.clearGlyphOriginOverrides(); -``` - -The next Three render-loop synchronization writes the changed origins without reshaping. Later content changes may reshape -the authoritative targets; the application can snapshot again and interpolate from its current displayed values. - -## Dispose ownership explicitly - -```ts -label.dispose(); -worldText.dispose(); -inter.dispose(); -noto.dispose(); -loader.dispose(); +const snapshot = label.snapshotGlyphOrigins(); +if (snapshot !== undefined) { + const x = snapshot.shapedX.slice(); + x[0] += 4; + label.setGlyphOrigins({ layout: snapshot.layout, x, y: snapshot.shapedY }); +} ``` -`remove()` changes membership; `dispose()` ends ownership. Use the explicit destroy sequence when a text will never be -reused: - -```ts -label.removeFromParent(); -label.dispose(); -``` +Origin overrides are presentation-only. They use stable glyph identities from the inspected layout and never mutate +authoritative Rust shaping or layout. A semantic text/style/geometry revision retires incompatible overrides. +`clearGlyphOriginOverrides()` restores shaped positions. -`Text.dispose()` is idempotent and permanent. It releases the current core paragraph membership, renderer-neutral cached -state, and any implicit standalone batch and target. It does not dispose explicit-group buffers or loaded fonts, and it -does not mutate the caller-owned scene graph; a disposed object still parented in Three is skipped but remains referenced -until the caller removes it. When grouped, disposal stages the same old-membership cleanup as `remove()` and the group -publishes that cleanup before its next render. When already detached and unbound, disposal still cancels pending work, -clears retained shaping/layout state and font references, marks the object permanently disposed, and prevents future -attachment. Mutating or adding a disposed `Text` throws. +## Ownership and disposal -`TextGroup` owns its hidden paragraph batch, canonical batch storage, renderer-specific targets, materials, attributes, -and subscriptions. Removing a child only frees/recycles logical slots inside those shared resources. `TextGroup.dispose()` -permanently releases the group-owned resources, but does not dispose or remove child `Text` objects; callers may remove -those retained children and add them to a live compatible group. A disposed group rejects text attachment and cannot be -reactivated. +- `Text.dispose()` unbinds the object and releases its font leases. +- `TextGroup.dispose()` releases the group session and GPU resources but does not dispose descendant `Text` objects. +- `LoadedFont.dispose()` releases font and raster resources after all text leases are gone. +- `FontLoader.dispose()` releases its claim on the manager-scoped runtime. -`LoadedFont.dispose()` fails while any live `Text` lease remains. After the final text is disposed or changes font, font -disposal releases that loaded-font ownership. `FontStack` itself owns no lifecycle and cannot keep a disposed concrete font -valid. `FontLoader.dispose()` releases its cache-domain ownership; loaded fonts and their shared shaping state remain valid -until their own final owners are gone. +Dispose text objects before their loaded fonts. A disposed `TextGroup` can be removed while its still-live `Text` children +are moved into another group. -Renderer-specific GPU resources retire according to the renderer target's in-flight-frame rules. Disposal is idempotent. +## React Three Fiber -## Required conformance cases +`@pmndrs/text/r3f` exports ``, ``, and `useFont`. Components preserve the Three ownership and batching +semantics above. Nested R3F `` values flatten into formatted spans; an outer text requires a font, while nested spans +may override it. The maintained renderer target is `@react-three/fiber/webgpu`, which inherits Three's WebGL fallback. -The implementation is not complete until tests prove: +## Deliberately absent surfaces -- an unattached `Text` performs no shaping or GPU allocation; -- direct `scene.add(text)` renders through an implicit batch of one on its first render; -- `TextGroup` exposes no duplicate creation or allocation shortcut; `new Text()` plus ordinary `add()` is the only explicit-group path; -- direct and nested descendants join the nearest `TextGroup`, while nested `TextGroup` boundaries do not merge; -- a detached `Text` owns desired state but no paragraph batch or GPU resources, and direct scene attachment creates its implicit batch before first render; -- add/remove/reparent events plus pre-render ancestry reconciliation cannot leave stale or duplicate membership; -- moving a `Text` performs an atomic old allocation removal and new allocation creation without ghost glyphs; -- removing one text recycles its slots without shrinking or disposing shared group buffers; -- disposing a populated group unbinds but does not dispose its direct or nested text, and each retained text can bind to a live compatible group; -- text left parented below a disposed group remains unbound and cannot fall through to an outer group or implicit standalone batch; -- disposed text rejects mutation and attachment, text disposal does not dispose group/font resources, and group disposal does not dispose child text/fonts; -- font disposal fails while paragraph or text leases remain, and group disposal or reparenting cannot create missing glyphs by releasing font data; -- fixed capacity is checked after shaping rather than by `add()`, preserves the last complete revision, never throws from Three traversal, reports once, and retries only after a relevant change; -- `setCapacity()` preserves the group, every public `Text`, every core paragraph handle, and existing target attachments while replacing canonical and GPU storage transactionally; -- `TextGroup.clone()` and `copy()` are rejected rather than silently duplicating identity-bearing text, listener, and renderer state; -- simultaneous scene placements use separate groups, ordinary reparenting can move one group between scenes, and attempting - to draw one group through a second renderer fails before encoding; -- construction-only incompatibilities fail without mutating the current group; -- runtime setters coalesce and select the narrowest dirty work; -- automatic synchronous preparation renders warm edits in the observing frame; -- a same-technique `FontStack` produces the core-authored minimum physical batches and exact ordered glyph runs; -- mixed-technique group additions and font stacks fail before shaping without replacing live text; -- font-bound, font-stack-bound, style-only, reusable-tag, and readonly-tuple `span()` forms normalize identically, while mixed-technique format lists fail; -- `txt`/`span`, explicit spans, and nested React `` produce the same UTF-16 source/span snapshot; -- WebGPU and forced WebGL2 execute the same Bitmap, MTSDF, and Slug behavior on Three.js 0.185.1. +There is no effects/variant API, JavaScript layout callback, public TypeGPU batch, or user-authored command parser in this +surface. Custom visual behavior uses `material`; renderer-directed batching uses the compiled Rust policy and render plan. +TypeGPU will be rebuilt against that plan in a later stack. diff --git a/packages/text/package.json b/packages/text/package.json index b517f06b..5ce13c33 100644 --- a/packages/text/package.json +++ b/packages/text/package.json @@ -40,10 +40,6 @@ "types": "./dist/r3f.d.ts", "import": "./dist/r3f.js" }, - "./typegpu": { - "types": "./dist/typegpu.d.ts", - "import": "./dist/typegpu.js" - }, "./raster/bitmap": { "types": "./dist/raster/bitmap-technique.d.ts", "import": "./dist/raster/bitmap-technique.js" @@ -124,14 +120,12 @@ "oxlint": "1.75.0", "react": "19.2.8", "three": "0.185.1", - "typegpu": "0.11.9", "unicode-property-value-aliases": "3.9.0" }, "peerDependencies": { "@react-three/fiber": ">=10.0.0-alpha.2 <11", "react": ">=19.2 <19.3", - "three": ">=0.185.1", - "typegpu": ">=0.11.9 <0.12" + "three": ">=0.185.1" }, "peerDependenciesMeta": { "@react-three/fiber": { @@ -139,9 +133,6 @@ }, "react": { "optional": true - }, - "typegpu": { - "optional": true } }, "pmndrs": { diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index c9f28c21..9a7282ab 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -57,33 +57,6 @@ pub const HARFRUST_VERSION: &str = "0.12.0"; pub const HARFRUST_COMMIT: &str = "60b28ea22b5261710018d69c168a762bcb28794c"; pub const UNICODE_VERSION: &str = "17.0.0"; -#[repr(C)] -struct ShapeRequestHeader { - text_offset: u32, - text_length: u32, - runs_offset: u32, - run_count: u32, - features_offset: u32, - feature_count: u32, - languages_offset: u32, - languages_length: u32, -} - -#[repr(C)] -struct ReshapeRequestHeader { - shape: ShapeRequestHeader, - ranges_offset: u32, - range_count: u32, -} - -#[repr(C)] -struct BidiRequestHeader { - text_offset: u32, - text_length: u32, - direction: u8, - reserved: [u8; 3], -} - #[repr(C)] struct PolicyRequestHeader { byte_length: u32, @@ -444,61 +417,6 @@ struct FeatureRecord { end: u32, } -#[repr(C)] -struct RunRecord { - font_handle: u32, - text_start: u32, - text_end: u32, - script: u32, - language_offset: u32, - feature_start: u32, - feature_count: u16, - direction: u8, - cluster_level: u8, - flags: u32, -} - -#[repr(C)] -struct ReshapeRangeRecord { - run: u32, - item_start: u32, - item_end: u32, - context_start: u32, - context_end: u32, - flags: u32, -} - -#[repr(C)] -struct ResultHeader { - byte_length: u32, - font_handles_offset: u32, - font_handle_count: u32, - run_font_slots_offset: u32, - run_glyph_starts_offset: u32, - run_glyph_counts_offset: u32, - run_count: u32, - glyph_ids_offset: u32, - clusters_offset: u32, - x_advances_offset: u32, - y_advances_offset: u32, - x_offsets_offset: u32, - y_offsets_offset: u32, - glyph_flags_offset: u32, - glyph_count: u32, -} - -#[repr(C)] -struct BidiResultHeader { - byte_length: u32, - levels_offset: u32, - classes_offset: u32, - text_length: u32, - paragraph_starts_offset: u32, - paragraph_ends_offset: u32, - paragraph_levels_offset: u32, - paragraph_count: u32, -} - macro_rules! layout { ($size:ident, $alignment:ident, $type:ty) => { pub const $size: u32 = size_of::<$type>() as u32; @@ -506,21 +424,6 @@ macro_rules! layout { }; } -layout!( - SHAPE_REQUEST_HEADER_SIZE, - SHAPE_REQUEST_HEADER_ALIGNMENT, - ShapeRequestHeader -); -layout!( - RESHAPE_REQUEST_HEADER_SIZE, - RESHAPE_REQUEST_HEADER_ALIGNMENT, - ReshapeRequestHeader -); -layout!( - BIDI_REQUEST_HEADER_SIZE, - BIDI_REQUEST_HEADER_ALIGNMENT, - BidiRequestHeader -); layout!( POLICY_REQUEST_HEADER_SIZE, POLICY_REQUEST_HEADER_ALIGNMENT, @@ -645,18 +548,6 @@ layout!( DiagnosticRecord ); layout!(FEATURE_RECORD_SIZE, FEATURE_RECORD_ALIGNMENT, FeatureRecord); -layout!(RUN_RECORD_SIZE, RUN_RECORD_ALIGNMENT, RunRecord); -layout!( - RESHAPE_RANGE_RECORD_SIZE, - RESHAPE_RANGE_RECORD_ALIGNMENT, - ReshapeRangeRecord -); -layout!(RESULT_HEADER_SIZE, RESULT_HEADER_ALIGNMENT, ResultHeader); -layout!( - BIDI_RESULT_HEADER_SIZE, - BIDI_RESULT_HEADER_ALIGNMENT, - BidiResultHeader -); macro_rules! field_offset { ($name:ident, $type:ty, $field:ident) => { @@ -664,19 +555,6 @@ macro_rules! field_offset { }; } -field_offset!(SHAPE_TEXT_OFFSET, ShapeRequestHeader, text_offset); -field_offset!(SHAPE_TEXT_LENGTH, ShapeRequestHeader, text_length); -field_offset!(SHAPE_RUNS_OFFSET, ShapeRequestHeader, runs_offset); -field_offset!(SHAPE_RUN_COUNT, ShapeRequestHeader, run_count); -field_offset!(SHAPE_FEATURES_OFFSET, ShapeRequestHeader, features_offset); -field_offset!(SHAPE_FEATURE_COUNT, ShapeRequestHeader, feature_count); -field_offset!(SHAPE_LANGUAGES_OFFSET, ShapeRequestHeader, languages_offset); -field_offset!(SHAPE_LANGUAGES_LENGTH, ShapeRequestHeader, languages_length); -field_offset!(RESHAPE_RANGES_OFFSET, ReshapeRequestHeader, ranges_offset); -field_offset!(RESHAPE_RANGE_COUNT, ReshapeRequestHeader, range_count); -field_offset!(BIDI_TEXT_OFFSET, BidiRequestHeader, text_offset); -field_offset!(BIDI_TEXT_LENGTH, BidiRequestHeader, text_length); -field_offset!(BIDI_DIRECTION, BidiRequestHeader, direction); field_offset!(POLICY_BYTE_LENGTH, PolicyRequestHeader, byte_length); field_offset!( POLICY_CAPABILITY_SETS_OFFSET, @@ -1880,77 +1758,6 @@ field_offset!(FEATURE_TAG, FeatureRecord, tag); field_offset!(FEATURE_VALUE, FeatureRecord, value); field_offset!(FEATURE_START, FeatureRecord, start); field_offset!(FEATURE_END, FeatureRecord, end); -field_offset!(RUN_FONT_HANDLE, RunRecord, font_handle); -field_offset!(RUN_TEXT_START, RunRecord, text_start); -field_offset!(RUN_TEXT_END, RunRecord, text_end); -field_offset!(RUN_SCRIPT, RunRecord, script); -field_offset!(RUN_LANGUAGE_OFFSET, RunRecord, language_offset); -field_offset!(RUN_FEATURE_START, RunRecord, feature_start); -field_offset!(RUN_FEATURE_COUNT, RunRecord, feature_count); -field_offset!(RUN_DIRECTION, RunRecord, direction); -field_offset!(RUN_CLUSTER_LEVEL, RunRecord, cluster_level); -field_offset!(RUN_FLAGS, RunRecord, flags); -field_offset!(RANGE_RUN, ReshapeRangeRecord, run); -field_offset!(RANGE_ITEM_START, ReshapeRangeRecord, item_start); -field_offset!(RANGE_ITEM_END, ReshapeRangeRecord, item_end); -field_offset!(RANGE_CONTEXT_START, ReshapeRangeRecord, context_start); -field_offset!(RANGE_CONTEXT_END, ReshapeRangeRecord, context_end); -field_offset!(RANGE_FLAGS, ReshapeRangeRecord, flags); -field_offset!(RESULT_BYTE_LENGTH, ResultHeader, byte_length); -field_offset!( - RESULT_FONT_HANDLES_OFFSET, - ResultHeader, - font_handles_offset -); -field_offset!(RESULT_FONT_HANDLE_COUNT, ResultHeader, font_handle_count); -field_offset!( - RESULT_RUN_FONT_SLOTS_OFFSET, - ResultHeader, - run_font_slots_offset -); -field_offset!( - RESULT_RUN_GLYPH_STARTS_OFFSET, - ResultHeader, - run_glyph_starts_offset -); -field_offset!( - RESULT_RUN_GLYPH_COUNTS_OFFSET, - ResultHeader, - run_glyph_counts_offset -); -field_offset!(RESULT_RUN_COUNT, ResultHeader, run_count); -field_offset!(RESULT_GLYPH_IDS_OFFSET, ResultHeader, glyph_ids_offset); -field_offset!(RESULT_CLUSTERS_OFFSET, ResultHeader, clusters_offset); -field_offset!(RESULT_X_ADVANCES_OFFSET, ResultHeader, x_advances_offset); -field_offset!(RESULT_Y_ADVANCES_OFFSET, ResultHeader, y_advances_offset); -field_offset!(RESULT_X_OFFSETS_OFFSET, ResultHeader, x_offsets_offset); -field_offset!(RESULT_Y_OFFSETS_OFFSET, ResultHeader, y_offsets_offset); -field_offset!(RESULT_GLYPH_FLAGS_OFFSET, ResultHeader, glyph_flags_offset); -field_offset!(RESULT_GLYPH_COUNT, ResultHeader, glyph_count); -field_offset!(BIDI_RESULT_BYTE_LENGTH, BidiResultHeader, byte_length); -field_offset!(BIDI_RESULT_LEVELS_OFFSET, BidiResultHeader, levels_offset); -field_offset!(BIDI_RESULT_CLASSES_OFFSET, BidiResultHeader, classes_offset); -field_offset!(BIDI_RESULT_TEXT_LENGTH, BidiResultHeader, text_length); -field_offset!( - BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - BidiResultHeader, - paragraph_starts_offset -); -field_offset!( - BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - BidiResultHeader, - paragraph_ends_offset -); -field_offset!( - BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, - BidiResultHeader, - paragraph_levels_offset -); -field_offset!( - BIDI_RESULT_PARAGRAPH_COUNT, - BidiResultHeader, - paragraph_count -); pub fn json() -> String { json!({ @@ -1989,39 +1796,9 @@ pub fn json() -> String { "sessionCount": "pmndrs_text_engine_session_count", "requestPointer": "pmndrs_text_engine_request_ptr", "requestCapacity": "pmndrs_text_engine_request_capacity", - "textUpdate": "pmndrs_text_engine_update", - "shapeBatch": "pmndrs_text_shaper_shape_batch", - "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", - "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", - "resultPointer": "pmndrs_text_shaper_result_ptr", - "resultLength": "pmndrs_text_shaper_result_len" + "textUpdate": "pmndrs_text_engine_update" }, "layouts": { - "shapeRequest": { - "size": SHAPE_REQUEST_HEADER_SIZE, - "alignment": SHAPE_REQUEST_HEADER_ALIGNMENT, - "textOffset": SHAPE_TEXT_OFFSET, - "textLength": SHAPE_TEXT_LENGTH, - "runsOffset": SHAPE_RUNS_OFFSET, - "runCount": SHAPE_RUN_COUNT, - "featuresOffset": SHAPE_FEATURES_OFFSET, - "featureCount": SHAPE_FEATURE_COUNT, - "languagesOffset": SHAPE_LANGUAGES_OFFSET, - "languagesLength": SHAPE_LANGUAGES_LENGTH - }, - "reshapeRequest": { - "size": RESHAPE_REQUEST_HEADER_SIZE, - "alignment": RESHAPE_REQUEST_HEADER_ALIGNMENT, - "rangesOffset": RESHAPE_RANGES_OFFSET, - "rangeCount": RESHAPE_RANGE_COUNT - }, - "bidiRequest": { - "size": BIDI_REQUEST_HEADER_SIZE, - "alignment": BIDI_REQUEST_HEADER_ALIGNMENT, - "textOffset": BIDI_TEXT_OFFSET, - "textLength": BIDI_TEXT_LENGTH, - "direction": BIDI_DIRECTION - }, "policyRequest": { "size": POLICY_REQUEST_HEADER_SIZE, "alignment": POLICY_REQUEST_HEADER_ALIGNMENT, @@ -2509,71 +2286,6 @@ pub fn json() -> String { "start": FEATURE_START, "end": FEATURE_END }, - "run": { - "size": RUN_RECORD_SIZE, - "alignment": RUN_RECORD_ALIGNMENT, - "fontHandle": RUN_FONT_HANDLE, - "textStart": RUN_TEXT_START, - "textEnd": RUN_TEXT_END, - "script": RUN_SCRIPT, - "languageOffset": RUN_LANGUAGE_OFFSET, - "featureStart": RUN_FEATURE_START, - "featureCount": RUN_FEATURE_COUNT, - "direction": RUN_DIRECTION, - "clusterLevel": RUN_CLUSTER_LEVEL, - "flags": RUN_FLAGS - }, - "reshapeRange": { - "size": RESHAPE_RANGE_RECORD_SIZE, - "alignment": RESHAPE_RANGE_RECORD_ALIGNMENT, - "run": RANGE_RUN, - "itemStart": RANGE_ITEM_START, - "itemEnd": RANGE_ITEM_END, - "contextStart": RANGE_CONTEXT_START, - "contextEnd": RANGE_CONTEXT_END, - "flags": RANGE_FLAGS - }, - "result": { - "size": RESULT_HEADER_SIZE, - "alignment": RESULT_HEADER_ALIGNMENT, - "byteLength": RESULT_BYTE_LENGTH, - "fontHandlesOffset": RESULT_FONT_HANDLES_OFFSET, - "fontHandleCount": RESULT_FONT_HANDLE_COUNT, - "runFontSlotsOffset": RESULT_RUN_FONT_SLOTS_OFFSET, - "runGlyphStartsOffset": RESULT_RUN_GLYPH_STARTS_OFFSET, - "runGlyphCountsOffset": RESULT_RUN_GLYPH_COUNTS_OFFSET, - "runCount": RESULT_RUN_COUNT, - "glyphIdsOffset": RESULT_GLYPH_IDS_OFFSET, - "clustersOffset": RESULT_CLUSTERS_OFFSET, - "xAdvancesOffset": RESULT_X_ADVANCES_OFFSET, - "yAdvancesOffset": RESULT_Y_ADVANCES_OFFSET, - "xOffsetsOffset": RESULT_X_OFFSETS_OFFSET, - "yOffsetsOffset": RESULT_Y_OFFSETS_OFFSET, - "glyphFlagsOffset": RESULT_GLYPH_FLAGS_OFFSET, - "glyphCount": RESULT_GLYPH_COUNT - }, - "bidiResult": { - "size": BIDI_RESULT_HEADER_SIZE, - "alignment": BIDI_RESULT_HEADER_ALIGNMENT, - "byteLength": BIDI_RESULT_BYTE_LENGTH, - "levelsOffset": BIDI_RESULT_LEVELS_OFFSET, - "classesOffset": BIDI_RESULT_CLASSES_OFFSET, - "textLength": BIDI_RESULT_TEXT_LENGTH, - "paragraphStartsOffset": BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - "paragraphEndsOffset": BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - "paragraphLevelsOffset": BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, - "paragraphCount": BIDI_RESULT_PARAGRAPH_COUNT - } - }, - "bidi": { - "directions": { "auto": 0, "ltr": 1, "rtl": 2 }, - "classes": { - "L": 0, "R": 1, "AL": 2, "EN": 3, "ES": 4, "ET": 5, - "AN": 6, "CS": 7, "NSM": 8, "BN": 9, "B": 10, "S": 11, - "WS": 12, "ON": 13, "LRE": 14, "LRO": 15, "RLE": 16, - "RLO": 17, "PDF": 18, "LRI": 19, "RLI": 20, "FSI": 21, - "PDI": 22 - } }, "policy": { "capabilityFlags": { diff --git a/packages/text/rust/shaper/src/engine/flow_geometry.rs b/packages/text/rust/shaper/src/engine/flow_geometry.rs index 1b7dfdfb..75914b35 100644 --- a/packages/text/rust/shaper/src/engine/flow_geometry.rs +++ b/packages/text/rust/shaper/src/engine/flow_geometry.rs @@ -36,7 +36,7 @@ pub(crate) struct RetainedExclusion { pub vertex_start: u32, } -#[derive(Default, PartialEq)] +#[derive(Clone, Default, PartialEq)] pub(crate) struct FlowGeometryArena { pub constraints: Vec, pub regions: Vec, diff --git a/packages/text/rust/shaper/src/engine/layout_query.rs b/packages/text/rust/shaper/src/engine/layout_query.rs index 03dd1168..d55973ef 100644 --- a/packages/text/rust/shaper/src/engine/layout_query.rs +++ b/packages/text/rust/shaper/src/engine/layout_query.rs @@ -7,10 +7,11 @@ use alloc::vec::Vec; use super::{ EngineError, + cluster_state::ClusterArena, flow_composition::{FlowFragment, FlowLayoutArena, FlowLine}, flow_geometry::FlowGeometryArena, frame::{AXIS_AT_MOST, AXIS_EXACT, AXIS_UNCONSTRAINED}, - positioning::SemanticGlyph, + positioning::{SemanticGlyph, positioned_fragment_advance}, semantic_view::{ SEMANTIC_GLYPH, SEMANTIC_LINE, SEMANTIC_PARAGRAPH_MEASUREMENT, SemanticRecord, }, @@ -18,6 +19,13 @@ use super::{ pub(crate) const MEASUREMENT_FLAG_OVERFLOWED: u16 = 1; +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub(crate) struct LayoutExtents { + pub width: f64, + pub height: f64, + pub consumed_clusters: usize, +} + pub(crate) fn append_measurement( target: &mut Vec, paragraph_id: u32, @@ -28,6 +36,10 @@ pub(crate) fn append_measurement( positioned_glyphs: &[SemanticGlyph], semantic_line_glyph_starts: &[u32], semantic_line_glyph_counts: &[u32], + semantic_line_inline_extents: Option<&[f64]>, + text: &[u16], + clusters: &ClusterArena, + intrinsic_extents: Option, include_glyphs: bool, ) -> Result<(), EngineError> { let constraint = geometry @@ -37,8 +49,10 @@ pub(crate) fn append_measurement( if constraint.paragraph_id != paragraph_id { return Err(EngineError::InvalidRequest); } - if semantic_line_glyph_starts.len() != flow.lines.len() - || semantic_line_glyph_counts.len() != flow.lines.len() + if include_glyphs + && (semantic_line_glyph_starts.len() != flow.lines.len() + || semantic_line_glyph_counts.len() != flow.lines.len()) + || semantic_line_inline_extents.is_some_and(|extents| extents.len() != flow.lines.len()) { return Err(EngineError::InvalidRequest); } @@ -87,15 +101,11 @@ pub(crate) fn append_measurement( let Some(last) = fragments.last() else { continue; }; - let inline_start = fragments - .iter() - .map(|fragment| fragment.slot_start) - .fold(f64::INFINITY, f64::min); - let inline_end = fragments - .iter() - .map(|fragment| fragment.slot_start + fragment.line.advance) - .fold(f64::NEG_INFINITY, f64::max); - let advance = (inline_end - inline_start).max(0.0); + let advance = if let Some(extents) = semantic_line_inline_extents { + extents[index] + } else { + line_inline_extent(flow, line, index, text, clusters)? + }; content_width = content_width.max(advance); content_height = content_height.max(line.block_start + line.height); consumed_clusters = consumed_clusters @@ -147,11 +157,23 @@ pub(crate) fn append_measurement( } } + let intrinsic = intrinsic_extents.unwrap_or(LayoutExtents { + width: content_width, + height: content_height, + consumed_clusters, + }); + let full_content_width = content_width.max(intrinsic.width); + let full_content_height = content_height.max(intrinsic.height); let width = resolve_axis(constraint.width_mode, constraint.width, content_width)?; let height = resolve_axis(constraint.height_mode, constraint.height, content_height)?; let overflowed = consumed_clusters < cluster_count - || content_width > f64::from(width) - || content_height > f64::from(height); + || intrinsic.consumed_clusters < cluster_count + || axis_overflowed(constraint.width_mode, constraint.width, full_content_width)? + || axis_overflowed( + constraint.height_mode, + constraint.height, + full_content_height, + )?; let missing_glyph_count = positioned_glyphs .iter() .filter(|glyph| glyph.glyph_id == 0) @@ -172,13 +194,71 @@ pub(crate) fn append_measurement( item_count: u32::try_from(line_count).map_err(|_| EngineError::ResultTooLarge)?, inline_start: width, block_start: height, - inline_extent: finite_nonnegative_f32(content_width)?, - block_extent: finite_nonnegative_f32(content_height)?, + inline_extent: finite_nonnegative_f32(full_content_width)?, + block_extent: finite_nonnegative_f32(full_content_height)?, ..SemanticRecord::default() }; Ok(()) } +pub(crate) fn flow_extents( + flow_thread_id: u32, + flow: &FlowLayoutArena, + text: &[u16], + clusters: &ClusterArena, +) -> Result { + let mut extents = LayoutExtents::default(); + for (index, line) in flow.lines.iter().copied().enumerate() { + if line.flow_thread_id != flow_thread_id { + continue; + } + let fragments = line_fragments(flow, line)?; + if fragments.is_empty() { + continue; + } + let Some(last) = fragments.last() else { + continue; + }; + extents.width = extents + .width + .max(line_inline_extent(flow, line, index, text, clusters)?); + extents.height = extents.height.max(line.block_start + line.height); + extents.consumed_clusters = extents + .consumed_clusters + .max(usize::try_from(last.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?); + } + Ok(extents) +} + +fn line_inline_extent( + flow: &FlowLayoutArena, + line: FlowLine, + index: usize, + text: &[u16], + clusters: &ClusterArena, +) -> Result { + let fragments = line_fragments(flow, line)?; + if fragments.is_empty() { + return Ok(0.0); + } + let final_line = flow + .lines + .get(index + 1) + .is_none_or(|next| next.flow_thread_id != line.flow_thread_id); + let inline_start = fragments + .iter() + .map(|fragment| fragment.slot_start) + .fold(f64::INFINITY, f64::min); + let mut inline_end = f64::NEG_INFINITY; + for fragment in fragments.iter().copied() { + inline_end = inline_end.max( + fragment.slot_start + + positioned_fragment_advance(line, fragment, final_line, text, clusters)?, + ); + } + Ok((inline_end - inline_start).max(0.0)) +} + fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; let end = start @@ -198,6 +278,14 @@ fn resolve_axis(mode: u8, requested: f32, content: f64) -> Result Result { + match mode { + AXIS_UNCONSTRAINED => Ok(false), + AXIS_AT_MOST | AXIS_EXACT => Ok(content > f64::from(requested)), + _ => Err(EngineError::InvalidRequest), + } +} + fn finite_f32(value: f64) -> Result { let narrowed = value as f32; if narrowed.is_finite() { @@ -269,6 +357,10 @@ mod tests { &positioned, &[0], &[2], + Some(&[7.0]), + &[], + &ClusterArena::default(), + None, false, ) .unwrap(); @@ -301,6 +393,10 @@ mod tests { &positioned, &[0], &[2], + Some(&[7.0]), + &[], + &ClusterArena::default(), + None, true, ) .unwrap(); @@ -314,7 +410,7 @@ mod tests { } #[test] - fn constrained_measurement_reports_content_overflow_independently_of_box_size() { + fn constrained_measurement_preserves_demanded_intrinsic_content_extents() { let geometry = FlowGeometryArena { constraints: vec![constraint(AXIS_AT_MOST, 6.0, AXIS_AT_MOST, 4.0)], ..FlowGeometryArena::default() @@ -358,6 +454,14 @@ mod tests { &[], &[0], &[0], + Some(&[7.0]), + &[], + &ClusterArena::default(), + Some(LayoutExtents { + width: 9.0, + height: 8.0, + consumed_clusters: 2, + }), false, ) .unwrap(); @@ -365,8 +469,66 @@ mod tests { assert_eq!(records[0].flags, MEASUREMENT_FLAG_OVERFLOWED); assert_eq!(records[0].inline_start, 6.0); assert_eq!(records[0].block_start, 4.0); - assert_eq!(records[0].inline_extent, 7.0); - assert_eq!(records[0].block_extent, 5.0); + assert_eq!(records[0].inline_extent, 9.0); + assert_eq!(records[0].block_extent, 8.0); + } + + #[test] + fn unconstrained_content_does_not_overflow_after_single_precision_publication() { + let geometry = FlowGeometryArena { + constraints: vec![constraint(AXIS_UNCONSTRAINED, 0.0, AXIS_UNCONSTRAINED, 0.0)], + ..FlowGeometryArena::default() + }; + let flow = FlowLayoutArena { + lines: vec![FlowLine { + flow_thread_id: 11, + region_id: 3, + transform_index: 1, + clip_id: 0, + fragment_start: 0, + fragment_count: 1, + align: 1, + block_start: 0.0, + baseline: 100.0, + height: 140.64, + }], + fragments: vec![FlowFragment { + line: ComposedLine { + cluster_start: 0, + cluster_end: 1, + text_start: 0, + text_end: 1, + advance: 140.64, + hard_break: false, + }, + slot_start: 0.0, + slot_end: 140.64, + boundary_index: NO_BOUNDARY, + }], + ..FlowLayoutArena::default() + }; + let mut records = vec![]; + append_measurement( + &mut records, + 7, + 1, + 1, + &geometry, + &flow, + &[], + &[0], + &[0], + Some(&[140.64]), + &[], + &ClusterArena::default(), + None, + false, + ) + .unwrap(); + + assert_eq!(records[0].flags, 0); + assert_eq!(records[0].inline_start, 140.64_f32); + assert_eq!(records[0].block_start, 140.64_f32); } fn constraint(width_mode: u8, width: f32, height_mode: u8, height: f32) -> FlowConstraint { diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index 07a23bfe..df185813 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -52,6 +52,7 @@ pub(crate) struct PositionedGlyphArena { semantic_glyphs: Vec, semantic_line_glyph_starts: Vec, semantic_line_glyph_counts: Vec, + semantic_line_inline_extents: Vec, semantic_change_masks: Vec, semantic_f32: [Vec; SEMANTIC_F32_FIELD_COUNT], semantic_u32: [Vec; SEMANTIC_U32_FIELD_COUNT], @@ -97,6 +98,7 @@ impl PositionedGlyphArena { self.reserve(shape.glyph_ids.len())?; reserve(&mut self.semantic_line_glyph_starts, flow.lines.len())?; reserve(&mut self.semantic_line_glyph_counts, flow.lines.len())?; + reserve(&mut self.semantic_line_inline_extents, flow.lines.len())?; let visually_ltr = is_trivially_ltr(bidi, runs); for (line_index, line) in flow.lines.iter().copied().enumerate() { let semantic_line_start = self.semantic_glyphs.len(); @@ -106,6 +108,7 @@ impl PositionedGlyphArena { u32::try_from(semantic_line_start).map_err(|_| EngineError::ResultTooLarge)?, ); self.semantic_line_glyph_counts.push(0); + self.semantic_line_inline_extents.push(0.0); continue; } let first = fragments.first().ok_or(EngineError::InvalidRequest)?; @@ -122,8 +125,13 @@ impl PositionedGlyphArena { .lines .get(line_index + 1) .is_none_or(|next| next.flow_thread_id != line.flow_thread_id); + let inline_start = fragments + .iter() + .map(|fragment| fragment.slot_start) + .fold(f64::INFINITY, f64::min); + let mut inline_end = f64::NEG_INFINITY; for fragment in fragments.iter().copied() { - self.position_fragment( + let fragment_advance = self.position_fragment( line, fragment, final_line, @@ -138,6 +146,7 @@ impl PositionedGlyphArena { metrics_for, extents_for, )?; + inline_end = inline_end.max(fragment.slot_start + fragment_advance); } self.semantic_line_glyph_starts .push(u32::try_from(semantic_line_start).map_err(|_| EngineError::ResultTooLarge)?); @@ -149,6 +158,8 @@ impl PositionedGlyphArena { ) .map_err(|_| EngineError::ResultTooLarge)?, ); + self.semantic_line_inline_extents + .push((inline_end - inline_start).max(0.0)); } self.assign_content_revisions(previous, identity_index, next_content_revision) } @@ -158,6 +169,7 @@ impl PositionedGlyphArena { self.semantic_glyphs.clear(); self.semantic_line_glyph_starts.clear(); self.semantic_line_glyph_counts.clear(); + self.semantic_line_inline_extents.clear(); self.semantic_change_masks.clear(); for field in &mut self.semantic_f32 { field.clear(); @@ -185,6 +197,10 @@ impl PositionedGlyphArena { ) } + pub(crate) fn semantic_line_inline_extents(&self) -> &[f64] { + &self.semantic_line_inline_extents + } + pub(crate) fn semantic_change_masks(&self) -> &[u16] { &self.semantic_change_masks } @@ -213,7 +229,7 @@ impl PositionedGlyphArena { visually_ltr: bool, metrics_for: impl Fn(u32) -> Option + Copy, extents_for: impl Fn(u32, u32) -> Option + Copy, - ) -> Result<(), EngineError> { + ) -> Result { let cluster_start = usize::try_from(fragment.line.cluster_start) .map_err(|_| EngineError::InvalidRequest)?; let cluster_end = @@ -258,17 +274,15 @@ impl PositionedGlyphArena { let available = (fragment.slot_end - fragment.slot_start - fragment.line.advance).max(0.0); let paragraph_level = paragraph_level_at(bidi, fragment.line.text_start); - let justify_spaces = - if line.align == ALIGN_JUSTIFY && !fragment.line.hard_break && !final_line { - count_justification_spaces(text, clusters, cluster_start, cluster_end) - } else { - 0 - }; - let per_space = if justify_spaces == 0 { - 0.0 - } else { - available / f64::from(justify_spaces) - }; + let (justify_spaces, per_space) = justification_adjustment( + line, + fragment, + final_line, + text, + clusters, + cluster_start, + cluster_end, + ); let offset = if per_space == 0.0 { alignment_offset(line.align, paragraph_level, available) } else { @@ -421,7 +435,7 @@ impl PositionedGlyphArena { extents_for, )?; } - Ok(()) + Ok(fragment.line.advance + per_space * f64::from(justify_spaces)) } #[allow(clippy::too_many_arguments)] @@ -922,6 +936,56 @@ fn count_justification_spaces( .unwrap_or(u32::MAX) } +#[allow(clippy::too_many_arguments)] +fn justification_adjustment( + line: FlowLine, + fragment: FlowFragment, + final_line: bool, + text: &[u16], + clusters: &ClusterArena, + cluster_start: usize, + cluster_end: usize, +) -> (u32, f64) { + let spaces = if line.align == ALIGN_JUSTIFY && !fragment.line.hard_break && !final_line { + count_justification_spaces(text, clusters, cluster_start, cluster_end) + } else { + 0 + }; + let available = (fragment.slot_end - fragment.slot_start - fragment.line.advance).max(0.0); + (spaces, justification_space_advance(available, spaces)) +} + +pub(crate) fn positioned_fragment_advance( + line: FlowLine, + fragment: FlowFragment, + final_line: bool, + text: &[u16], + clusters: &ClusterArena, +) -> Result { + let cluster_start = + usize::try_from(fragment.line.cluster_start).map_err(|_| EngineError::InvalidRequest)?; + let cluster_end = + usize::try_from(fragment.line.cluster_end).map_err(|_| EngineError::InvalidRequest)?; + let (spaces, per_space) = justification_adjustment( + line, + fragment, + final_line, + text, + clusters, + cluster_start, + cluster_end, + ); + Ok(fragment.line.advance + per_space * f64::from(spaces)) +} + +fn justification_space_advance(available: f64, space_count: u32) -> f64 { + if space_count == 0 { + 0.0 + } else { + available / f64::from(space_count) + } +} + fn cluster_is_space(text: &[u16], clusters: &ClusterArena, cluster: usize) -> bool { clusters .starts @@ -1008,6 +1072,12 @@ mod tests { assert!(!is_trivially_ltr(&bidi, &[run])); } + #[test] + fn justification_expands_only_lines_with_expandable_spaces() { + assert_eq!(justification_space_advance(22.0, 2), 11.0); + assert_eq!(justification_space_advance(22.0, 0), 0.0); + } + #[test] fn positions_once_and_revisions_only_exact_content_changes() { let text = vec![0x61, 0x62, 0x0a]; diff --git a/packages/text/rust/shaper/src/engine/semantic_wire.rs b/packages/text/rust/shaper/src/engine/semantic_wire.rs index feef0fbe..f5e8cb2d 100644 --- a/packages/text/rust/shaper/src/engine/semantic_wire.rs +++ b/packages/text/rust/shaper/src/engine/semantic_wire.rs @@ -1436,6 +1436,7 @@ fn validate_regions( { let id = read_u32(record, abi::ENGINE_REGION_ID)?; let transform_index = read_u32(record, abi::ENGINE_REGION_TRANSFORM_INDEX)?; + let shape = byte(record, abi::ENGINE_REGION_SHAPE)?; if id == 0 || transform_index == 0 || prior_u32_duplicate( @@ -1458,14 +1459,14 @@ fn validate_regions( { return Err(STATUS_INVALID_REQUEST); } - let region_bounds = bounds( + let region_bounds = nonempty_block_bounds( record, abi::ENGINE_REGION_INLINE_START, abi::ENGINE_REGION_BLOCK_START, abi::ENGINE_REGION_INLINE_END, abi::ENGINE_REGION_BLOCK_END, )?; - let clip = bounds( + let clip = nonempty_block_bounds( record, abi::ENGINE_REGION_CLIP_INLINE_START, abi::ENGINE_REGION_CLIP_BLOCK_START, @@ -1476,6 +1477,7 @@ fn validate_regions( || clip.1 < region_bounds.1 || clip.2 > region_bounds.2 || clip.3 > region_bounds.3 + || (region_bounds.0 == region_bounds.2 && shape != SHAPE_RECTANGLE) { return Err(STATUS_INVALID_REQUEST); } @@ -1668,6 +1670,26 @@ fn bounds( } } +fn nonempty_block_bounds( + record: &[u8], + inline_start: usize, + block_start: usize, + inline_end: usize, + block_end: usize, +) -> Result<(f32, f32, f32, f32), u32> { + let values = ( + finite(record, inline_start)?, + finite(record, block_start)?, + finite(record, inline_end)?, + finite(record, block_end)?, + ); + if values.0 > values.2 || values.1 >= values.3 { + Err(STATUS_INVALID_REQUEST) + } else { + Ok(values) + } +} + fn valid_axis(mode: u8, value: f32) -> bool { match mode { AXIS_UNCONSTRAINED => value == 0.0, @@ -2242,6 +2264,41 @@ mod tests { "request placement is not semantic geometry", ); + let mut zero_width = valid_geometry_bytes(); + write_f32( + &mut zero_width, + CONSTRAINT_OFFSET + abi::ENGINE_CONSTRAINT_WIDTH, + 0.0, + ); + write_u16( + &mut zero_width, + REGION_OFFSET + abi::ENGINE_REGION_EXCLUSION_COUNT, + 0, + ); + write_f32( + &mut zero_width, + REGION_OFFSET + abi::ENGINE_REGION_INLINE_END, + 0.0, + ); + write_f32( + &mut zero_width, + REGION_OFFSET + abi::ENGINE_REGION_CLIP_INLINE_END, + 0.0, + ); + let zero_width = parse_valid_geometry(&zero_width).unwrap(); + let mut retained_zero_width = FlowGeometryArena::default(); + retained_zero_width.build(zero_width).unwrap(); + assert_eq!( + InlineSlotArena::default() + .resolve_band(&retained_zero_width, 0, 0.0, 10.0, 1) + .unwrap(), + [InlineSlot { + start: 0.0, + end: 0.0, + }], + "a zero-width host measurement still composes one-cluster lines", + ); + let mut outside_text = bytes.clone(); let inline = INLINE_OFFSET + abi::ENGINE_INLINE_OBJECT_TEXT_OFFSET; write_u32(&mut outside_text, inline, 1); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 8dfe6d83..622c6fd9 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -11,7 +11,10 @@ use super::{ flow_composition::{EllipsisReplacement, FlowLayoutArena}, flow_geometry::FlowGeometryArena, font_binding::FontRenderBinding, - frame::{CommittedUpdate, PreparedUpdate, SessionRevision, UpdateRequest}, + frame::{ + CommittedUpdate, OVERFLOW_CLIP, OVERFLOW_VISIBLE, PreparedUpdate, SessionRevision, + UpdateRequest, + }, identity_index::IdentityIndex, policy::{ALLOCATION_ORDERED_DIRECT, CapabilitySetId, ValidatedPolicy}, policy_gather::{ @@ -155,6 +158,11 @@ struct ParagraphState { pending_geometry: FlowGeometryArena, flow_layout: FlowLayoutArena, pending_flow_layout: FlowLayoutArena, + intrinsic_geometry_scratch: FlowGeometryArena, + intrinsic_flow_layout_scratch: FlowLayoutArena, + intrinsic_flow_slot_scratch: super::flow_geometry::InlineSlotArena, + intrinsic_positioned_scratch: PositionedGlyphArena, + intrinsic_identity_scratch: IdentityIndex, boundary_shape: BoundaryShapeArena, pending_boundary_shape: BoundaryShapeArena, boundary_shape_scratch: ShapeArena, @@ -690,9 +698,79 @@ impl TextEngine { for order_index in 0..session.active_order().len() { let paragraph_id = session.active_order()[order_index].id; let paragraph = session - .paragraph(paragraph_id) + .paragraph_mut(paragraph_id) .ok_or(EngineError::InvalidRequest)?; - let state = ¶graph.state; + let state = &mut paragraph.state; + let visible_extents = { + let text = if state.text_prepared { + &state.pending_text + } else { + &state.text + }; + let clusters = if state.clusters_prepared { + &state.pending_clusters + } else { + &state.clusters + }; + let geometry = if state.geometry_prepared { + &state.pending_geometry + } else { + &state.geometry + }; + let flow = if state.flow_layout_prepared { + &state.pending_flow_layout + } else { + &state.flow_layout + }; + let flow_thread_id = geometry + .constraints + .first() + .ok_or(EngineError::InvalidRequest)? + .flow_thread_id; + super::layout_query::flow_extents(flow_thread_id, flow, text, clusters)? + }; + let active_flow = if state.flow_layout_prepared { + &state.pending_flow_layout + } else { + &state.flow_layout + }; + let active_line_count = active_flow.lines.len(); + let has_ellipsis = !active_flow.ellipsis_threads().is_empty(); + let cluster_count = if state.clusters_prepared { + state.pending_clusters.starts.len() + } else { + state.clusters.starts.len() + }; + let constraint = if state.geometry_prepared { + state.pending_geometry.constraints.first() + } else { + state.geometry.constraints.first() + } + .copied() + .ok_or(EngineError::InvalidRequest)?; + let needs_intrinsic = + visible_extents.consumed_clusters < cluster_count || has_ellipsis; + if needs_intrinsic { + state.prepare_intrinsic_flow_layout( + shaper.as_deref().ok_or(EngineError::InvalidRequest)?, + font_stacks, + font_bindings, + request.limits.max_lines, + request.limits.max_slots_per_band, + )?; + } + let max_lines_truncated = constraint.max_lines != 0 + && active_line_count + >= usize::try_from(constraint.max_lines) + .map_err(|_| EngineError::ResultTooLarge)?; + let inspect_full_clipped_layout = needs_intrinsic + && constraint.overflow == OVERFLOW_CLIP + && !max_lines_truncated; + if inspect_full_clipped_layout { + state.prepare_intrinsic_positioned( + shaper.as_deref().ok_or(EngineError::InvalidRequest)?, + )?; + } let text = if state.text_prepared { &state.pending_text } else { @@ -708,16 +786,41 @@ impl TextEngine { } else { &state.geometry }; - let flow = if state.flow_layout_prepared { + let active_flow = if state.flow_layout_prepared { &state.pending_flow_layout } else { &state.flow_layout }; - let positioned = if state.positioned_prepared { + let active_positioned = if state.positioned_prepared { &state.pending_positioned } else { &state.positioned }; + let flow = if inspect_full_clipped_layout { + &state.intrinsic_flow_layout_scratch + } else { + active_flow + }; + let positioned = if inspect_full_clipped_layout { + &state.intrinsic_positioned_scratch + } else { + active_positioned + }; + let intrinsic_extents = if needs_intrinsic { + let flow_thread_id = geometry + .constraints + .first() + .ok_or(EngineError::InvalidRequest)? + .flow_thread_id; + Some(super::layout_query::flow_extents( + flow_thread_id, + &state.intrinsic_flow_layout_scratch, + text, + clusters, + )?) + } else { + None + }; let (line_glyph_starts, line_glyph_counts) = positioned.semantic_line_glyph_spans(); super::layout_query::append_measurement( @@ -730,6 +833,10 @@ impl TextEngine { positioned.semantic_glyphs(), line_glyph_starts, line_glyph_counts, + Some(positioned.semantic_line_inline_extents()), + text, + clusters, + intrinsic_extents, include_layout_inspection, )?; } @@ -1100,6 +1207,9 @@ impl ParagraphState { self.pending_geometry.clear(); self.flow_layout.clear(); self.pending_flow_layout.clear(); + self.intrinsic_geometry_scratch.clear(); + self.intrinsic_flow_layout_scratch.clear(); + self.intrinsic_positioned_scratch.clear(); self.boundary_shape.clear(); self.pending_boundary_shape.clear(); self.boundary_shape_scratch.clear(); @@ -1234,6 +1344,7 @@ impl ParagraphState { self.pending_clusters.reserve(capacity)?; self.flow_layout.reserve(capacity, 1)?; self.pending_flow_layout.reserve(capacity, 1)?; + self.intrinsic_flow_layout_scratch.reserve(capacity, 1)?; self.boundary_shape.reserve(capacity.min(64))?; self.pending_boundary_shape.reserve(capacity.min(64))?; self.boundary_shape_scratch.reserve(8)?; @@ -1245,6 +1356,7 @@ impl ParagraphState { } self.positioned.reserve(glyph_capacity)?; self.pending_positioned.reserve(glyph_capacity)?; + self.intrinsic_positioned_scratch.reserve(glyph_capacity)?; self.glyph_identity_index .prepare(glyph_capacity) .map_err(|_| EngineError::ResultTooLarge)?; @@ -2002,6 +2114,129 @@ impl ParagraphState { Ok(()) } + fn prepare_intrinsic_flow_layout( + &mut self, + shaper: &ShaperRegistry, + font_stacks: &[RegisteredFontStack], + font_bindings: &[RegisteredFontBinding], + max_lines: u32, + max_slots_per_band: u32, + ) -> Result<(), EngineError> { + let source_geometry = if self.geometry_prepared { + &self.pending_geometry + } else { + &self.geometry + }; + self.intrinsic_geometry_scratch.clone_from(source_geometry); + let constraint = self + .intrinsic_geometry_scratch + .constraints + .first_mut() + .ok_or(EngineError::InvalidRequest)?; + let region_start = + usize::try_from(constraint.region_start).map_err(|_| EngineError::InvalidRequest)?; + let final_region = region_start + .checked_add(usize::from(constraint.region_count)) + .and_then(|end| end.checked_sub(1)) + .ok_or(EngineError::InvalidRequest)?; + let region = self + .intrinsic_geometry_scratch + .regions + .get_mut(final_region) + .ok_or(EngineError::InvalidRequest)?; + const INTRINSIC_BLOCK_END: f32 = 16_777_216.0; + constraint.max_lines = 0; + constraint.viewport_block_end = INTRINSIC_BLOCK_END; + constraint.overflow = OVERFLOW_VISIBLE; + region.record.block_end = INTRINSIC_BLOCK_END; + + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + self.intrinsic_flow_layout_scratch.build( + &self.intrinsic_geometry_scratch, + clusters, + styles, + &mut self.intrinsic_flow_slot_scratch, + usize::try_from(max_lines).map_err(|_| EngineError::ResultTooLarge)?, + usize::try_from(max_slots_per_band).map_err(|_| EngineError::ResultTooLarge)?, + |handle| shaper.font_metrics(handle), + |stack_handle| { + font_stacks + .binary_search_by_key(&stack_handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks[index].fonts.first().copied()) + .and_then(|handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| binding.shaping_handle) + }) + }, + ) + } + + fn prepare_intrinsic_positioned(&mut self, shaper: &ShaperRegistry) -> Result<(), EngineError> { + let text = if self.text_prepared { + self.pending_text.as_slice() + } else { + self.text.as_slice() + }; + let clusters = if self.clusters_prepared { + &self.pending_clusters + } else { + &self.clusters + }; + let runs = if self.shaping_runs_prepared { + self.pending_shaping_runs.runs() + } else { + self.shaping_runs.runs() + }; + let shape = if self.shape_prepared { + &self.pending_shape + } else { + &self.shape + }; + let styles = if self.styles_prepared { + self.pending_resolved_styles.segments() + } else { + self.resolved_styles.segments() + }; + let bidi = if self.bidi_prepared { + &self.pending_bidi + } else { + &self.bidi + }; + let previous = if self.positioned_prepared { + &self.pending_positioned + } else { + &self.positioned + }; + let mut next_content_revision = 1; + self.intrinsic_positioned_scratch.build( + previous, + &self.intrinsic_flow_layout_scratch, + text, + clusters, + runs, + shape, + &BoundaryShapeArena::default(), + styles, + bidi, + &mut self.intrinsic_identity_scratch, + &mut next_content_revision, + |handle| shaper.font_metrics(handle), + |handle, glyph| shaper.font_glyph_extents(handle, glyph), + ) + } + fn abort_flow_layout(&mut self) { self.pending_flow_layout.clear(); self.pending_boundary_shape.clear(); diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index edb3a395..919364b2 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -38,14 +38,12 @@ pub const STATUS_REVISION_CONFLICT: u32 = 12; pub const STATUS_FONT_STACK_MISSING: u32 = 13; pub const STATUS_FONT_IN_USE: u32 = 14; -const BUFFER_FLAGS_MASK: u32 = 0xff; const MAX_CACHED_PLANS_PER_FONT: usize = 64; const DEFAULT_SHAPE_BUFFER_CAPACITY: usize = 32_768; const DEFAULT_SHAPE_FEATURE_CAPACITY: usize = 128; pub struct ShaperRegistry { fonts: BTreeMap, - result: ResultArena, shape_buffer: Option, context_codepoints: Vec, shape_features: Vec, @@ -55,7 +53,6 @@ impl Default for ShaperRegistry { fn default() -> Self { Self { fonts: BTreeMap::new(), - result: ResultArena::default(), shape_buffer: Some(UnicodeBuffer::new()), context_codepoints: Vec::new(), shape_features: Vec::new(), @@ -63,12 +60,6 @@ impl Default for ShaperRegistry { } } -#[derive(Default)] -struct ResultArena { - words: Vec, - byte_length: u32, -} - struct RegisteredFont { sfnt: Vec, extents: Vec, @@ -122,27 +113,6 @@ pub struct FeatureRecord { pub end: u32, } -pub struct RunRequest { - pub font_handle: u32, - pub text_start: u32, - pub text_end: u32, - pub script: u32, - pub language: Option>, - pub features: Vec, - pub direction: u8, - pub cluster_level: u8, - pub flags: u32, -} - -pub struct ReshapeRange { - pub run: u32, - pub item_start: u32, - pub item_end: u32, - pub context_start: u32, - pub context_end: u32, - pub flags: u32, -} - #[derive(Clone, Copy)] pub(crate) struct ShapeRangeRef { pub item_start: u32, @@ -164,40 +134,6 @@ pub(crate) struct ShapeRunRef<'a> { pub flags: u32, } -impl<'a> From<&'a RunRequest> for ShapeRunRef<'a> { - fn from(run: &'a RunRequest) -> Self { - Self { - text_start: run.text_start, - text_end: run.text_end, - script: run.script, - language: run.language.as_deref(), - features: &run.features, - direction: run.direction, - cluster_level: run.cluster_level, - flags: run.flags, - } - } -} - -pub struct ShapeBatchRequest { - pub text: Vec, - pub runs: Vec, -} - -pub struct ShapeBatchOutput { - pub font_handles: Vec, - pub run_font_slots: Vec, - pub run_glyph_starts: Vec, - pub run_glyph_counts: Vec, - pub glyph_ids: Vec, - pub clusters: Vec, - pub x_advances: Vec, - pub y_advances: Vec, - pub x_offsets: Vec, - pub y_offsets: Vec, - pub glyph_flags: Vec, -} - impl ShaperRegistry { pub fn initialize(&mut self) -> Result<(), u32> { let Some(shape_buffer) = self.shape_buffer.as_mut() else { @@ -223,7 +159,6 @@ impl ShaperRegistry { extents: &[u8], availability: &[u8], ) -> u32 { - self.result.clear(); if handle == 0 { return STATUS_INVALID_HANDLE; } @@ -286,7 +221,6 @@ impl ShaperRegistry { } pub fn dispose_font(&mut self, handle: u32) -> u32 { - self.result.clear(); if self.fonts.remove(&handle).is_some() { STATUS_OK } else { @@ -294,110 +228,6 @@ impl ShaperRegistry { } } - pub fn shape_batch(&mut self, request: &ShapeBatchRequest) -> Result { - self.result.clear(); - self.shape_segments(request, None) - } - - pub fn reshape_ranges( - &mut self, - request: &ShapeBatchRequest, - ranges: &[ReshapeRange], - ) -> Result { - self.result.clear(); - self.shape_segments(request, Some(ranges)) - } - - fn shape_segments( - &mut self, - request: &ShapeBatchRequest, - ranges: Option<&[ReshapeRange]>, - ) -> Result { - validate_request(request, ranges)?; - let segment_count = ranges.map_or(request.runs.len(), <[ReshapeRange]>::len); - let mut output = ShapeBatchOutput::with_run_capacity(segment_count); - let mut font_slots = BTreeMap::::new(); - - for segment in 0..segment_count { - let (run_index, range) = if let Some(ranges) = ranges { - let range = &ranges[segment]; - ( - usize::try_from(range.run).map_err(|_| STATUS_INVALID_REQUEST)?, - ShapeRangeRef { - item_start: range.item_start, - item_end: range.item_end, - context_start: range.context_start, - context_end: range.context_end, - flags: range.flags, - }, - ) - } else { - let run = &request.runs[segment]; - ( - segment, - ShapeRangeRef { - item_start: run.text_start, - item_end: run.text_end, - context_start: run.text_start, - context_end: run.text_end, - flags: run.flags, - }, - ) - }; - let run = &request.runs[run_index]; - let run_ref = ShapeRunRef::from(run); - let slot = if let Some(slot) = font_slots.get(&run.font_handle) { - *slot - } else { - let slot = u16::try_from(output.font_handles.len()) - .map_err(|_| STATUS_RESULT_TOO_LARGE)?; - output.font_handles.push(run.font_handle); - font_slots.insert(run.font_handle, slot); - slot - }; - let glyph_start = - u32::try_from(output.glyph_ids.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - let font = self - .fonts - .get_mut(&run.font_handle) - .ok_or(STATUS_FONT_MISSING)?; - let shaped = shape_segment( - font, - &request.text, - run_ref, - range, - &mut self.shape_buffer, - &mut self.context_codepoints, - &mut self.shape_features, - )?; - let append_result: Result<(), u32> = (|| { - let glyph_count = - u32::try_from(shaped.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - output.run_font_slots.push(slot); - output.run_glyph_starts.push(glyph_start); - output.run_glyph_counts.push(glyph_count); - for (info, position) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { - output - .glyph_ids - .push(u16::try_from(info.glyph_id).map_err(|_| STATUS_RESULT_TOO_LARGE)?); - output.clusters.push(info.cluster); - output.x_advances.push(position.x_advance); - output.y_advances.push(position.y_advance); - output.x_offsets.push(position.x_offset); - output.y_offsets.push(position.y_offset); - output.glyph_flags.push( - u16::try_from(info.flags().to_bits()) - .map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - } - Ok(()) - })(); - self.shape_buffer = Some(shaped.clear()); - append_result?; - } - Ok(output) - } - pub(crate) fn with_shaped_run( &mut self, font_handle: u32, @@ -441,22 +271,6 @@ impl ShaperRegistry { result } - pub fn clear_result(&mut self) { - self.result.clear(); - } - - pub fn set_result(&mut self, result: Vec) -> Result<(), u32> { - self.result.set(result) - } - - pub fn result_pointer(&self) -> *const u32 { - self.result.words.as_ptr() - } - - pub fn result_length(&self) -> u32 { - self.result.byte_length - } - pub fn font_count(&self) -> u32 { self.fonts.len().try_into().unwrap_or(u32::MAX) } @@ -489,108 +303,6 @@ impl ShaperRegistry { } } -impl ResultArena { - fn clear(&mut self) { - self.words.clear(); - self.byte_length = 0; - } - - fn set(&mut self, bytes: Vec) -> Result<(), u32> { - self.words.clear(); - self.words - .try_reserve_exact(bytes.len().div_ceil(4)) - .map_err(|_| STATUS_RESULT_TOO_LARGE)?; - for chunk in bytes.chunks(4) { - let mut word = [0; 4]; - word[..chunk.len()].copy_from_slice(chunk); - self.words.push(u32::from_le_bytes(word)); - } - self.byte_length = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - Ok(()) - } -} - -impl ShapeBatchOutput { - fn with_run_capacity(run_count: usize) -> Self { - Self { - font_handles: Vec::new(), - run_font_slots: Vec::with_capacity(run_count), - run_glyph_starts: Vec::with_capacity(run_count), - run_glyph_counts: Vec::with_capacity(run_count), - glyph_ids: Vec::new(), - clusters: Vec::new(), - x_advances: Vec::new(), - y_advances: Vec::new(), - x_offsets: Vec::new(), - y_offsets: Vec::new(), - glyph_flags: Vec::new(), - } - } -} - -fn validate_request( - request: &ShapeBatchRequest, - ranges: Option<&[ReshapeRange]>, -) -> Result<(), u32> { - let text_length = u32::try_from(request.text.len()).map_err(|_| STATUS_INVALID_REQUEST)?; - if request.runs.is_empty() { - return Err(STATUS_INVALID_REQUEST); - } - for run in &request.runs { - if run.font_handle == 0 - || run.text_start > run.text_end - || run.text_end > text_length - || run.direction > 1 - || run.cluster_level > 3 - || run.flags & !BUFFER_FLAGS_MASK != 0 - || !valid_script(run.script) - || !valid_utf16_boundary(&request.text, run.text_start) - || !valid_utf16_boundary(&request.text, run.text_end) - || run - .language - .as_ref() - .is_some_and(|value| parse_language(value).is_none()) - { - return Err(STATUS_INVALID_REQUEST); - } - for feature in &run.features { - if !valid_tag(feature.tag) - || feature.start > feature.end - || feature.end > text_length - || !valid_utf16_boundary(&request.text, feature.start) - || !valid_utf16_boundary(&request.text, feature.end) - { - return Err(STATUS_INVALID_REQUEST); - } - } - } - if let Some(ranges) = ranges { - if ranges.is_empty() { - return Err(STATUS_INVALID_REQUEST); - } - for range in ranges { - let run = request - .runs - .get(usize::try_from(range.run).map_err(|_| STATUS_INVALID_REQUEST)?) - .ok_or(STATUS_INVALID_REQUEST)?; - if range.context_start > range.item_start - || range.item_start > range.item_end - || range.item_end > range.context_end - || range.context_start < run.text_start - || range.context_end > run.text_end - || range.flags & !BUFFER_FLAGS_MASK != 0 - || !valid_utf16_boundary(&request.text, range.item_start) - || !valid_utf16_boundary(&request.text, range.item_end) - || !valid_utf16_boundary(&request.text, range.context_start) - || !valid_utf16_boundary(&request.text, range.context_end) - { - return Err(STATUS_INVALID_REQUEST); - } - } - } - Ok(()) -} - fn shape_segment( font: &mut RegisteredFont, text: &[u16], @@ -915,11 +627,6 @@ pub(crate) fn valid_tag(tag: u32) -> bool { .all(|byte| (0x20..=0x7e).contains(byte)) } -fn valid_script(tag: u32) -> bool { - let bytes = tag.to_be_bytes(); - bytes[0].is_ascii_uppercase() && bytes[1..].iter().all(u8::is_ascii_lowercase) -} - pub(crate) fn valid_utf16_boundary(text: &[u16], offset: u32) -> bool { let Ok(offset) = usize::try_from(offset) else { return false; @@ -1002,8 +709,6 @@ mod tests { fn tags_reject_non_opentype_bytes() { assert!(valid_tag(u32::from_be_bytes(*b"Latn"))); assert!(!valid_tag(u32::from_be_bytes([b'L', 0, b't', b'n']))); - assert!(valid_script(u32::from_be_bytes(*b"Latn"))); - assert!(!valid_script(u32::from_be_bytes(*b"LATN"))); } #[test] diff --git a/packages/text/rust/shaper/src/wasm.rs b/packages/text/rust/shaper/src/wasm.rs index 696ed914..47db5b29 100644 --- a/packages/text/rust/shaper/src/wasm.rs +++ b/packages/text/rust/shaper/src/wasm.rs @@ -5,16 +5,11 @@ use crate::{ STATUS_FONT_IN_USE, STATUS_FONT_STACK_MISSING, STATUS_INVALID_HANDLE, STATUS_INVALID_REQUEST, STATUS_OK, STATUS_POLICY_CONFLICT, STATUS_POLICY_MISSING, STATUS_RESULT_TOO_LARGE, STATUS_REVISION_CONFLICT, STATUS_SESSION_CONFLICT, STATUS_SESSION_MISSING, ShaperRegistry, - bidi, engine::{ EngineError, TextEngine, font_binding_wire::parse_font_binding, frame::SessionRevision, frame_wire::parse_update_request, render_plan_wire::publication_layout, transport::FrameTransport, wire::parse_policy, }, - wire::{ - pack_bidi_result, pack_result, parse_bidi_request, parse_reshape_request, - parse_shape_request, - }, }; #[cfg(target_arch = "wasm32")] @@ -623,83 +618,6 @@ pub unsafe extern "C" fn pmndrs_text_engine_update( }) } -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pmndrs_text_shaper_shape_batch(pointer: u32, length: u32) -> u32 { - with_state(|state| { - state.registry.clear_result(); - let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { - return STATUS_INVALID_REQUEST; - }; - let request = match parse_shape_request(bytes) { - Ok(request) => request, - Err(status) => return status, - }; - let output = match state.registry.shape_batch(&request) { - Ok(output) => output, - Err(status) => return status, - }; - match pack_result(&output) { - Ok(result) => store_result(&mut state.registry, result), - Err(status) => status, - } - }) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pmndrs_text_shaper_reshape_ranges(pointer: u32, length: u32) -> u32 { - with_state(|state| { - state.registry.clear_result(); - let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { - return STATUS_INVALID_REQUEST; - }; - let (request, ranges) = match parse_reshape_request(bytes) { - Ok(request) => request, - Err(status) => return status, - }; - let output = match state.registry.reshape_ranges(&request, &ranges) { - Ok(output) => output, - Err(status) => return status, - }; - match pack_result(&output) { - Ok(result) => store_result(&mut state.registry, result), - Err(status) => status, - } - }) -} - -#[unsafe(no_mangle)] -pub unsafe extern "C" fn pmndrs_text_shaper_analyze_bidi(pointer: u32, length: u32) -> u32 { - with_state(|state| { - state.registry.clear_result(); - let Some(bytes) = owned_bytes(&state.allocations, pointer, length) else { - return STATUS_INVALID_REQUEST; - }; - let (text, direction) = match parse_bidi_request(bytes) { - Ok(request) => request, - Err(status) => return status, - }; - let output = match bidi::analyze(&text, direction) { - Ok(output) => output, - Err(bidi::BidiError::InvalidDirection) => return STATUS_INVALID_REQUEST, - Err(bidi::BidiError::ResultTooLarge) => return STATUS_RESULT_TOO_LARGE, - }; - match pack_bidi_result(&output) { - Ok(result) => store_result(&mut state.registry, result), - Err(status) => status, - } - }) -} - -#[unsafe(no_mangle)] -pub extern "C" fn pmndrs_text_shaper_result_ptr() -> u32 { - with_state(|state| u32::try_from(state.registry.result_pointer() as usize).unwrap_or(0)) -} - -#[unsafe(no_mangle)] -pub extern "C" fn pmndrs_text_shaper_result_len() -> u32 { - with_state(|state| state.registry.result_length()) -} - #[derive(Default)] struct WasmState { registry: ShaperRegistry, @@ -784,13 +702,6 @@ fn owns_region(allocations: &[Allocation], pointer: u32, length: u32) -> bool { }) } -fn store_result(registry: &mut ShaperRegistry, result: Vec) -> u32 { - match registry.set_result(result) { - Ok(()) => 0, - Err(status) => status, - } -} - fn engine_status(error: EngineError) -> u32 { match error { EngineError::InvalidHandle => STATUS_INVALID_HANDLE, diff --git a/packages/text/rust/shaper/src/wire.rs b/packages/text/rust/shaper/src/wire.rs index 9446bcfe..841c3a87 100644 --- a/packages/text/rust/shaper/src/wire.rs +++ b/packages/text/rust/shaper/src/wire.rs @@ -1,323 +1,4 @@ -use alloc::vec::Vec; - -use crate::{ - FeatureRecord, ReshapeRange, RunRequest, STATUS_INVALID_REQUEST, STATUS_RESULT_TOO_LARGE, - ShapeBatchOutput, ShapeBatchRequest, - abi_contract::{ - BIDI_DIRECTION, BIDI_REQUEST_HEADER_SIZE, BIDI_RESULT_BYTE_LENGTH, - BIDI_RESULT_CLASSES_OFFSET, BIDI_RESULT_HEADER_SIZE, BIDI_RESULT_LEVELS_OFFSET, - BIDI_RESULT_PARAGRAPH_COUNT, BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - BIDI_RESULT_TEXT_LENGTH, BIDI_TEXT_LENGTH, BIDI_TEXT_OFFSET, FEATURE_END, - FEATURE_RECORD_SIZE, FEATURE_START, FEATURE_TAG, FEATURE_VALUE, RANGE_CONTEXT_END, - RANGE_CONTEXT_START, RANGE_FLAGS, RANGE_ITEM_END, RANGE_ITEM_START, RANGE_RUN, - RESHAPE_RANGE_COUNT, RESHAPE_RANGE_RECORD_SIZE, RESHAPE_RANGES_OFFSET, - RESHAPE_REQUEST_HEADER_SIZE, RESULT_BYTE_LENGTH, RESULT_CLUSTERS_OFFSET, - RESULT_FONT_HANDLE_COUNT, RESULT_FONT_HANDLES_OFFSET, RESULT_GLYPH_COUNT, - RESULT_GLYPH_FLAGS_OFFSET, RESULT_GLYPH_IDS_OFFSET, RESULT_HEADER_SIZE, RESULT_RUN_COUNT, - RESULT_RUN_FONT_SLOTS_OFFSET, RESULT_RUN_GLYPH_COUNTS_OFFSET, - RESULT_RUN_GLYPH_STARTS_OFFSET, RESULT_X_ADVANCES_OFFSET, RESULT_X_OFFSETS_OFFSET, - RESULT_Y_ADVANCES_OFFSET, RESULT_Y_OFFSETS_OFFSET, RUN_CLUSTER_LEVEL, RUN_DIRECTION, - RUN_FEATURE_COUNT, RUN_FEATURE_START, RUN_FLAGS, RUN_FONT_HANDLE, RUN_LANGUAGE_OFFSET, - RUN_RECORD_SIZE, RUN_SCRIPT, RUN_TEXT_END, RUN_TEXT_START, SHAPE_FEATURE_COUNT, - SHAPE_FEATURES_OFFSET, SHAPE_LANGUAGES_LENGTH, SHAPE_LANGUAGES_OFFSET, - SHAPE_REQUEST_HEADER_SIZE, SHAPE_RUN_COUNT, SHAPE_RUNS_OFFSET, SHAPE_TEXT_LENGTH, - SHAPE_TEXT_OFFSET, - }, - bidi::BidiAnalysis, -}; - -const NO_LANGUAGE: u32 = u32::MAX; - -pub fn parse_bidi_request(bytes: &[u8]) -> Result<(Vec, u8), u32> { - if bytes.len() < BIDI_REQUEST_HEADER_SIZE as usize { - return Err(STATUS_INVALID_REQUEST); - } - let text_offset = read_u32(bytes, BIDI_TEXT_OFFSET)?; - let text_length = read_u32(bytes, BIDI_TEXT_LENGTH)?; - let direction = *bytes.get(BIDI_DIRECTION).ok_or(STATUS_INVALID_REQUEST)?; - if bytes.get(BIDI_DIRECTION + 1..BIDI_REQUEST_HEADER_SIZE as usize) != Some(&[0, 0, 0]) { - return Err(STATUS_INVALID_REQUEST); - } - let text_bytes = array(bytes, text_offset, text_length, 2, 2)?; - let mut text = - Vec::with_capacity(usize::try_from(text_length).map_err(|_| STATUS_INVALID_REQUEST)?); - for unit in text_bytes.chunks_exact(2) { - text.push(u16::from_le_bytes([unit[0], unit[1]])); - } - Ok((text, direction)) -} - -pub fn parse_shape_request(bytes: &[u8]) -> Result { - parse_request(bytes, false).map(|(request, _)| request) -} - -pub fn parse_reshape_request(bytes: &[u8]) -> Result<(ShapeBatchRequest, Vec), u32> { - let (request, ranges) = parse_request(bytes, true)?; - Ok((request, ranges.ok_or(STATUS_INVALID_REQUEST)?)) -} - -fn parse_request( - bytes: &[u8], - reshape: bool, -) -> Result<(ShapeBatchRequest, Option>), u32> { - let header_size = if reshape { - RESHAPE_REQUEST_HEADER_SIZE - } else { - SHAPE_REQUEST_HEADER_SIZE - } as usize; - if bytes.len() < header_size { - return Err(STATUS_INVALID_REQUEST); - } - let text_offset = read_u32(bytes, SHAPE_TEXT_OFFSET)?; - let text_length = read_u32(bytes, SHAPE_TEXT_LENGTH)?; - let runs_offset = read_u32(bytes, SHAPE_RUNS_OFFSET)?; - let run_count = read_u32(bytes, SHAPE_RUN_COUNT)?; - let features_offset = read_u32(bytes, SHAPE_FEATURES_OFFSET)?; - let feature_count = read_u32(bytes, SHAPE_FEATURE_COUNT)?; - let languages_offset = read_u32(bytes, SHAPE_LANGUAGES_OFFSET)?; - let languages_length = read_u32(bytes, SHAPE_LANGUAGES_LENGTH)?; - - let text_bytes = array(bytes, text_offset, text_length, 2, 2)?; - let mut text = - Vec::with_capacity(usize::try_from(text_length).map_err(|_| STATUS_INVALID_REQUEST)?); - for unit in text_bytes.chunks_exact(2) { - text.push(u16::from_le_bytes([unit[0], unit[1]])); - } - - let feature_bytes = array( - bytes, - features_offset, - feature_count, - FEATURE_RECORD_SIZE, - 4, - )?; - let mut features = - Vec::with_capacity(usize::try_from(feature_count).map_err(|_| STATUS_INVALID_REQUEST)?); - for record in feature_bytes.chunks_exact(FEATURE_RECORD_SIZE as usize) { - features.push(FeatureRecord { - tag: read_u32(record, FEATURE_TAG)?, - value: read_u32(record, FEATURE_VALUE)?, - start: read_u32(record, FEATURE_START)?, - end: read_u32(record, FEATURE_END)?, - }); - } - - let languages = array(bytes, languages_offset, languages_length, 1, 1)?; - let run_bytes = array(bytes, runs_offset, run_count, RUN_RECORD_SIZE, 4)?; - let mut runs = - Vec::with_capacity(usize::try_from(run_count).map_err(|_| STATUS_INVALID_REQUEST)?); - for record in run_bytes.chunks_exact(RUN_RECORD_SIZE as usize) { - let feature_start = usize::try_from(read_u32(record, RUN_FEATURE_START)?) - .map_err(|_| STATUS_INVALID_REQUEST)?; - let feature_count = usize::from(read_u16(record, RUN_FEATURE_COUNT)?); - let feature_end = feature_start - .checked_add(feature_count) - .ok_or(STATUS_INVALID_REQUEST)?; - let selected_features = features - .get(feature_start..feature_end) - .ok_or(STATUS_INVALID_REQUEST)? - .to_vec(); - let language_offset = read_u32(record, RUN_LANGUAGE_OFFSET)?; - let language = if language_offset == NO_LANGUAGE { - None - } else { - let offset = usize::try_from(language_offset).map_err(|_| STATUS_INVALID_REQUEST)?; - let length = usize::from(read_u16(languages, offset)?); - let start = offset.checked_add(2).ok_or(STATUS_INVALID_REQUEST)?; - let end = start.checked_add(length).ok_or(STATUS_INVALID_REQUEST)?; - Some( - languages - .get(start..end) - .ok_or(STATUS_INVALID_REQUEST)? - .to_vec(), - ) - }; - runs.push(RunRequest { - font_handle: read_u32(record, RUN_FONT_HANDLE)?, - text_start: read_u32(record, RUN_TEXT_START)?, - text_end: read_u32(record, RUN_TEXT_END)?, - script: read_u32(record, RUN_SCRIPT)?, - language, - features: selected_features, - direction: *record.get(RUN_DIRECTION).ok_or(STATUS_INVALID_REQUEST)?, - cluster_level: *record - .get(RUN_CLUSTER_LEVEL) - .ok_or(STATUS_INVALID_REQUEST)?, - flags: read_u32(record, RUN_FLAGS)?, - }); - } - - let ranges = if reshape { - let ranges_offset = read_u32(bytes, RESHAPE_RANGES_OFFSET)?; - let range_count = read_u32(bytes, RESHAPE_RANGE_COUNT)?; - let range_bytes = array( - bytes, - ranges_offset, - range_count, - RESHAPE_RANGE_RECORD_SIZE, - 4, - )?; - let mut ranges = - Vec::with_capacity(usize::try_from(range_count).map_err(|_| STATUS_INVALID_REQUEST)?); - for record in range_bytes.chunks_exact(RESHAPE_RANGE_RECORD_SIZE as usize) { - ranges.push(ReshapeRange { - run: read_u32(record, RANGE_RUN)?, - item_start: read_u32(record, RANGE_ITEM_START)?, - item_end: read_u32(record, RANGE_ITEM_END)?, - context_start: read_u32(record, RANGE_CONTEXT_START)?, - context_end: read_u32(record, RANGE_CONTEXT_END)?, - flags: read_u32(record, RANGE_FLAGS)?, - }); - } - Some(ranges) - } else { - None - }; - Ok((ShapeBatchRequest { text, runs }, ranges)) -} - -pub fn pack_result(output: &ShapeBatchOutput) -> Result, u32> { - let run_count = output.run_font_slots.len(); - let glyph_count = output.glyph_ids.len(); - if output.run_glyph_starts.len() != run_count - || output.run_glyph_counts.len() != run_count - || output.clusters.len() != glyph_count - || output.x_advances.len() != glyph_count - || output.y_advances.len() != glyph_count - || output.x_offsets.len() != glyph_count - || output.y_offsets.len() != glyph_count - || output.glyph_flags.len() != glyph_count - { - return Err(STATUS_RESULT_TOO_LARGE); - } - let mut bytes = result_bytes( - RESULT_HEADER_SIZE as usize, - &[ - (output.font_handles.len(), 4), - (output.run_font_slots.len(), 2), - (output.run_glyph_starts.len(), 4), - (output.run_glyph_counts.len(), 4), - (output.glyph_ids.len(), 2), - (output.clusters.len(), 4), - (output.x_advances.len(), 4), - (output.y_advances.len(), 4), - (output.x_offsets.len(), 4), - (output.y_offsets.len(), 4), - (output.glyph_flags.len(), 2), - ], - )?; - let font_handles_offset = append_u32(&mut bytes, &output.font_handles)?; - let run_font_slots_offset = append_u16(&mut bytes, &output.run_font_slots)?; - let run_glyph_starts_offset = append_u32(&mut bytes, &output.run_glyph_starts)?; - let run_glyph_counts_offset = append_u32(&mut bytes, &output.run_glyph_counts)?; - let glyph_ids_offset = append_u16(&mut bytes, &output.glyph_ids)?; - let clusters_offset = append_u32(&mut bytes, &output.clusters)?; - let x_advances_offset = append_i32(&mut bytes, &output.x_advances)?; - let y_advances_offset = append_i32(&mut bytes, &output.y_advances)?; - let x_offsets_offset = append_i32(&mut bytes, &output.x_offsets)?; - let y_offsets_offset = append_i32(&mut bytes, &output.y_offsets)?; - let glyph_flags_offset = append_u16(&mut bytes, &output.glyph_flags)?; - let byte_length = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - - write_u32(&mut bytes, RESULT_BYTE_LENGTH, byte_length); - write_u32(&mut bytes, RESULT_FONT_HANDLES_OFFSET, font_handles_offset); - write_u32( - &mut bytes, - RESULT_FONT_HANDLE_COUNT, - u32::try_from(output.font_handles.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - write_u32( - &mut bytes, - RESULT_RUN_FONT_SLOTS_OFFSET, - run_font_slots_offset, - ); - write_u32( - &mut bytes, - RESULT_RUN_GLYPH_STARTS_OFFSET, - run_glyph_starts_offset, - ); - write_u32( - &mut bytes, - RESULT_RUN_GLYPH_COUNTS_OFFSET, - run_glyph_counts_offset, - ); - write_u32( - &mut bytes, - RESULT_RUN_COUNT, - u32::try_from(run_count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - write_u32(&mut bytes, RESULT_GLYPH_IDS_OFFSET, glyph_ids_offset); - write_u32(&mut bytes, RESULT_CLUSTERS_OFFSET, clusters_offset); - write_u32(&mut bytes, RESULT_X_ADVANCES_OFFSET, x_advances_offset); - write_u32(&mut bytes, RESULT_Y_ADVANCES_OFFSET, y_advances_offset); - write_u32(&mut bytes, RESULT_X_OFFSETS_OFFSET, x_offsets_offset); - write_u32(&mut bytes, RESULT_Y_OFFSETS_OFFSET, y_offsets_offset); - write_u32(&mut bytes, RESULT_GLYPH_FLAGS_OFFSET, glyph_flags_offset); - write_u32( - &mut bytes, - RESULT_GLYPH_COUNT, - u32::try_from(glyph_count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - Ok(bytes) -} - -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -pub fn pack_bidi_result(output: &BidiAnalysis) -> Result, u32> { - let text_length = output.levels.len(); - let paragraph_count = output.paragraph_starts.len(); - if output.classes.len() != text_length - || output.paragraph_ends.len() != paragraph_count - || output.paragraph_levels.len() != paragraph_count - { - return Err(STATUS_RESULT_TOO_LARGE); - } - let mut bytes = result_bytes( - BIDI_RESULT_HEADER_SIZE as usize, - &[ - (output.levels.len(), 1), - (output.classes.len(), 1), - (output.paragraph_starts.len(), 4), - (output.paragraph_ends.len(), 4), - (output.paragraph_levels.len(), 1), - ], - )?; - let levels_offset = append_u8(&mut bytes, &output.levels)?; - let classes_offset = append_u8(&mut bytes, &output.classes)?; - let paragraph_starts_offset = append_u32(&mut bytes, &output.paragraph_starts)?; - let paragraph_ends_offset = append_u32(&mut bytes, &output.paragraph_ends)?; - let paragraph_levels_offset = append_u8(&mut bytes, &output.paragraph_levels)?; - let byte_length = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - write_u32(&mut bytes, BIDI_RESULT_BYTE_LENGTH, byte_length); - write_u32(&mut bytes, BIDI_RESULT_LEVELS_OFFSET, levels_offset); - write_u32(&mut bytes, BIDI_RESULT_CLASSES_OFFSET, classes_offset); - write_u32( - &mut bytes, - BIDI_RESULT_TEXT_LENGTH, - u32::try_from(text_length).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_STARTS_OFFSET, - paragraph_starts_offset, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_ENDS_OFFSET, - paragraph_ends_offset, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_LEVELS_OFFSET, - paragraph_levels_offset, - ); - write_u32( - &mut bytes, - BIDI_RESULT_PARAGRAPH_COUNT, - u32::try_from(paragraph_count).map_err(|_| STATUS_RESULT_TOO_LARGE)?, - ); - Ok(bytes) -} +use crate::STATUS_INVALID_REQUEST; pub(crate) fn array( bytes: &[u8], @@ -357,128 +38,3 @@ pub(crate) fn read_f32(bytes: &[u8], offset: usize) -> Result { pub(crate) fn write_u32(bytes: &mut [u8], offset: usize, value: u32) { bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); } - -#[inline(never)] -fn result_bytes(header_length: usize, fields: &[(usize, usize)]) -> Result, u32> { - let mut byte_length = header_length; - for &(element_count, element_size) in fields { - let padding = (element_size - byte_length % element_size) % element_size; - byte_length = byte_length - .checked_add(padding) - .and_then(|length| element_count.checked_mul(element_size)?.checked_add(length)) - .ok_or(STATUS_RESULT_TOO_LARGE)?; - } - u32::try_from(byte_length).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - let mut bytes = Vec::new(); - bytes - .try_reserve_exact(byte_length) - .map_err(|_| STATUS_RESULT_TOO_LARGE)?; - bytes.resize(header_length, 0); - Ok(bytes) -} - -fn align(bytes: &mut Vec, alignment: usize) -> Result { - let padding = (alignment - bytes.len() % alignment) % alignment; - bytes.resize( - bytes - .len() - .checked_add(padding) - .ok_or(STATUS_RESULT_TOO_LARGE)?, - 0, - ); - u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE) -} - -fn append_u16(bytes: &mut Vec, values: &[u16]) -> Result { - let offset = align(bytes, 2)?; - for value in values { - bytes.extend_from_slice(&value.to_le_bytes()); - } - Ok(offset) -} - -#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] -fn append_u8(bytes: &mut Vec, values: &[u8]) -> Result { - let offset = u32::try_from(bytes.len()).map_err(|_| STATUS_RESULT_TOO_LARGE)?; - bytes.extend_from_slice(values); - Ok(offset) -} - -fn append_u32(bytes: &mut Vec, values: &[u32]) -> Result { - let offset = align(bytes, 4)?; - for value in values { - bytes.extend_from_slice(&value.to_le_bytes()); - } - Ok(offset) -} - -fn append_i32(bytes: &mut Vec, values: &[i32]) -> Result { - let offset = align(bytes, 4)?; - for value in values { - bytes.extend_from_slice(&value.to_le_bytes()); - } - Ok(offset) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn malformed_wire_ranges_fail_without_panicking() { - assert!(matches!( - parse_shape_request(&[]), - Err(STATUS_INVALID_REQUEST) - )); - assert!(matches!( - parse_reshape_request(&[]), - Err(STATUS_INVALID_REQUEST) - )); - assert!(matches!( - parse_bidi_request(&[]), - Err(STATUS_INVALID_REQUEST) - )); - let mut bytes = vec![0; SHAPE_REQUEST_HEADER_SIZE as usize]; - write_u32(&mut bytes, SHAPE_TEXT_OFFSET, u32::MAX); - write_u32(&mut bytes, SHAPE_TEXT_LENGTH, 1); - assert!(matches!( - parse_shape_request(&bytes), - Err(STATUS_INVALID_REQUEST) - )); - } - - #[test] - fn result_layout_overflow_returns_a_status_before_allocation() { - assert_eq!( - result_bytes(RESULT_HEADER_SIZE as usize, &[(usize::MAX, 4)]), - Err(STATUS_RESULT_TOO_LARGE), - ); - } - - #[test] - fn result_header_points_to_exact_soa_arrays() { - let output = ShapeBatchOutput { - font_handles: vec![7], - run_font_slots: vec![0], - run_glyph_starts: vec![0], - run_glyph_counts: vec![1], - glyph_ids: vec![42], - clusters: vec![3], - x_advances: vec![100], - y_advances: vec![0], - x_offsets: vec![-2], - y_offsets: vec![5], - glyph_flags: vec![3], - }; - let bytes = pack_result(&output).unwrap(); - assert_eq!( - read_u32(&bytes, RESULT_BYTE_LENGTH).unwrap() as usize, - bytes.len() - ); - assert_eq!(read_u32(&bytes, RESULT_FONT_HANDLE_COUNT).unwrap(), 1); - assert_eq!(read_u32(&bytes, RESULT_RUN_COUNT).unwrap(), 1); - assert_eq!(read_u32(&bytes, RESULT_GLYPH_COUNT).unwrap(), 1); - let glyph_offset = read_u32(&bytes, RESULT_GLYPH_IDS_OFFSET).unwrap() as usize; - assert_eq!(read_u16(&bytes, glyph_offset).unwrap(), 42); - } -} diff --git a/packages/text/scripts/benchmark-paragraph-layout.mts b/packages/text/scripts/benchmark-paragraph-layout.mts index b209312f..3efeadec 100644 --- a/packages/text/scripts/benchmark-paragraph-layout.mts +++ b/packages/text/scripts/benchmark-paragraph-layout.mts @@ -1,6 +1,6 @@ /* @workflow { "name": "text:layout-benchmark", - "summary": "Measures paragraph preparation, layout, and packing cost per glyph across realistic scales, with phase attribution and allocation.", + "summary": "Profiles public TextGroup updates from frame preparation through Rust text_update and Three render-plan application.", "requirements": "Built package: pnpm --filter @pmndrs/text build. Accepts --glyphs, --reps, --warmup, --case, --json.", "writes": "stdout only, or the JSON report path passed to --json" } */ @@ -8,8 +8,11 @@ import { writeFile } from 'node:fs/promises'; import { setFlagsFromString } from 'node:v8'; import { runInNewContext } from 'node:vm'; +import { setThreeTextProfiler, type ThreeTextProfilePhase } from '../dist/three.js'; + import { createBenchmarkParagraph, + disposeBenchmarkParagraph, loadParagraphBenchmarkFixture, paragraphTextForGlyphs, } from './support/paragraph-benchmark-fixture.mts'; @@ -40,8 +43,16 @@ const DEFAULT_SCALES = [5_500, 11_000, 22_000, 33_000] as const; type CaseName = 'cold' | 'font-size' | 'layout-width' | 'text'; -interface Sample { - readonly durationMs: number; +interface UpdateProfile { + readonly wallMs: number; + readonly frameMs: number; + readonly prepareMs: number; + readonly engineMs: number; + readonly applyMs: number; + readonly transformsMs: number; +} + +interface Sample extends UpdateProfile { readonly glyphs: number; } @@ -56,6 +67,11 @@ interface CaseReport { readonly rsdPercent: number; readonly perGlyphUs: number; readonly bytesPerUpdate: number; + readonly frameMedianMs: number; + readonly prepareMedianMs: number; + readonly engineMedianMs: number; + readonly applyMedianMs: number; + readonly transformsMedianMs: number; } const options = parseArguments(process.argv.slice(2)); @@ -77,19 +93,26 @@ if (options.jsonPath !== undefined) { console.log(`\nwrote ${options.jsonPath}`); } -font.runtime.dispose(); font.loaded.dispose(); +font.runtime.dispose(); async function measureCase(name: CaseName, text: string): Promise { const total = options.warmup + options.repetitions; const samples: Sample[] = []; - const { runtime } = font; - // A cold case must build a fresh batch every repetition; the others measure an update to a warm one, which is what a // frame actually does. Both still run the same warmup discipline. const heapDeltas: number[] = []; + const counter = createBenchmarkParagraph(font, text, 600); + counter.group.updateMatrixWorld(true); + if (counter.group.error !== undefined) throw counter.group.error; + const calibratedGlyphs = counter.paragraph.measureLayout()?.glyphCount; + disposeBenchmarkParagraph(counter); + if (calibratedGlyphs === undefined) throw new Error('paragraph benchmark fixture did not publish a glyph count'); const warm = name === 'cold' ? undefined : createBenchmarkParagraph(font, text, 600); - if (warm !== undefined) runtime.update(); + if (warm !== undefined) { + warm.group.updateMatrixWorld(true); + if (warm.group.error !== undefined) throw warm.group.error; + } for (let repetition = 0; repetition < total; repetition += 1) { const recording = repetition >= options.warmup; @@ -99,23 +122,22 @@ async function measureCase(name: CaseName, text: string): Promise { collectGarbage(); const heapBefore = process.memoryUsage().heapUsed; - const started = performance.now(); - runtime.update(); - const durationMs = performance.now() - started; + const updated = created ?? warm!; + const profile = profileUpdate(() => updated.group.updateMatrixWorld(true)); const heapAfter = process.memoryUsage().heapUsed; - const glyphs = glyphCount(created?.batch ?? warm!.batch); - created?.batch.dispose(); + if (updated.group.error !== undefined) throw updated.group.error; + if (created !== undefined) disposeBenchmarkParagraph(created); if (recording) { - samples.push({ durationMs, glyphs }); + samples.push({ ...profile, glyphs: calibratedGlyphs }); heapDeltas.push(Math.max(0, heapAfter - heapBefore)); } } - warm?.batch.dispose(); + if (warm !== undefined) disposeBenchmarkParagraph(warm); - const durations = samples.map((sample) => sample.durationMs).sort((left, right) => left - right); + const durations = sorted(samples, 'wallMs'); const glyphs = samples[0]?.glyphs ?? 0; const mean = durations.reduce((sum, value) => sum + value, 0) / durations.length; const variance = durations.reduce((sum, value) => sum + (value - mean) ** 2, 0) / durations.length; @@ -132,9 +154,43 @@ async function measureCase(name: CaseName, text: string): Promise { rsdPercent: mean === 0 ? 0 : (Math.sqrt(variance) / mean) * 100, perGlyphUs: glyphs === 0 ? 0 : (median * 1000) / glyphs, bytesPerUpdate: bytes, + frameMedianMs: medianOf(sorted(samples, 'frameMs')), + prepareMedianMs: medianOf(sorted(samples, 'prepareMs')), + engineMedianMs: medianOf(sorted(samples, 'engineMs')), + applyMedianMs: medianOf(sorted(samples, 'applyMs')), + transformsMedianMs: medianOf(sorted(samples, 'transformsMs')), + }; +} + +function profileUpdate(update: () => void): UpdateProfile { + const durations = new Map(); + setThreeTextProfiler((phase, startedMs, endedMs) => { + durations.set(phase, (durations.get(phase) ?? 0) + endedMs - startedMs); + }); + const started = performance.now(); + try { + update(); + } finally { + setThreeTextProfiler(undefined); + } + return { + wallMs: performance.now() - started, + frameMs: durations.get('frame.total') ?? 0, + prepareMs: durations.get('frame.prepare') ?? 0, + engineMs: durations.get('engine.update') ?? 0, + applyMs: durations.get('plan.apply') ?? 0, + transformsMs: durations.get('transforms.sync') ?? 0, }; } +function sorted(samples: readonly Sample[], key: Key): number[] { + return samples.map((sample) => sample[key]).sort((left, right) => left - right); +} + +function medianOf(values: readonly number[]): number { + return values[Math.floor(values.length / 2)] ?? 0; +} + function applyChange(name: CaseName, paragraph: ParagraphHandle, repetition: number, text: string): void { // Every repetition applies a value no earlier repetition used, so a retained per-constraint cache can never answer a // measured update. A repeating cycle would let the cache serve part of the run and report a median that no drag, @@ -145,29 +201,23 @@ function applyChange(name: CaseName, paragraph: ParagraphHandle, repetition: num } else paragraph.text = `${text.slice(0, text.length - repetition)}`; } -type TextRuntimeHandle = (typeof font)['runtime']; -type ParagraphBatchHandle = ReturnType; -type ParagraphHandle = ReturnType; - -function glyphCount(batch: ParagraphBatchHandle): number { - let total = 0; - for (const paragraph of batch.current.paragraphs) total += paragraph.layout.glyphIds.length; - return total; -} +type ParagraphHandle = ReturnType['paragraph']; function printReport(rows: readonly CaseReport[]): void { console.log( `\nwarmup ${options.warmup} discarded · ${options.repetitions} recorded repetitions · budget ${budget60.toFixed(2)}ms @60Hz · ${budget120.toFixed(2)}ms @120Hz\n`, ); console.log( - `${'case'.padEnd(13)}${'glyphs'.padStart(8)}${'median'.padStart(10)}${'p95'.padStart(10)}${'min'.padStart(9)}${'rsd'.padStart(7)}${'µs/glyph'.padStart(10)}${'B/glyph'.padStart(9)} budget`, + `${'case'.padEnd(13)}${'glyphs'.padStart(8)}${'prepare'.padStart(10)}${'engine'.padStart(10)}${'apply'.padStart(10)}${'frame'.padStart(10)}${'wall'.padStart(10)}${'wall p95'.padStart(11)}${'rsd'.padStart(7)} budget`, ); for (const row of rows) { const over = row.medianMs / budget120; console.log( - `${row.name.padEnd(13)}${String(row.glyphs).padStart(8)}${`${row.medianMs.toFixed(2)}ms`.padStart(10)}${`${row.p95Ms.toFixed(2)}ms`.padStart(10)}${`${row.minMs.toFixed(2)}ms`.padStart(9)}${`${row.rsdPercent.toFixed(1)}%`.padStart(7)}${row.perGlyphUs.toFixed(3).padStart(10)}${(row.bytesPerUpdate / Math.max(1, row.glyphs)).toFixed(0).padStart(9)} ${over <= 1 ? 'within 120Hz' : `${over.toFixed(1)}x over 120Hz`}`, + `${row.name.padEnd(13)}${String(row.glyphs).padStart(8)}${`${row.prepareMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.engineMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.applyMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.frameMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.medianMs.toFixed(2)}ms`.padStart(10)}${`${row.p95Ms.toFixed(2)}ms`.padStart(11)}${`${row.rsdPercent.toFixed(1)}%`.padStart(7)} ${over <= 1 ? 'within 120Hz' : `${over.toFixed(1)}x over 120Hz`}`, ); } + console.log('\nThis public Three diagnostic is not the canonical Rust-vs-TypeScript comparison.'); + console.log('Use text:rust-layout-benchmark for the unchanged complete text_update + render-plan metric.'); } function parseArguments(argv: readonly string[]) { diff --git a/packages/text/scripts/support/engine-kernel-fixture.mts b/packages/text/scripts/support/engine-kernel-fixture.mts index 0e1ecbb8..2be8fff3 100644 --- a/packages/text/scripts/support/engine-kernel-fixture.mts +++ b/packages/text/scripts/support/engine-kernel-fixture.mts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { createBenchmarkParagraph, + disposeBenchmarkParagraph, loadParagraphBenchmarkFixture, paragraphTextForGlyphs, } from './paragraph-benchmark-fixture.mts'; @@ -32,8 +33,8 @@ export async function captureKernelWorkloads(targets: readonly number[]): Promis try { return targets.map((target) => captureWorkload(fixture, target, policy)); } finally { - fixture.runtime.dispose(); fixture.loaded.dispose(); + fixture.runtime.dispose(); } } @@ -44,8 +45,9 @@ function captureWorkload( ): CapturedKernelInput { const text = paragraphTextForGlyphs(targetGlyphs); const created = createBenchmarkParagraph(fixture, text, 600); - fixture.runtime.update(); - const layout = created.paragraph.committed?.layout; + created.group.updateMatrixWorld(true); + if (created.group.error !== undefined) throw created.group.error; + const layout = created.paragraph.inspectLayout(); if (layout === undefined) throw new Error('paragraph benchmark fixture did not publish a layout'); const glyphs = layout.glyphIds.length; const planeLeft = new Float32Array(glyphs); @@ -89,6 +91,6 @@ function captureWorkload( levels, policy, }; - created.batch.dispose(); + disposeBenchmarkParagraph(created); return captured; } diff --git a/packages/text/scripts/support/paragraph-benchmark-fixture.mts b/packages/text/scripts/support/paragraph-benchmark-fixture.mts index 910f0164..5c701614 100644 --- a/packages/text/scripts/support/paragraph-benchmark-fixture.mts +++ b/packages/text/scripts/support/paragraph-benchmark-fixture.mts @@ -1,7 +1,8 @@ import { readFile } from 'node:fs/promises'; -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '../../dist/index.js'; -import { bitmap } from '../../dist/raster/bitmap-technique.js'; +import { createTextRuntime, FontRegistry } from '../../dist/index.js'; +import { Text, TextGroup } from '../../dist/three.js'; +import { bitmap } from '../../dist/three/bitmap.js'; export const paragraphBenchmarkSource = [ 'Typography is a moving system. AVATAR To Wa Yo repeat familiar kerning pairs while a responsive panel changes the space around them. The quick visual check is useful, but the benchmark records the cost of shaping, layout, upload, and every rendered frame.', @@ -12,11 +13,10 @@ export const paragraphBenchmarkSource = [ export async function loadParagraphBenchmarkFixture() { const workspaceRoot = new URL('../../../../', import.meta.url); const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('packages/text/dist/text_shaper.wasm', workspaceRoot)), }); - const runtime = await createTextRuntime({ registry, shaper }); const bytes = await readFile(new URL('apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', workspaceRoot)); const loaded = await runtime.loadFont({ input: { baked: `data:application/octet-stream;base64,${bytes.toString('base64')}` }, @@ -30,14 +30,20 @@ export function createBenchmarkParagraph( text: string, width: number, ) { - const batch = fixture.runtime.createParagraphBatch({ technique: bitmap }); - const paragraph = batch.add({ + const group = new TextGroup({ capacity: { size: Math.max(256, text.length), policy: 'grow' } }); + const paragraph = new Text({ font: fixture.loaded, text, contentBox: { width: { mode: 'exact', size: width }, wrap: 'word' }, style: { fontSize: 24 }, }); - return { batch, paragraph }; + group.add(paragraph); + return { group, paragraph }; +} + +export function disposeBenchmarkParagraph(created: ReturnType): void { + created.group.dispose(); + created.paragraph.dispose(); } export function paragraphTextForGlyphs(target: number): string { diff --git a/packages/text/src/formatted-text.ts b/packages/text/src/formatted-text.ts index 25bc8b59..1ce84cf3 100644 --- a/packages/text/src/formatted-text.ts +++ b/packages/text/src/formatted-text.ts @@ -1,6 +1,6 @@ import { statedProperties } from './internal/span-cascade.js'; import type { FontSelection } from './loaded-font.js'; -import type { ParagraphStyle } from './paragraph.js'; +import type { ParagraphStyle } from './text-properties.js'; import type { AnyRasterTechnique } from './raster-technique.js'; declare const textLiteralTechnique: unique symbol; @@ -16,13 +16,12 @@ export interface GlyphPaintInput { readonly shadow?: { readonly color: ColorInput; readonly offset: readonly [number, number] }; } -export interface ParagraphSpan { +export interface ParagraphSpan { readonly start: number; readonly end: number; readonly font?: FontSelection; readonly style?: ParagraphStyle; readonly paint?: GlyphPaintInput; - readonly renderVariant?: Variant; } export interface TextLiteral { diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index ac6e562f..36696562 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -1,37 +1,5 @@ // Generated from Rust compiler layout facts. Do not edit. export const textShaperAbi = { - "bidi": { - "classes": { - "AL": 2, - "AN": 6, - "B": 10, - "BN": 9, - "CS": 7, - "EN": 3, - "ES": 4, - "ET": 5, - "FSI": 21, - "L": 0, - "LRE": 14, - "LRI": 19, - "LRO": 15, - "NSM": 8, - "ON": 13, - "PDF": 18, - "PDI": 22, - "R": 1, - "RLE": 16, - "RLI": 20, - "RLO": 17, - "S": 11, - "WS": 12 - }, - "directions": { - "auto": 0, - "ltr": 1, - "rtl": 2 - } - }, "endianness": "little", "engine": { "axisModes": { @@ -216,7 +184,6 @@ export const textShaperAbi = { }, "functions": { "allocate": "pmndrs_text_shaper_alloc", - "analyzeBidi": "pmndrs_text_shaper_analyze_bidi", "createSession": "pmndrs_text_engine_create_session", "deallocate": "pmndrs_text_shaper_dealloc", "disposeFont": "pmndrs_text_shaper_dispose_font", @@ -236,34 +203,11 @@ export const textShaperAbi = { "requestCapacity": "pmndrs_text_engine_request_capacity", "requestPointer": "pmndrs_text_engine_request_ptr", "reserveSession": "pmndrs_text_engine_reserve_session", - "reshapeRanges": "pmndrs_text_shaper_reshape_ranges", - "resultLength": "pmndrs_text_shaper_result_len", - "resultPointer": "pmndrs_text_shaper_result_ptr", "retainedFontBytes": "pmndrs_text_shaper_retained_font_bytes", "sessionCount": "pmndrs_text_engine_session_count", - "shapeBatch": "pmndrs_text_shaper_shape_batch", "textUpdate": "pmndrs_text_engine_update" }, "layouts": { - "bidiRequest": { - "alignment": 4, - "direction": 8, - "size": 12, - "textLength": 4, - "textOffset": 0 - }, - "bidiResult": { - "alignment": 4, - "byteLength": 0, - "classesOffset": 8, - "levelsOffset": 4, - "paragraphCount": 28, - "paragraphEndsOffset": 20, - "paragraphLevelsOffset": 24, - "paragraphStartsOffset": 16, - "size": 32, - "textLength": 12 - }, "engineBuffer": { "alignment": 4, "byteLength": 28, @@ -749,67 +693,6 @@ export const textShaperAbi = { "programCount": 16, "programsOffset": 12, "size": 44 - }, - "reshapeRange": { - "alignment": 4, - "contextEnd": 16, - "contextStart": 12, - "flags": 20, - "itemEnd": 8, - "itemStart": 4, - "run": 0, - "size": 24 - }, - "reshapeRequest": { - "alignment": 4, - "rangeCount": 36, - "rangesOffset": 32, - "size": 40 - }, - "result": { - "alignment": 4, - "byteLength": 0, - "clustersOffset": 32, - "fontHandleCount": 8, - "fontHandlesOffset": 4, - "glyphCount": 56, - "glyphFlagsOffset": 52, - "glyphIdsOffset": 28, - "runCount": 24, - "runFontSlotsOffset": 12, - "runGlyphCountsOffset": 20, - "runGlyphStartsOffset": 16, - "size": 60, - "xAdvancesOffset": 36, - "xOffsetsOffset": 44, - "yAdvancesOffset": 40, - "yOffsetsOffset": 48 - }, - "run": { - "alignment": 4, - "clusterLevel": 27, - "direction": 26, - "featureCount": 24, - "featureStart": 20, - "flags": 28, - "fontHandle": 0, - "languageOffset": 16, - "script": 12, - "size": 32, - "textEnd": 8, - "textStart": 4 - }, - "shapeRequest": { - "alignment": 4, - "featureCount": 20, - "featuresOffset": 16, - "languagesLength": 28, - "languagesOffset": 24, - "runCount": 12, - "runsOffset": 8, - "size": 32, - "textLength": 4, - "textOffset": 0 } }, "memory": "memory", diff --git a/packages/text/src/index.ts b/packages/text/src/index.ts index c814640b..331b3db1 100644 --- a/packages/text/src/index.ts +++ b/packages/text/src/index.ts @@ -59,38 +59,14 @@ export type { FontSelection, FontStack, LoadedFont } from './loaded-font.js'; export { createFontStack, FontLeaseError } from './loaded-font.js'; export type { - GlyphBatchKey, GlyphBufferCapacity, - GlyphCapacityOverflow, - GlyphOriginUpdate, - GlyphSnapshot, - GlyphTopology, - Paragraph, ParagraphAxisConstraint, ParagraphBaseProperties, - ParagraphBatch, - ParagraphBatchObserver, - ParagraphBatchOptions, ParagraphContentBox, ParagraphContentProperties, - ParagraphId, ParagraphProperties, - ParagraphSnapshot, - ParagraphUpdate, - PreparedGlyphBatch, - PreparedGlyphRun, - PreparedParagraph, - PreparedParagraphBatchRevision, - TextPreparationError, -} from './paragraph-batch.js'; -export type { - ParagraphBatchAttachment, - ParagraphBatchTarget, - ParagraphBatchTargetError, - ParagraphBatchTargetRevision, - ParagraphBatchTargetStage, - ParagraphBatchTargetUpdate, -} from './paragraph-batch-attachment.js'; + ParagraphStyle, +} from './text-properties.js'; export type { ColorInput, @@ -112,17 +88,6 @@ export { SpanNestingError } from './internal/span-cascade.js'; export type { GlyphPaint, LinearRgba, ResolvedPaint } from './paint.js'; -export type { - ParagraphConstraints, - ParagraphEngine, - ParagraphEngineOptions, - ParagraphInput, - ParagraphStyle, -} from './paragraph.js'; -export type { Paragraph as LayoutParagraph } from './paragraph.js'; -export type { ParagraphAxisConstraint as LayoutParagraphAxisConstraint } from './paragraph.js'; -export { createParagraphEngine } from './paragraph.js'; - export type { AnyRasterModule, AnyRasterInput, @@ -188,33 +153,7 @@ export { normalizeRasterCoverage, } from './raster-coverage.js'; -export type { - BidiAnalysisViews, - BidiDirection, - RuntimeShaper, - RuntimeShaperMemoryReport, - RuntimeShaperOptions, - ReshapeBatchRequest, - ReshapeRange, - ShapeBatchRequest, - ShapeRunRequest, - ShapedBatchViews, - TextShaperWasmSource, -} from './shaper.js'; -export { createRuntimeShaper } from './shaper.js'; export type { FontFeature, ResolvedFontFeature } from './font-feature.js'; -export type { - AsyncTextUpdateOptions, - LoadedFontInput, - LoadedFontRequest, - TextPreparationWorker, - TextRuntime, - TextRuntimeOptions, - TextRuntimeRevision, - TextUpdateCallback, - TextUpdateOutcome, - TextUpdateProgress, - TextUpdateResult, -} from './text-runtime.js'; -export { createTextPreparationWorker, createTextRuntime } from './text-runtime.js'; +export type { LoadedFontInput, LoadedFontRequest, TextRuntime, TextRuntimeOptions } from './text-runtime.js'; +export { createTextRuntime } from './text-runtime.js'; diff --git a/packages/text/src/internal/text-preparation-worker-protocol.ts b/packages/text/src/internal/text-preparation-worker-protocol.ts deleted file mode 100644 index c8876f37..00000000 --- a/packages/text/src/internal/text-preparation-worker-protocol.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { FontHandle } from '../identity.js'; -import type { ParagraphLayout } from '../layout.js'; -import type { RuntimeShaperFontData } from '../shaper.js'; -import type { WorkerParagraphLayoutInput } from '../paragraph-batch.js'; - -export interface TextPreparationRequestV1 { - readonly type: 'pmndrs-text-prepare-v1'; - readonly id: number; - readonly fonts: readonly RuntimeShaperFontData[]; - readonly paragraphs: readonly { - readonly batch: number; - readonly paragraph: number; - readonly input: WorkerParagraphLayoutInput; - }[]; -} - -export interface TextPreparationCancelV1 { - readonly type: 'pmndrs-text-cancel-v1'; - readonly id: number; -} - -export interface TextPreparationProgressV1 { - readonly type: 'pmndrs-text-progress-v1'; - readonly id: number; - readonly preparedParagraphs: number; - readonly totalParagraphs: number; - readonly stagedGlyphs: number; -} - -export interface TextPreparationSuccessV1 { - readonly type: 'pmndrs-text-success-v1'; - readonly id: number; - readonly layouts: readonly { - readonly batch: number; - readonly paragraph: number; - readonly layout: ParagraphLayout; - }[]; -} - -export interface TextPreparationFailureV1 { - readonly type: 'pmndrs-text-failure-v1'; - readonly id: number; - readonly error: Readonly<{ name: string; message: string; stack?: string }>; -} - -export type TextPreparationWorkerMessageV1 = TextPreparationRequestV1 | TextPreparationCancelV1; -export type TextPreparationWorkerResultV1 = - | TextPreparationProgressV1 - | TextPreparationSuccessV1 - | TextPreparationFailureV1; - -export function isTextPreparationWorkerResultV1(value: unknown): value is TextPreparationWorkerResultV1 { - if (typeof value !== 'object' || value === null || !('type' in value) || !('id' in value)) return false; - const candidate = value as Record; - if (!nonnegativeInteger(candidate.id)) return false; - if (candidate.type === 'pmndrs-text-progress-v1') - return ( - nonnegativeInteger(candidate.preparedParagraphs) && - nonnegativeInteger(candidate.totalParagraphs) && - nonnegativeInteger(candidate.stagedGlyphs) && - candidate.preparedParagraphs <= candidate.totalParagraphs - ); - if (candidate.type === 'pmndrs-text-failure-v1') { - if (typeof candidate.error !== 'object' || candidate.error === null) return false; - const error = candidate.error as Record; - return ( - typeof error.name === 'string' && - typeof error.message === 'string' && - (error.stack === undefined || typeof error.stack === 'string') - ); - } - if (candidate.type !== 'pmndrs-text-success-v1' || !Array.isArray(candidate.layouts)) return false; - return candidate.layouts.every((entry: unknown) => { - if (typeof entry !== 'object' || entry === null) return false; - const layout = entry as Record; - return nonnegativeInteger(layout.batch) && nonnegativeInteger(layout.paragraph) && isParagraphLayout(layout.layout); - }); -} - -export function fontHandle(value: number): FontHandle { - return value as FontHandle; -} - -function isParagraphLayout(value: unknown): value is ParagraphLayout { - if (typeof value !== 'object' || value === null) return false; - const layout = value as Record; - for (const field of ['width', 'height', 'contentWidth', 'contentHeight', 'firstBaseline', 'lastBaseline']) - if (typeof layout[field] !== 'number' || !Number.isFinite(layout[field])) return false; - if (typeof layout.overflowed !== 'boolean') return false; - return ( - layout.fontHandles instanceof Uint32Array && - layout.glyphFontSlots instanceof Uint16Array && - layout.glyphIds instanceof Uint16Array && - layout.clusters instanceof Uint32Array && - layout.glyphFontSizes instanceof Float32Array && - layout.x instanceof Float32Array && - layout.y instanceof Float32Array && - layout.glyphFlags instanceof Uint16Array && - layout.lineTextStarts instanceof Uint32Array && - layout.lineTextEnds instanceof Uint32Array && - layout.lineGlyphStarts instanceof Uint32Array && - layout.lineGlyphCounts instanceof Uint32Array && - layout.lineBaselines instanceof Float32Array && - layout.lineAdvances instanceof Float32Array - ); -} - -function nonnegativeInteger(value: unknown): value is number { - return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0; -} diff --git a/packages/text/src/loader.ts b/packages/text/src/loader.ts index 70f7be12..17ff65d5 100644 --- a/packages/text/src/loader.ts +++ b/packages/text/src/loader.ts @@ -24,7 +24,6 @@ import type { RegisteredRaster, } from './raster.js'; import type { BakeProgressListener } from './bake.js'; -import type { RuntimeShaperFontData } from './shaper.js'; const DEFAULT_MAX_ARTIFACT_BYTES = 64 * 1024 * 1024; const DEFAULT_MAX_BUFFER_VIEWS = 4_096; @@ -135,38 +134,6 @@ export class FontRegistry { return this.#fontsByHandle.get(handle); } - /** @internal Register a shaping-only font replica inside a preparation Worker. */ - _registerShapingFont(data: RuntimeShaperFontData): RegisteredFont { - const existing = this.#fontsByHandle.get(data.handle); - if (existing !== undefined) { - if (existing.shapingHash !== data.shapingHash) - throw new TypeError('Worker font handle is already registered with another shaping identity'); - return existing; - } - const font = new RegisteredFontImpl({ - registry: this, - key: data.key, - handle: data.handle, - shapingHash: data.shapingHash, - glyphCount: data.glyphCount, - metrics: data.metrics, - }); - setRegisteredFontData(font, { - fontFaceIndex: data.fontFaceIndex, - sourceHash: data.sourceHash, - sourceCandidates: [], - shapingSfnt: data.shapingSfnt, - glyphExtents: data.glyphExtents, - glyphExtentsAvailability: data.glyphExtentsAvailability, - rasterSources: new Map(), - unicodeVersion: data.unicodeVersion, - }); - this.#fontsByKey.set(data.key, font); - this.#fontsByHash.set(data.shapingHash, font); - this.#fontsByHandle.set(data.handle, font); - return font; - } - /** @internal */ async _registerAsset(bytes: ArrayBufferView, context: FontAssetContext = {}): Promise { this.#checkArtifactSize(bytes.byteLength); diff --git a/packages/text/src/paragraph-batch-attachment.ts b/packages/text/src/paragraph-batch-attachment.ts deleted file mode 100644 index e97e724b..00000000 --- a/packages/text/src/paragraph-batch-attachment.ts +++ /dev/null @@ -1,220 +0,0 @@ -import type { ParagraphBatch, ParagraphBatchObserver, PreparedParagraphBatchRevision } from './paragraph-batch.js'; -import type { AnyRasterTechnique } from './raster-technique.js'; - -export interface ParagraphBatchTargetRevision { - readonly sourceRevision: number; - dispose(): void; -} - -export interface ParagraphBatchTargetStage { - readonly sourceRevision: number; - commit(): TargetRevision; - abort(): void; -} - -export type ParagraphBatchTargetUpdate = - | { readonly status: 'ready'; readonly stage: ParagraphBatchTargetStage } - | { - readonly status: 'pending'; - readonly ready: Promise>; - cancel(reason?: unknown): void; - }; - -export interface ParagraphBatchTarget< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, -> { - readonly technique: Technique; - stage( - previous: TargetRevision | undefined, - next: PreparedParagraphBatchRevision, - options?: { readonly signal?: AbortSignal }, - ): ParagraphBatchTargetUpdate; - dispose(): void; -} - -export interface ParagraphBatchTargetError { - readonly kind: 'target-failed'; - readonly sourceRevision: number; - readonly cause: unknown; -} - -export interface ParagraphBatchAttachment< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, -> { - readonly source: PreparedParagraphBatchRevision; - readonly current: TargetRevision | undefined; - readonly candidate: ParagraphBatchTargetStage | undefined; - readonly error: ParagraphBatchTargetError | undefined; - prepare(): void; - commit(): TargetRevision | undefined; - retry(): void; - dispose(): void; -} - -interface PendingStage { - readonly revision: number; - readonly cancel: (reason?: unknown) => void; -} - -export function attachParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, ->( - batch: ParagraphBatch, - target: ParagraphBatchTarget, -): ParagraphBatchAttachment { - if (target.technique !== batch.technique) throw new TypeError('paragraph batch target uses another raster technique'); - return new ParagraphBatchAttachmentImpl(batch, target); -} - -class ParagraphBatchAttachmentImpl< - Technique extends AnyRasterTechnique, - Variant, - TargetRevision extends ParagraphBatchTargetRevision, -> implements ParagraphBatchAttachment { - readonly #target: ParagraphBatchTarget; - readonly #unsubscribe: () => void; - #source: PreparedParagraphBatchRevision; - #current: TargetRevision | undefined; - #candidate: ParagraphBatchTargetStage | undefined; - #pending: PendingStage | undefined; - #error: ParagraphBatchTargetError | undefined; - #attemptedRevision = -1; - #disposed = false; - - constructor( - batch: ParagraphBatch, - target: ParagraphBatchTarget, - ) { - this.#target = target; - this.#source = batch.current; - const observer: ParagraphBatchObserver = { - next: (source) => { - this.#source = source; - }, - complete: () => this.dispose(), - }; - this.#unsubscribe = batch.subscribe(observer); - } - - get source(): PreparedParagraphBatchRevision { - return this.#source; - } - get current(): TargetRevision | undefined { - return this.#current; - } - get candidate(): ParagraphBatchTargetStage | undefined { - return this.#candidate; - } - get error(): ParagraphBatchTargetError | undefined { - return this.#error; - } - - prepare(): void { - this.#assertActive(); - const sourceRevision = this.#source.revision; - if (this.#current?.sourceRevision === sourceRevision) return; - if (this.#candidate?.sourceRevision === sourceRevision) return; - if (this.#pending?.revision === sourceRevision) return; - if (this.#attemptedRevision === sourceRevision) return; - this.#discardCandidate(new DOMException('A newer source revision is being prepared', 'AbortError')); - this.#attemptedRevision = sourceRevision; - const controller = new AbortController(); - let update: ParagraphBatchTargetUpdate; - try { - update = this.#target.stage(this.#current, this.#source, { signal: controller.signal }); - } catch (cause) { - this.#recordError(sourceRevision, cause); - return; - } - if (update.status === 'ready') { - this.#acceptStage(sourceRevision, update.stage); - return; - } - this.#pending = { - revision: sourceRevision, - cancel: (reason) => { - controller.abort(reason); - update.cancel(reason); - }, - }; - void update.ready.then( - (stage) => { - if (this.#disposed || this.#pending?.revision !== sourceRevision || this.#source.revision !== sourceRevision) { - stage.abort(); - return; - } - this.#pending = undefined; - this.#acceptStage(sourceRevision, stage); - }, - (cause: unknown) => { - if (this.#pending?.revision !== sourceRevision) return; - this.#pending = undefined; - if (!this.#disposed && this.#source.revision === sourceRevision) this.#recordError(sourceRevision, cause); - }, - ); - } - - commit(): TargetRevision | undefined { - this.#assertActive(); - const stage = this.#candidate; - if (stage === undefined || stage.sourceRevision !== this.#source.revision) return this.#current; - const next = stage.commit(); - if (next.sourceRevision !== stage.sourceRevision) { - next.dispose(); - throw new TypeError('paragraph batch target committed the wrong source revision'); - } - const previous = this.#current; - this.#candidate = undefined; - this.#current = next; - this.#error = undefined; - previous?.dispose(); - return next; - } - - retry(): void { - this.#assertActive(); - if (this.#pending?.revision === this.#source.revision) return; - this.#attemptedRevision = -1; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#unsubscribe(); - this.#discardCandidate(new DOMException('Paragraph batch attachment was disposed', 'AbortError')); - this.#current?.dispose(); - this.#current = undefined; - this.#target.dispose(); - } - - #acceptStage(sourceRevision: number, stage: ParagraphBatchTargetStage): void { - if (stage.sourceRevision !== sourceRevision) { - stage.abort(); - this.#recordError(sourceRevision, new TypeError('paragraph batch target staged the wrong source revision')); - return; - } - this.#candidate = stage; - this.#error = undefined; - } - - #discardCandidate(reason: unknown): void { - this.#pending?.cancel(reason); - this.#pending = undefined; - this.#candidate?.abort(); - this.#candidate = undefined; - } - - #recordError(sourceRevision: number, cause: unknown): void { - this.#error = Object.freeze({ kind: 'target-failed', sourceRevision, cause }); - } - - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph batch attachment has been disposed'); - } -} diff --git a/packages/text/src/paragraph-batch.ts b/packages/text/src/paragraph-batch.ts deleted file mode 100644 index 54b782cd..00000000 --- a/packages/text/src/paragraph-batch.ts +++ /dev/null @@ -1,1630 +0,0 @@ -import { - acquireFontSelection, - assertFontSelection, - concreteFonts, - releaseFontSelection, - type FontSelection, - type LoadedFont, -} from './loaded-font.js'; -import type { ParagraphLayout } from './layout.js'; -import type { FontHandle } from './identity.js'; -import { - createParagraphEngine, - type Paragraph as EngineParagraph, - type ParagraphEngine, - type ParagraphStyle, -} from './paragraph.js'; -import type { ResolvedPaint } from './paint.js'; -import type { - AnyRasterTechnique, - GlyphBatchStorageOf, - GlyphRange, - RasterBindingOf, - RasterGlyphInput, - RasterGlyphSelection, - RasterResourceId, - RasterTechniqueId, -} from './raster-technique.js'; -import type { RuntimeShaper } from './shaper.js'; -import type { TextRuntime } from './text-runtime.js'; -import type { FormattedText, GlyphPaintInput, ParagraphSpan, TextInput } from './formatted-text.js'; -import { - assertSpanNesting, - resolveSpanCascade, - statedProperties, - type SpanCascadeSegment, -} from './internal/span-cascade.js'; -import { - attachParagraphBatch, - type ParagraphBatchAttachment, - type ParagraphBatchTarget, - type ParagraphBatchTargetRevision, -} from './paragraph-batch-attachment.js'; - -declare const paragraphIdBrand: unique symbol; -declare const glyphTopologyBrand: unique symbol; - -export type ParagraphId = number & { readonly [paragraphIdBrand]: true }; -export type GlyphTopology = number & { readonly [glyphTopologyBrand]: true }; - -export interface GlyphBufferCapacity { - readonly size: number; - readonly policy: 'grow' | 'chunk' | 'fixed'; -} - -export type ParagraphAxisConstraint = - | { readonly mode: 'unconstrained' } - | { readonly mode: 'at-most'; readonly size: number } - | { readonly mode: 'exact'; readonly size: number }; - -export interface ParagraphContentBox { - readonly width?: ParagraphAxisConstraint; - readonly height?: ParagraphAxisConstraint; - readonly maxLines?: number; - readonly wrap?: 'none' | 'word' | 'character'; - readonly align?: 'start' | 'center' | 'end' | 'justify'; - readonly overflow?: 'visible' | 'clip' | 'ellipsis'; -} - -export interface ParagraphBaseProperties { - readonly font: FontSelection; - readonly contentBox?: ParagraphContentBox; - readonly style?: ParagraphStyle; - readonly paint?: GlyphPaintInput; - readonly rasterPixelRatio?: number; - readonly order?: number; - readonly renderVariant?: Variant; -} - -export type ParagraphContentProperties = - | Readonly<{ text: string; spans?: readonly ParagraphSpan[] }> - | Readonly<{ text: FormattedText; spans?: never }>; - -export type ParagraphProperties = ParagraphBaseProperties< - Technique, - Variant -> & - ParagraphContentProperties; - -export type ParagraphUpdate = - | (Partial> & - Readonly<{ text?: string; spans?: readonly ParagraphSpan[] }>) - | (Partial> & - Readonly<{ text: FormattedText; spans?: never }>); - -export interface ParagraphSnapshot { - readonly font: FontSelection; - readonly text: string; - readonly spans: readonly ParagraphSpan[]; - readonly contentBox: ParagraphContentBox; - readonly style: ParagraphStyle; - readonly paint: GlyphPaintInput; - readonly rasterPixelRatio: number; - readonly order: number; - readonly renderVariant: Variant | undefined; -} - -export interface GlyphSnapshot { - readonly topology: GlyphTopology; - readonly glyphIds: Uint32Array; - readonly clusters: Uint32Array; - readonly fontSlots: Uint16Array; - readonly shapedX: Float32Array; - readonly shapedY: Float32Array; - readonly displayedX: Float32Array; - readonly displayedY: Float32Array; -} - -export interface GlyphOriginUpdate { - readonly topology: GlyphTopology; - readonly x: Float32Array; - readonly y: Float32Array; -} - -export interface PreparedParagraph { - readonly id: ParagraphId; - readonly layout: ParagraphLayout; - readonly topology: GlyphTopology; -} - -export interface GlyphBatchKey { - readonly technique: RasterTechniqueId; - readonly resource: RasterResourceId; - readonly pipelineVariant: number; - readonly generation: number; - readonly chunk: number; -} - -export interface PreparedGlyphBatch { - readonly key: GlyphBatchKey; - readonly technique: Technique; - readonly font: LoadedFont; - readonly capacity: number; - readonly instanceCount: number; - readonly binding: RasterBindingOf; - readonly storage: GlyphBatchStorageOf; - readonly dirtyRanges: readonly GlyphRange[]; -} - -export interface PreparedGlyphRun { - readonly batch: GlyphBatchKey; - readonly paragraph: ParagraphId; - readonly renderVariant: Variant | undefined; - readonly start: number; - readonly count: number; -} - -export interface PreparedParagraphBatchRevision { - readonly paragraphBatch: ParagraphBatch; - readonly revision: number; - readonly technique: Technique; - readonly paragraphs: readonly PreparedParagraph[]; - readonly glyphBatches: readonly PreparedGlyphBatch[]; - readonly glyphRuns: readonly PreparedGlyphRun[]; -} - -export interface GlyphCapacityOverflow { - readonly resourceKey: GlyphBatchKey; - readonly required: number; -} - -export type TextPreparationError = - | { - readonly kind: 'capacity-exceeded'; - readonly batch: ParagraphBatch; - readonly capacity: number; - readonly required: number; - readonly overflows: readonly GlyphCapacityOverflow[]; - } - | { readonly kind: 'preparation-failed'; readonly cause: unknown }; - -export interface ParagraphBatchOptions { - readonly technique: Technique; - readonly capacity?: GlyphBufferCapacity; - readonly rasterPixelRatio?: number; - readonly renderVariant?: Variant; -} - -export interface ParagraphBatchObserver { - next(revision: PreparedParagraphBatchRevision): void; - complete(): void; -} - -export interface Paragraph { - readonly id: ParagraphId; - readonly batch: ParagraphBatch; - readonly disposed: boolean; - readonly committed: PreparedParagraph | undefined; - font: FontSelection; - text: TextInput; - spans: readonly ParagraphSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - order: number; - renderVariant: Variant | undefined; - set(properties: ParagraphUpdate): void; - setSpan(index: number, span: ParagraphSpan): void; - removeSpan(index: number): void; - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - snapshotProperties(): ParagraphSnapshot; - dispose(): void; -} - -export interface ParagraphBatch { - readonly runtime: TextRuntime; - readonly technique: Technique; - readonly capacity: GlyphBufferCapacity; - readonly current: PreparedParagraphBatchRevision; - readonly paragraphCount: number; - readonly hasPendingChanges: boolean; - readonly preparationError: TextPreparationError | undefined; - readonly disposed: boolean; - rasterPixelRatio: number; - renderVariant: Variant | undefined; - add(properties: ParagraphProperties): Paragraph; - setCapacity(capacity: GlyphBufferCapacity): void; - has(paragraph: Paragraph): boolean; - subscribe(observer: ParagraphBatchObserver): () => void; - attach( - target: ParagraphBatchTarget, - ): ParagraphBatchAttachment; - dispose(): void; -} - -export interface ParagraphBatchHost { - readonly runtime: TextRuntime; - readonly shaper: RuntimeShaper; - dirty(): void; - remove(batch: ParagraphBatchController): void; -} - -export interface ParagraphBatchController { - readonly publicBatch: ParagraphBatch; - readonly dirty: boolean; - capture(): ParagraphBatchPreparation; - prepare( - snapshot: ParagraphBatchPreparation, - layouts?: ReadonlyMap, - ): PreparedParagraphBatchCandidate; - publish(candidate: PreparedParagraphBatchCandidate): void; - discard(candidate: PreparedParagraphBatchCandidate): void; - release(snapshot: ParagraphBatchPreparation): void; -} - -export interface ParagraphBatchPreparation { - readonly controller: ParagraphBatchController; - readonly desiredRevision: number; - readonly paragraphs: readonly CapturedParagraph[]; - readonly layouts: readonly ParagraphLayoutPreparation[]; - readonly capacity: GlyphBufferCapacity; - readonly capacityChanged: boolean; - readonly rasterPixelRatio: number; - readonly renderVariant: unknown; - readonly previous: PreparedParagraphBatchRevision; - readonly leases: readonly LoadedFont[]; -} - -export interface ParagraphLayoutPreparation { - readonly paragraph: ParagraphId; - readonly input?: WorkerParagraphLayoutInput; -} - -export interface PreparedParagraphBatchCandidate { - readonly snapshot: ParagraphBatchPreparation; - readonly revision: PreparedParagraphBatchRevision; - readonly paragraphs: readonly PreparedOwnedParagraph[]; -} - -const DEFAULT_CAPACITY = Object.freeze({ size: 4_096, policy: 'chunk' as const }); -const EMPTY_BOX = Object.freeze({}); -const EMPTY_STYLE = Object.freeze({}); -const EMPTY_PAINT = Object.freeze({}); - -export function createParagraphBatch( - host: ParagraphBatchHost, - options: ParagraphBatchOptions, -): ParagraphBatchController { - return new ParagraphBatchImpl(host, options) as ParagraphBatchController; -} - -class ParagraphBatchImpl - implements ParagraphBatch, ParagraphBatchController -{ - readonly runtime: TextRuntime; - readonly technique: Technique; - readonly #host: ParagraphBatchHost; - readonly #paragraphs = new Set>(); - readonly #observers = new Set>(); - readonly #spareStorage = new Map>(); - readonly #glyphInputs: MutableGlyphInput['data']>[] = []; - #capacity: GlyphBufferCapacity; - #rasterPixelRatio: number; - #renderVariant: Variant | undefined; - #revision = 0; - #desiredRevision = 0; - #capacityChanged = false; - #nextParagraphId = 1; - #dirty = false; - #disposed = false; - #preparationError: TextPreparationError | undefined; - #current: PreparedParagraphBatchRevision; - - constructor(host: ParagraphBatchHost, options: ParagraphBatchOptions) { - if (options === undefined || options.technique === undefined) - throw new TypeError('paragraph batch requires a technique'); - this.#host = host; - this.runtime = host.runtime; - this.technique = options.technique; - this.#capacity = normalizeCapacity(options.capacity ?? DEFAULT_CAPACITY); - this.#rasterPixelRatio = positive(options.rasterPixelRatio ?? 1, 'rasterPixelRatio'); - this.#renderVariant = options.renderVariant; - this.#current = Object.freeze({ - paragraphBatch: this, - revision: 0, - technique: this.technique, - paragraphs: Object.freeze([]), - glyphBatches: Object.freeze([]), - glyphRuns: Object.freeze([]), - }); - } - - get publicBatch(): ParagraphBatch { - return this as ParagraphBatch; - } - get capacity(): GlyphBufferCapacity { - return this.#capacity; - } - get current(): PreparedParagraphBatchRevision { - return this.#current; - } - get paragraphCount(): number { - return this.#paragraphs.size; - } - get hasPendingChanges(): boolean { - return this.#dirty; - } - get dirty(): boolean { - return this.#dirty; - } - get preparationError(): TextPreparationError | undefined { - return this.#preparationError; - } - get disposed(): boolean { - return this.#disposed; - } - get rasterPixelRatio(): number { - return this.#rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - const next = positive(value, 'rasterPixelRatio'); - if (next !== this.#rasterPixelRatio) { - this.#rasterPixelRatio = next; - this.#markDirty(); - } - } - get renderVariant(): Variant | undefined { - return this.#renderVariant; - } - set renderVariant(value: Variant | undefined) { - if (!Object.is(value, this.#renderVariant)) { - this.#renderVariant = value; - this.#markDirty(); - } - } - - add(properties: ParagraphProperties): Paragraph { - this.#assertActive(); - const paragraph = new ParagraphImpl(this, this.#nextParagraphId++ as ParagraphId, properties); - this.#paragraphs.add(paragraph); - this.#markDirty(); - return paragraph; - } - - setCapacity(value: GlyphBufferCapacity): void { - this.#assertActive(); - const next = normalizeCapacity(value); - if (next.size === this.#capacity.size && next.policy === this.#capacity.policy) return; - this.#capacity = next; - this.#capacityChanged = true; - this.#markDirty(); - } - - has(paragraph: Paragraph): boolean { - return this.#paragraphs.has(paragraph as ParagraphImpl); - } - - subscribe(observer: ParagraphBatchObserver): () => void { - this.#assertActive(); - observer.next(this.#current); - this.#observers.add(observer); - let active = true; - return () => { - if (active) { - active = false; - this.#observers.delete(observer); - } - }; - } - - attach( - target: ParagraphBatchTarget, - ): ParagraphBatchAttachment { - this.#assertActive(); - return attachParagraphBatch(this, target); - } - - capture(): ParagraphBatchPreparation { - this.#assertActive(); - const paragraphs = [...this.#paragraphs] - .sort((a, b) => a.order - b.order || a.id - b.id) - .map((paragraph) => paragraph.capture()); - const leases = [...new Set(paragraphs.flatMap((paragraph) => selectedFonts(paragraph.state)))]; - acquireFonts(leases, this.runtime, this.technique); - return Object.freeze({ - controller: this, - desiredRevision: this.#desiredRevision, - paragraphs, - layouts: Object.freeze( - paragraphs.map((paragraph) => - Object.freeze({ - paragraph: paragraph.owner.id, - ...(paragraph.needsShape ? { input: paragraphLayoutInput(paragraph.state) } : {}), - }), - ), - ), - capacity: this.#capacity, - capacityChanged: this.#capacityChanged, - rasterPixelRatio: this.#rasterPixelRatio, - renderVariant: this.#renderVariant, - previous: this.#current, - leases, - }) as ParagraphBatchPreparation; - } - - prepare( - snapshot: ParagraphBatchPreparation, - layouts?: ReadonlyMap, - ): PreparedParagraphBatchCandidate { - this.#assertSnapshot(snapshot); - try { - const paragraphs = snapshot.paragraphs as unknown as readonly CapturedParagraph[]; - const renderVariant = snapshot.renderVariant as Variant | undefined; - const previous = snapshot.previous as unknown as PreparedParagraphBatchRevision; - const prepared = paragraphs.map((paragraph) => - paragraph.owner.prepare( - paragraph, - this.#host.shaper, - snapshot.rasterPixelRatio, - renderVariant, - layouts?.get(paragraph.owner.id), - ), - ); - const packed = pack(this, prepared, snapshot.capacity, previous, snapshot.capacityChanged); - const revision = Object.freeze({ - paragraphBatch: this, - revision: this.#revision + 1, - technique: this.technique, - paragraphs: Object.freeze(prepared.map((entry) => entry.publicParagraph)), - glyphBatches: Object.freeze(packed.batches), - glyphRuns: Object.freeze(packed.runs), - }) as PreparedParagraphBatchRevision; - return Object.freeze({ snapshot, revision, paragraphs: prepared }) as PreparedParagraphBatchCandidate; - } catch (cause) { - const error = - cause instanceof CapacityOverflow - ? Object.freeze({ - kind: 'capacity-exceeded' as const, - batch: this as ParagraphBatch, - capacity: snapshot.capacity.size, - required: cause.required, - overflows: Object.freeze(cause.overflows), - }) - : Object.freeze({ kind: 'preparation-failed' as const, cause }); - if (snapshot.desiredRevision === this.#desiredRevision) { - this.#dirty = false; - this.#preparationError = error; - } - throw error; - } - } - - publish(candidate: PreparedParagraphBatchCandidate): void { - this.#assertSnapshot(candidate.snapshot); - const value = candidate.revision; - const nextKeys = new Set(value.glyphBatches.map((glyphBatch) => glyphBatch.key)); - for (const glyphBatch of this.#current.glyphBatches) - if (nextKeys.has(glyphBatch.key)) this.#spareStorage.set(glyphBatch.key, glyphBatch.storage); - this.#current = value as PreparedParagraphBatchRevision; - this.#revision = value.revision; - this.#dirty = candidate.snapshot.desiredRevision !== this.#desiredRevision; - this.#preparationError = undefined; - if (!this.#dirty) this.#capacityChanged = false; - for (const paragraph of candidate.paragraphs) - paragraph.owner.setPrepared(paragraph as PreparedOwnedParagraph); - for (const observer of this.#observers) observer.next(this.#current); - } - - discard(candidate: PreparedParagraphBatchCandidate): void { - this.#assertSnapshot(candidate.snapshot); - const currentKeys = new Set(this.#current.glyphBatches.map((glyphBatch) => glyphBatch.key)); - for (const glyphBatch of candidate.revision.glyphBatches) - if (currentKeys.has(glyphBatch.key)) - this.#spareStorage.set(glyphBatch.key, glyphBatch.storage as GlyphBatchStorageOf); - } - - release(snapshot: ParagraphBatchPreparation): void { - this.#assertSnapshot(snapshot); - releaseFonts(snapshot.leases as readonly LoadedFont[]); - } - storage(key: GlyphBatchKey, capacity: number): GlyphBatchStorageOf { - const spare = this.#spareStorage.get(key); - if (spare !== undefined) { - this.#spareStorage.delete(key); - return spare; - } - return packingOperations(this.technique).createStorage(capacity); - } - /** - * Packing rewrites one input per glyph on every update and no prepared batch - * retains them, so the batch keeps the objects and overwrites them in place - * rather than handing the collector a glyph-sized set of corpses each update. - */ - glyphInputs(): MutableGlyphInput['data']>[] { - return this.#glyphInputs; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const paragraph of [...this.#paragraphs]) paragraph.dispose(); - this.#paragraphs.clear(); - for (const observer of this.#observers) observer.complete(); - this.#observers.clear(); - this.#spareStorage.clear(); - this.#glyphInputs.length = 0; - this.#host.remove(this); - } - - removeParagraph(paragraph: ParagraphImpl): void { - if (this.#paragraphs.delete(paragraph)) this.#markDirty(); - } - markParagraphDirty(): void { - this.#markDirty(); - } - #markDirty(): void { - this.#desiredRevision += 1; - this.#preparationError = undefined; - if (!this.#dirty) { - this.#dirty = true; - this.#host.dirty(); - } - } - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph batch has been disposed'); - } - #assertSnapshot(snapshot: ParagraphBatchPreparation): void { - if (snapshot.controller !== this) throw new TypeError('paragraph batch preparation belongs to another batch'); - } -} - -interface CapturedParagraph { - readonly owner: ParagraphImpl; - readonly desiredRevision: number; - readonly state: ParagraphSnapshot; - readonly origins: GlyphOriginUpdate | undefined; - readonly prepared: PreparedOwnedParagraph | undefined; - readonly needsShape: boolean; - readonly topology: number; - readonly hasDensity: boolean; -} - -/** A pooled glyph input while core still owns it, before the technique observes it. */ -type MutableGlyphInput = { -readonly [Field in keyof RasterGlyphInput]: RasterGlyphInput[Field] }; - -/** Shares one hidden class across the pool by stating every field up front. */ -function blankGlyphInput(data: Data, paint: ResolvedPaint): MutableGlyphInput { - return { data, glyphId: 0, fontSize: 0, originX: 0, originY: 0, rasterPixelRatio: 0, paint }; -} - -/** - * Cascade-resolved paint and render variant for one prepared revision. A - * paragraph whose cascade states a single segment attributes every glyph - * identically, so it carries one value instead of two arrays as long as the - * glyph count. - */ -type GlyphAttribution = - | { readonly kind: 'uniform'; readonly paint: ResolvedPaint; readonly variant: Variant | undefined } - | { - readonly kind: 'per-glyph'; - /** Parallel to `layout.glyphIds`. */ - readonly paints: readonly ResolvedPaint[]; - /** Parallel to `layout.glyphIds`. */ - readonly variants: readonly (Variant | undefined)[]; - }; - -interface PreparedOwnedParagraph { - readonly owner: ParagraphImpl; - readonly capture: CapturedParagraph; - readonly state: ParagraphSnapshot; - readonly publicParagraph: PreparedParagraph; - readonly layout: ParagraphLayout; - readonly fonts: ReadonlyMap>; - readonly displayedX: Float32Array; - readonly displayedY: Float32Array; - readonly rasterPixelRatio: number; - readonly batchRenderVariant: Variant | undefined; - readonly attribution: GlyphAttribution; -} - -class ParagraphImpl implements Paragraph { - readonly id: ParagraphId; - readonly batch: ParagraphBatchImpl; - #state: ParagraphSnapshot; - #disposed = false; - #prepared: PreparedOwnedParagraph | undefined; - #origins: GlyphOriginUpdate | undefined; - #topology = 0; - #desiredRevision = 0; - #leasedFonts: readonly LoadedFont[]; - #needsShape = true; - #layoutSession: ParagraphLayoutSession | undefined; - readonly hasDensity: boolean; - - constructor( - batch: ParagraphBatchImpl, - id: ParagraphId, - properties: ParagraphProperties, - ) { - this.batch = batch; - this.id = id; - this.hasDensity = properties.rasterPixelRatio !== undefined; - this.#state = normalizeProperties(properties, batch.runtime, batch.technique); - this.#leasedFonts = selectedFonts(this.#state); - acquireFonts(this.#leasedFonts, batch.runtime, batch.technique); - } - get disposed(): boolean { - return this.#disposed; - } - get committed(): PreparedParagraph | undefined { - return this.#prepared?.publicParagraph; - } - get font(): FontSelection { - return this.#state.font; - } - set font(value: FontSelection) { - this.set({ font: value }); - } - get text(): string { - return this.#state.text; - } - set text(value: TextInput) { - this.set({ text: value } as ParagraphUpdate); - } - get spans(): readonly ParagraphSpan[] { - return this.#state.spans; - } - set spans(value: readonly ParagraphSpan[]) { - this.set({ spans: value }); - } - get contentBox(): ParagraphContentBox { - return this.#state.contentBox; - } - set contentBox(value: ParagraphContentBox) { - this.set({ contentBox: value }); - } - get style(): ParagraphStyle { - return this.#state.style; - } - set style(value: ParagraphStyle) { - this.set({ style: value }); - } - get paint(): GlyphPaintInput { - return this.#state.paint; - } - set paint(value: GlyphPaintInput) { - this.set({ paint: value }); - } - get rasterPixelRatio(): number { - return this.#state.rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - this.set({ rasterPixelRatio: value }); - } - get order(): number { - return this.#state.order; - } - set order(value: number) { - this.set({ order: value }); - } - get renderVariant(): Variant | undefined { - return this.#state.renderVariant; - } - set renderVariant(value: Variant | undefined) { - this.set({ renderVariant: value } as ParagraphUpdate); - } - - set(update: ParagraphUpdate): void { - this.#assertActive(); - const next = normalizeProperties( - { ...this.#state, ...replacedContent(update) } as ParagraphProperties, - this.batch.runtime, - this.batch.technique, - ); - const nextFonts = selectedFonts(next); - acquireFonts(nextFonts, this.batch.runtime, this.batch.technique); - releaseFonts(this.#leasedFonts); - this.#leasedFonts = nextFonts; - if ('font' in update || 'text' in update || 'spans' in update || 'contentBox' in update || 'style' in update) { - this.#needsShape = true; - } - this.#state = next; - this.#origins = undefined; - this.#desiredRevision += 1; - this.batch.markParagraphDirty(); - } - setSpan(index: number, value: ParagraphSpan): void { - const spans = [...this.spans]; - if (!Number.isSafeInteger(index) || index < 0 || index >= spans.length) - throw new RangeError('span index is outside the paragraph'); - spans[index] = value; - this.spans = spans; - } - removeSpan(index: number): void { - const spans = [...this.spans]; - if (!Number.isSafeInteger(index) || index < 0 || index >= spans.length) - throw new RangeError('span index is outside the paragraph'); - spans.splice(index, 1); - this.spans = spans; - } - snapshotProperties(): ParagraphSnapshot { - this.#assertActive(); - return this.#state; - } - snapshotGlyphs(): GlyphSnapshot { - this.#assertActive(); - const prepared = this.#prepared; - if (prepared === undefined) throw new Error('paragraph has not been prepared'); - const layout = prepared.layout; - const displayedX = this.#origins?.x ?? layout.x; - const displayedY = this.#origins?.y ?? layout.y; - return { - topology: prepared.publicParagraph.topology, - glyphIds: Uint32Array.from(layout.glyphIds), - clusters: layout.clusters.slice(), - fontSlots: layout.glyphFontSlots.slice(), - shapedX: layout.x.slice(), - shapedY: layout.y.slice(), - displayedX: displayedX.slice(), - displayedY: displayedY.slice(), - }; - } - setGlyphOrigins(update: GlyphOriginUpdate): void { - this.#assertActive(); - const prepared = this.#prepared; - if (prepared === undefined || update.topology !== prepared.publicParagraph.topology) - throw new TypeError('glyph origins do not match the committed topology'); - if (update.x.length !== prepared.layout.x.length || update.y.length !== prepared.layout.y.length) - throw new RangeError('glyph origin arrays do not match the glyph count'); - this.#origins = { topology: update.topology, x: update.x.slice(), y: update.y.slice() }; - this.#desiredRevision += 1; - this.batch.markParagraphDirty(); - } - clearGlyphOriginOverrides(): void { - this.#assertActive(); - if (this.#origins !== undefined) { - this.#origins = undefined; - this.#desiredRevision += 1; - this.batch.markParagraphDirty(); - } - } - - capture(): CapturedParagraph { - return Object.freeze({ - owner: this, - desiredRevision: this.#desiredRevision, - state: this.#state, - origins: this.#origins, - prepared: this.#prepared, - needsShape: this.#needsShape, - topology: this.#topology, - hasDensity: this.hasDensity, - }); - } - prepare( - capture: CapturedParagraph, - shaper: RuntimeShaper, - batchRasterPixelRatio: number, - batchRenderVariant: Variant | undefined, - preparedLayout?: ParagraphLayout, - ): PreparedOwnedParagraph { - if (capture.owner !== this) throw new TypeError('paragraph preparation belongs to another paragraph'); - const fonts = collectFonts(capture.state); - const layout = - !capture.needsShape && capture.prepared !== undefined - ? capture.prepared.layout - : (preparedLayout ?? this.#session(shaper).layout(paragraphLayoutInput(capture.state))); - const topology = ( - !capture.needsShape && capture.prepared !== undefined - ? capture.prepared.publicParagraph.topology - : capture.topology + 1 - ) as GlyphTopology; - return { - owner: this, - capture, - state: capture.state, - layout, - fonts, - displayedX: capture.origins?.x ?? layout.x, - displayedY: capture.origins?.y ?? layout.y, - rasterPixelRatio: capture.hasDensity ? capture.state.rasterPixelRatio : batchRasterPixelRatio, - batchRenderVariant, - attribution: resolveGlyphAttribution(capture.state, layout, batchRenderVariant), - publicParagraph: Object.freeze({ id: this.id, layout, topology }), - }; - } - publish(): void {} - setPrepared(value: PreparedOwnedParagraph): void { - this.#prepared = value; - this.#topology = value.publicParagraph.topology; - if (value.capture.desiredRevision === this.#desiredRevision) this.#needsShape = false; - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - releaseFonts(this.#leasedFonts); - this.#leasedFonts = []; - this.batch.removeParagraph(this); - this.#prepared = undefined; - this.#layoutSession?.dispose(); - this.#layoutSession = undefined; - } - /** Retained so preparation survives a change that only moves the content box. */ - #session(shaper: RuntimeShaper): ParagraphLayoutSession { - this.#layoutSession ??= new ParagraphLayoutSession(shaper); - return this.#layoutSession; - } - - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph has been disposed'); - } -} - -function pack( - batch: ParagraphBatchImpl, - prepared: readonly PreparedOwnedParagraph[], - capacity: GlyphBufferCapacity, - previous: PreparedParagraphBatchRevision, - forceReplacement: boolean, -): { batches: PreparedGlyphBatch[]; runs: PreparedGlyphRun[] } { - const technique = packingOperations(batch.technique); - type Entry = { - font: LoadedFont; - selection: RasterGlyphSelection>; - glyphs: RasterGlyphInput['data']>[]; - }; - type LogicalRun = { - entry: Entry; - paragraph: ParagraphImpl; - variant: Variant | undefined; - start: number; - count: number; - }; - /** - * A batching key is the resource/pipeline-variant pair, so it is looked up - * through nested maps rather than a per-glyph composite string. `orderedEntries` - * preserves the first-seen order that batch identity and run offsets depend on. - */ - const entries = new Map>(); - const orderedEntries: Entry[] = []; - const orderedRuns: LogicalRun[] = []; - const pool = batch.glyphInputs(); - /** Advances only past an input a selection accepted, so a skip reuses the slot. */ - let pooled = 0; - let previousRun: LogicalRun | undefined; - let cachedResource: RasterResourceId | undefined; - let cachedPipelineVariant = -1; - let cachedEntry: Entry | undefined; - for (const value of prepared) { - const { layout, owner, attribution } = value; - for (let index = 0; index < layout.glyphIds.length; index += 1) { - const handle = layout.fontHandles[layout.glyphFontSlots[index]!]!; - const font = value.fonts.get(handle); - if (font === undefined) throw new Error('paragraph layout referenced an unresolved loaded font'); - const paint = attribution.kind === 'uniform' ? attribution.paint : attribution.paints[index]!; - const input = (pool[pooled] ??= blankGlyphInput(font.data, paint)); - input.data = font.data; - input.glyphId = layout.glyphIds[index]!; - input.fontSize = layout.glyphFontSizes[index]!; - input.originX = value.displayedX[index]!; - input.originY = value.displayedY[index]!; - input.rasterPixelRatio = value.rasterPixelRatio; - input.paint = paint; - const selection = technique.select(input); - if (selection === undefined) continue; - pooled += 1; - /** Consecutive glyphs almost always reselect the same batch. */ - let entry: Entry; - if ( - cachedEntry !== undefined && - selection.resource === cachedResource && - selection.pipelineVariant === cachedPipelineVariant - ) { - entry = cachedEntry; - } else { - let variants = entries.get(selection.resource); - if (variants === undefined) { - variants = new Map(); - entries.set(selection.resource, variants); - } - let existing = variants.get(selection.pipelineVariant); - if (existing === undefined) { - existing = { font, selection, glyphs: [] }; - variants.set(selection.pipelineVariant, existing); - orderedEntries.push(existing); - } - entry = existing; - cachedResource = selection.resource; - cachedPipelineVariant = selection.pipelineVariant; - cachedEntry = existing; - } - const variant = attribution.kind === 'uniform' ? attribution.variant : attribution.variants[index]; - if ( - previousRun !== undefined && - previousRun.entry === entry && - previousRun.paragraph === owner && - Object.is(previousRun.variant, variant) && - previousRun.start + previousRun.count === entry.glyphs.length - ) - previousRun.count += 1; - else { - previousRun = { entry, paragraph: owner, variant, start: entry.glyphs.length, count: 1 }; - orderedRuns.push(previousRun); - } - entry.glyphs.push(input); - } - } - const batches: PreparedGlyphBatch[] = []; - const runLookup = new Map(); - if (capacity.policy === 'fixed') { - const overflows: GlyphCapacityOverflow[] = []; - for (const entry of orderedEntries) { - if (entry.glyphs.length <= capacity.size) continue; - overflows.push({ - resourceKey: Object.freeze({ - technique: batch.technique.id, - resource: entry.selection.resource, - pipelineVariant: entry.selection.pipelineVariant, - generation: nextGeneration(previous, entry.selection.resource, entry.selection.pipelineVariant, 0), - chunk: 0, - }), - required: entry.glyphs.length, - }); - } - if (overflows.length !== 0) throw new CapacityOverflow(overflows); - } - for (const entry of orderedEntries) { - const required = entry.glyphs.length; - const prior = matchingBatch(previous, entry.selection.resource, entry.selection.pipelineVariant, 0); - const chunkSize = - capacity.policy === 'grow' - ? prior !== undefined && !forceReplacement && prior.capacity >= required - ? prior.capacity - : grownCapacity(forceReplacement ? capacity.size : (prior?.capacity ?? capacity.size), required) - : capacity.size; - const chunks = Math.max(1, Math.ceil(required / chunkSize)); - const keys: { key: GlyphBatchKey; offset: number; capacity: number }[] = []; - for (let chunk = 0; chunk < chunks; chunk += 1) { - const start = chunk * chunkSize; - const count = Math.min(chunkSize, required - start); - const old = matchingBatch(previous, entry.selection.resource, entry.selection.pipelineVariant, chunk); - const reusable = old !== undefined && !forceReplacement && old.capacity === chunkSize; - const key = reusable - ? old.key - : Object.freeze({ - technique: batch.technique.id, - resource: entry.selection.resource, - pipelineVariant: entry.selection.pipelineVariant, - generation: old === undefined ? 0 : old.key.generation + 1, - chunk, - }); - const storage = batch.storage(key, chunkSize); - /** A single chunk already spans the whole entry, so it needs no copy. */ - const glyphs = - start === 0 && count === entry.glyphs.length ? entry.glyphs : entry.glyphs.slice(start, start + count); - technique.writeStorage( - storage, - { start: 0, count }, - { data: entry.font.data, binding: entry.selection.binding, glyphs }, - ); - batches.push( - Object.freeze({ - key, - technique: batch.technique, - font: entry.font, - capacity: chunkSize, - instanceCount: count, - binding: entry.selection.binding, - storage, - dirtyRanges: Object.freeze(storageDirtyRanges(reusable ? old.storage : undefined, storage, count, chunkSize)), - }), - ); - keys.push({ key, offset: start, capacity: chunkSize }); - } - runLookup.set(entry, keys); - } - const runs: PreparedGlyphRun[] = []; - for (const run of orderedRuns) { - const { entry } = run; - let remaining = run.count; - let cursor = run.start; - while (remaining > 0) { - const chunk = capacity.policy === 'grow' ? 0 : Math.floor(cursor / capacity.size); - const target = runLookup.get(entry)![chunk]!; - const local = cursor - target.offset; - const count = Math.min(remaining, target.capacity - local); - runs.push( - Object.freeze({ - batch: target.key, - paragraph: run.paragraph.id, - renderVariant: run.variant, - start: local, - count, - }), - ); - cursor += count; - remaining -= count; - } - } - return { batches, runs }; -} - -function storageDirtyRanges( - previous: Readonly>> | undefined, - next: Readonly>>, - count: number, - capacity: number, -): GlyphRange[] { - if (count === 0) return []; - if (previous === undefined) return [{ start: 0, count }]; - const fields = Reflect.ownKeys(next); - if (fields.length !== Reflect.ownKeys(previous).length) return [{ start: 0, count }]; - const previousBytes: Uint8Array[] = []; - const nextBytes: Uint8Array[] = []; - const strides: number[] = []; - for (const field of fields) { - const before = previous[field]; - const after = next[field]; - if ( - before === undefined || - after === undefined || - before.byteLength !== after.byteLength || - after.byteLength % capacity !== 0 - ) - return [{ start: 0, count }]; - previousBytes.push(new Uint8Array(before.buffer, before.byteOffset, before.byteLength)); - nextBytes.push(new Uint8Array(after.buffer, after.byteOffset, after.byteLength)); - strides.push(after.byteLength / capacity); - } - const ranges: GlyphRange[] = []; - let start = -1; - for (let slot = 0; slot < count; slot += 1) { - let changed = false; - for (let field = 0; field < fields.length && !changed; field += 1) { - const stride = strides[field]!; - const offset = slot * stride; - const before = previousBytes[field]!; - const after = nextBytes[field]!; - for (let byte = 0; byte < stride; byte += 1) - if (before[offset + byte] !== after[offset + byte]) { - changed = true; - break; - } - } - if (changed && start === -1) start = slot; - if (!changed && start !== -1) { - ranges.push({ start, count: slot - start }); - start = -1; - } - } - if (start !== -1) ranges.push({ start, count: count - start }); - return ranges; -} - -function matchingBatch( - revision: PreparedParagraphBatchRevision, - resource: RasterResourceId, - pipelineVariant: number, - chunk: number, -): PreparedGlyphBatch | undefined { - return revision.glyphBatches.find( - (batch) => - batch.key.resource === resource && batch.key.pipelineVariant === pipelineVariant && batch.key.chunk === chunk, - ); -} - -function nextGeneration( - revision: PreparedParagraphBatchRevision, - resource: RasterResourceId, - pipelineVariant: number, - chunk: number, -): number { - return (matchingBatch(revision, resource, pipelineVariant, chunk)?.key.generation ?? -1) + 1; -} - -class CapacityOverflow extends Error { - readonly overflows: readonly GlyphCapacityOverflow[]; - readonly required: number; - constructor(overflows: readonly GlyphCapacityOverflow[]) { - super('fixed glyph capacity is smaller than the prepared generation'); - this.name = 'CapacityOverflow'; - this.overflows = overflows; - this.required = Math.max(...overflows.map((overflow) => overflow.required)); - } -} - -/** - * Replacement text carries its own formatting: a literal brings its spans and a - * plain string brings none. Retaining the previous spans would reinterpret them - * against unrelated text, so an update that replaces text without stating spans - * clears the ones it replaced. - */ -function replacedContent( - update: ParagraphUpdate, -): ParagraphUpdate { - if (!('text' in update) || 'spans' in update) return update; - return { ...update, spans: [] } as ParagraphUpdate; -} - -function normalizeProperties( - properties: ParagraphProperties, - runtime: TextRuntime, - technique: Technique, -): ParagraphSnapshot { - if (properties === undefined) throw new TypeError('paragraph properties are required'); - assertFontSelection(properties.font, runtime, technique); - const formatted = typeof properties.text === 'string' ? undefined : properties.text; - const text = typeof properties.text === 'string' ? properties.text : properties.text.text; - const spans = Object.freeze([ - ...((formatted?.spans ?? properties.spans ?? []) as readonly ParagraphSpan[]), - ]); - for (const span of spans) { - if ( - !Number.isSafeInteger(span.start) || - !Number.isSafeInteger(span.end) || - span.start < 0 || - span.end < span.start || - span.end > text.length - ) - throw new RangeError('paragraph span is outside the text'); - if (span.font !== undefined) assertFontSelection(span.font, runtime, technique); - } - // Resolution folds covering spans from the outermost inward, so a pair that - // overlaps without nesting has no innermost span and must fail here rather - // than resolve to whichever span a consumer happened to visit last. - assertSpanNesting(spans, 'paragraph span'); - const rasterPixelRatio = positive(properties.rasterPixelRatio ?? 1, 'rasterPixelRatio'); - const order = finite(properties.order ?? 0, 'order'); - return Object.freeze({ - font: properties.font, - text, - spans, - contentBox: Object.freeze({ ...(properties.contentBox ?? EMPTY_BOX) }), - style: Object.freeze({ ...(properties.style ?? EMPTY_STYLE) }), - paint: Object.freeze({ ...(properties.paint ?? EMPTY_PAINT) }), - rasterPixelRatio, - order, - renderVariant: properties.renderVariant, - }); -} - -function collectFonts( - state: ParagraphSnapshot, -): ReadonlyMap> { - const map = new Map>(); - for (const font of concreteFonts(state.font)) map.set(font.font.handle, font); - for (const span of state.spans) - if (span.font !== undefined) for (const font of concreteFonts(span.font)) map.set(font.font.handle, font); - return map; -} -function selectedFonts( - state: ParagraphSnapshot, -): readonly LoadedFont[] { - return [...new Set(collectFonts(state).values())]; -} -function acquireFonts( - fonts: readonly LoadedFont[], - runtime: TextRuntime, - technique: Technique, -): void { - const acquired: LoadedFont[] = []; - try { - for (const font of fonts) { - acquireFontSelection(font, runtime, technique); - acquired.push(font); - } - } catch (error) { - releaseFonts(acquired); - throw error; - } -} -function releaseFonts(fonts: readonly LoadedFont[]): void { - for (const font of fonts) releaseFontSelection(font); -} -function shapingSpans( - state: WorkerParagraphLayoutInput, - fallbacks: ReadonlyMap = new Map(), -): import('./paragraph.js').ParagraphSpan[] { - const authored = state.spans.map((span) => ({ - start: span.start, - end: span.end, - ...(span.fonts === undefined ? {} : { font: span.fonts[0] }), - ...(span.style ?? {}), - })); - // A fallback overlay is machine-generated rather than authored, so it is split - // at the authored boundaries it crosses. Each piece then sits inside exactly - // one span or in a gap, which keeps the overlay inside the nesting invariant. - const boundaries = [ - ...new Set([0, state.text.length, ...state.spans.flatMap((span) => [span.start, span.end])]), - ].sort((left, right) => left - right); - const starts = [...fallbacks.keys()].sort((left, right) => left - right); - for (let index = 0; index < starts.length; index += 1) { - const start = starts[index]!; - const end = starts[index + 1] ?? state.text.length; - const font = fallbacks.get(start)!; - let cursor = start; - for (const boundary of boundaries) { - if (boundary <= cursor || boundary >= end) continue; - authored.push({ start: cursor, end: boundary, font }); - cursor = boundary; - } - if (cursor < end) authored.push({ start: cursor, end, font }); - } - return authored; -} - -/** - * The shaping layer receives the cascade already resolved into disjoint - * segments, so a span's font and shaping style reach run segmentation with the - * same answer that packing later paints with. - */ -function paragraphLayoutInput( - state: ParagraphSnapshot, -): WorkerParagraphLayoutInput { - return Object.freeze({ - text: state.text, - fonts: Object.freeze(concreteFonts(state.font).map((font) => font.font.handle)), - spans: Object.freeze( - paragraphCascade(state).flatMap((segment) => { - const font = segment.properties.font; - const style = shapingStyleOf(segment.properties); - if (font === undefined && style === undefined) return []; - return [ - Object.freeze({ - start: segment.start, - end: segment.end, - ...(font === undefined - ? {} - : { fonts: Object.freeze(concreteFonts(font).map((value) => value.font.handle)) }), - ...(style === undefined ? {} : { style }), - }), - ]; - }), - ), - style: state.style, - contentBox: state.contentBox, - }); -} - -export interface WorkerParagraphLayoutInput { - readonly text: string; - readonly fonts: readonly FontHandle[]; - readonly spans: readonly { - readonly start: number; - readonly end: number; - readonly fonts?: readonly FontHandle[]; - readonly style?: Partial; - }[]; - readonly style: ParagraphStyle; - readonly contentBox: ParagraphContentBox; -} - -/** - * Retains one prepared paragraph across updates. - * - * Preparation resolves Unicode segmentation, bidi levels, style and run segmentation, shaping, and cluster metrics from - * the text, the fonts, the spans, and the style. A content box is none of those: it is a layout constraint the retained - * paragraph already answers per call, against caches it keeps for exactly that purpose. Building a fresh paragraph for - * every change recomputed the whole preparation and then discarded those caches, so a width drag paid a cold build and - * shaping could never be reused. - * - * @internal - */ -export class ParagraphLayoutSession { - readonly #engine: ParagraphEngine; - #paragraph: EngineParagraph | undefined; - #preparedFrom: string | undefined; - #preparedText: string | undefined; - - constructor(shaper: RuntimeShaper) { - this.#engine = createParagraphEngine({ shaper }); - } - - layout(state: WorkerParagraphLayoutInput): ParagraphLayout { - const paragraph = this.#paragraph; - const preparedFrom = preparationKey(state); - if (paragraph !== undefined && this.#preparedText === state.text && this.#preparedFrom === preparedFrom) { - return paragraph.layout(layoutConstraints(state.contentBox)); - } - const layout = this.#prepare(state); - this.#preparedText = state.text; - this.#preparedFrom = preparedFrom; - return layout; - } - - dispose(): void { - this.#paragraph?.dispose(); - this.#paragraph = undefined; - this.#preparedFrom = undefined; - this.#preparedText = undefined; - } - - #prepare(state: WorkerParagraphLayoutInput): ParagraphLayout { - const fallbackIndexes = new Map(); - const fallbacks = new Map(); - const maximumDepth = Math.max(state.fonts.length, ...state.spans.map((span) => span.fonts?.length ?? 1)); - for (let pass = 0; pass < maximumDepth; pass += 1) { - const paragraph: EngineParagraph = this.#retain(state, fallbacks); - // Fallback substitution asks which clusters shaped to `.notdef`, which shaping already answered. Laying the - // paragraph out to discover it would break lines and position every glyph for a result that is discarded, and - // would make font selection depend on where the text happened to wrap. - const probe = paragraph.shaped(); - // Only clusters of the paragraph's own text can carry a fallback selection. A cluster past the end belongs to - // overflow measurement, and substituting a font for one would author a span outside the text. - const clusters = [...new Set(probe.clusters)] - .filter((value) => value < state.text.length) - .sort((left, right) => left - right); - let changed = false; - for (let glyph = 0; glyph < probe.glyphIds.length; glyph += 1) { - if (probe.glyphIds[glyph] !== 0) continue; - const cluster = probe.clusters[glyph]!; - if (cluster >= state.text.length) continue; - const fonts = fontHandlesAt(state, cluster); - const next = (fallbackIndexes.get(cluster) ?? 0) + 1; - if (next >= fonts.length) continue; - fallbackIndexes.set(cluster, next); - fallbacks.set(cluster, fonts[next]!); - const nextCluster = clusters.find((value) => value > cluster); - if (nextCluster !== undefined && !fallbacks.has(nextCluster)) { - // A sentinel restores authored selection after this shaped cluster. - fallbacks.set(nextCluster, fontHandlesAt(state, nextCluster)[0]!); - } - changed = true; - } - if (!changed) return paragraph.layout(layoutConstraints(state.contentBox)); - } - return this.#retain(state, fallbacks).layout(layoutConstraints(state.contentBox)); - } - - #retain(state: WorkerParagraphLayoutInput, fallbacks: ReadonlyMap): EngineParagraph { - const input = { - text: state.text, - font: state.fonts[0]!, - spans: shapingSpans(state, fallbacks), - style: state.style, - }; - const existing = this.#paragraph; - if (existing === undefined) { - const created = this.#engine.create(input); - this.#paragraph = created; - return created; - } - existing.update(input); - return existing; - } -} - -/** - * Identifies everything preparation depends on, excluding the text, which is compared directly. Spans, fonts, and style - * are bounded by the authored markup rather than by the glyph count, so serializing them stays cheap at any scale. - */ -function preparationKey(state: WorkerParagraphLayoutInput): string { - return JSON.stringify({ fonts: state.fonts, spans: state.spans, style: state.style }); -} - -/** @internal */ -export function prepareParagraphLayout(shaper: RuntimeShaper, state: WorkerParagraphLayoutInput): ParagraphLayout { - const session = new ParagraphLayoutSession(shaper); - try { - return session.layout(state); - } finally { - session.dispose(); - } -} - -function fontHandlesAt(state: WorkerParagraphLayoutInput, cluster: number): readonly FontHandle[] { - let selection = state.fonts; - for (const span of state.spans) { - if (span.start <= cluster && cluster < span.end && span.fonts !== undefined) selection = span.fonts; - } - return selection; -} - -/** - * Every span property in one flat record so a single cascade resolves shaping - * and paint under one merge rule. `outline` and `shadow` stay whole values - * because neither is meaningful without all of its parts. - */ -interface StatedSpanProperties extends ParagraphStyle, GlyphPaintInput { - readonly font?: FontSelection; - readonly renderVariant?: Variant; -} - -type ParagraphCascade = readonly SpanCascadeSegment< - StatedSpanProperties ->[]; - -/** - * A snapshot is replaced whenever a paragraph property changes, so keying the - * cascade on it resolves the spans once per revision for both the shaping layer - * and glyph-instance packing. - */ -const paragraphCascades = new WeakMap(); - -function paragraphCascade( - state: ParagraphSnapshot, -): ParagraphCascade { - const cached = paragraphCascades.get(state); - if (cached !== undefined) return cached as ParagraphCascade; - const resolved = resolveSpanCascade( - state.spans.map((span) => ({ - start: span.start, - end: span.end, - properties: statedProperties>( - span.font === undefined ? undefined : { font: span.font }, - span.style, - span.paint, - span.renderVariant === undefined ? undefined : { renderVariant: span.renderVariant }, - ), - })), - state.text.length, - 'paragraph span', - ); - paragraphCascades.set(state, resolved); - return resolved; -} - -function shapingStyleOf( - properties: StatedSpanProperties, -): ParagraphStyle | undefined { - const style = statedProperties({ - fontSize: properties.fontSize, - lineHeight: properties.lineHeight, - letterSpacing: properties.letterSpacing, - language: properties.language, - direction: properties.direction, - features: properties.features, - }); - return Object.keys(style).length === 0 ? undefined : style; -} - -function paintOf( - root: GlyphPaintInput, - properties: StatedSpanProperties, -): GlyphPaintInput | undefined { - const stated = statedProperties({ - color: properties.color, - opacity: properties.opacity, - outline: properties.outline, - shadow: properties.shadow, - }); - return Object.keys(stated).length === 0 ? undefined : { ...root, ...stated }; -} - -/** - * Paint and render variant resolve once per revision from the same cascade the - * shaping layer consumed, so packing indexes a per-glyph result instead of - * rescanning the span array for every glyph. - */ -function resolveGlyphAttribution( - state: ParagraphSnapshot, - layout: ParagraphLayout, - batchRenderVariant: Variant | undefined, -): GlyphAttribution { - const cascade = paragraphCascade(state); - const rootPaint = resolvePaint(state.paint); - const rootVariant = state.renderVariant ?? batchRenderVariant; - // An empty cascade states nothing, so every glyph inherits the paragraph root. - if (cascade.length === 0) return { kind: 'uniform', paint: rootPaint, variant: rootVariant }; - const paints = cascade.map((segment) => { - const paint = paintOf(state.paint, segment.properties); - return paint === undefined ? rootPaint : resolvePaint(paint); - }); - const variants = cascade.map((segment) => segment.properties.renderVariant ?? rootVariant); - // One segment covering the text from its start attributes every cluster alike. - if (cascade.length === 1 && cascade[0]!.start === 0) { - return { kind: 'uniform', paint: paints[0]!, variant: variants[0] }; - } - const starts = Uint32Array.from(cascade, (segment) => segment.start); - const glyphCount = layout.glyphIds.length; - const glyphPaints = new Array(glyphCount); - const glyphVariants = new Array(glyphCount); - for (let index = 0; index < glyphCount; index += 1) { - const segment = segmentIndexAt(starts, layout.clusters[index]!); - glyphPaints[index] = segment === -1 ? rootPaint : paints[segment]!; - glyphVariants[index] = segment === -1 ? rootVariant : variants[segment]; - } - return { kind: 'per-glyph', paints: glyphPaints, variants: glyphVariants }; -} - -function segmentIndexAt(starts: Uint32Array, offset: number): number { - let low = 0; - let high = starts.length - 1; - let found = -1; - while (low <= high) { - const middle = (low + high) >>> 1; - if (starts[middle]! <= offset) { - found = middle; - low = middle + 1; - } else { - high = middle - 1; - } - } - return found; -} -function layoutConstraints(box: ParagraphContentBox): import('./paragraph.js').ParagraphConstraints { - const axis = ( - value: ParagraphAxisConstraint | undefined, - ): import('./paragraph.js').ParagraphAxisConstraint | undefined => - value?.mode === 'exact' ? { mode: 'exactly', size: value.size } : value; - return { - ...(box.width === undefined ? {} : { width: axis(box.width)! }), - ...(box.height === undefined ? {} : { height: axis(box.height)! }), - ...(box.maxLines === undefined ? {} : { maxLines: box.maxLines }), - ...(box.wrap === undefined ? {} : { wrap: box.wrap }), - ...(box.align === undefined ? {} : { align: box.align }), - ...(box.overflow === undefined ? {} : { overflow: box.overflow }), - }; -} - -interface TechniquePackingOperations { - select( - input: RasterGlyphInput['data']>, - ): RasterGlyphSelection> | undefined; - createStorage(capacity: number): GlyphBatchStorageOf; - writeStorage( - storage: GlyphBatchStorageOf, - range: GlyphRange, - input: { - readonly data: LoadedFont['data']; - readonly binding: RasterBindingOf; - readonly glyphs: readonly RasterGlyphInput['data']>[]; - }, - ): void; -} - -function packingOperations( - technique: Technique, -): TechniquePackingOperations { - return technique as unknown as TechniquePackingOperations; -} -function normalizeCapacity(value: GlyphBufferCapacity): GlyphBufferCapacity { - if (!Number.isSafeInteger(value.size) || value.size <= 0) - throw new RangeError('glyph capacity size must be a positive safe integer'); - if (value.policy !== 'grow' && value.policy !== 'chunk' && value.policy !== 'fixed') - throw new TypeError('glyph capacity policy is invalid'); - return Object.freeze({ size: value.size, policy: value.policy }); -} -function grownCapacity(size: number, required: number): number { - let value = size; - while (value < required) value *= 2; - return value; -} -function positive(value: number, name: string): number { - if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be positive and finite`); - return value; -} -function finite(value: number, name: string): number { - if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite`); - return value; -} -function resolvePaint(input: GlyphPaintInput): ResolvedPaint { - const opacity = input.opacity ?? 1; - return Object.freeze({ - color: color(input.color ?? '#ffffff', opacity), - ...(input.outline === undefined - ? {} - : { - outline: Object.freeze({ - color: color(input.outline.color, opacity), - width: positive(input.outline.width, 'outline width'), - }), - }), - ...(input.shadow === undefined - ? {} - : { shadow: Object.freeze({ color: color(input.shadow.color, opacity), offset: input.shadow.offset }) }), - }); -} -function color( - value: GlyphPaintInput['color'] extends infer _ ? import('./formatted-text.js').ColorInput : never, - opacity: number, -): readonly [number, number, number, number] { - if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) throw new RangeError('opacity must be in [0, 1]'); - if (typeof value !== 'string') return [value[0], value[1], value[2], value[3] * opacity]; - const match = /^#([0-9a-f]{6}|[0-9a-f]{8})$/iu.exec(value); - if (match === null) throw new TypeError('colors must be #rrggbb, #rrggbbaa, or linear RGBA'); - const hex = match[1]!; - const channel = (at: number) => { - const srgb = Number.parseInt(hex.slice(at, at + 2), 16) / 255; - return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; - }; - return [ - channel(0), - channel(2), - channel(4), - (hex.length === 8 ? Number.parseInt(hex.slice(6), 16) / 255 : 1) * opacity, - ]; -} diff --git a/packages/text/src/paragraph.ts b/packages/text/src/paragraph.ts deleted file mode 100644 index 98c954d1..00000000 --- a/packages/text/src/paragraph.ts +++ /dev/null @@ -1,2047 +0,0 @@ -import type { FontHandle } from './identity.js'; -import type { ParagraphLayout, ParagraphMeasurement } from './layout.js'; -import type { FontFeature, ResolvedFontFeature } from './font-feature.js'; -import type { RegisteredFont } from './font.js'; -import type { BidiAnalysisViews, ReshapeRange, RuntimeShaper, ShapeBatchRequest, ShapedBatchViews } from './shaper.js'; -import { analyzeUnicodeText, type UnicodeTextAnalysis } from './internal/unicode.js'; -import { resolveSpanCascade, type SpanCascadeEntry } from './internal/span-cascade.js'; - -/** - * A layout-system-neutral axis constraint. - * - * These modes describe the common constraint vocabulary used by flex, grid, - * retained UI, and application-owned layout systems. - */ -export type ParagraphAxisConstraint = - | { readonly mode: 'unconstrained' } - | { readonly mode: 'at-most'; readonly size: number } - | { readonly mode: 'exactly'; readonly size: number }; - -export interface ParagraphStyle { - readonly fontSize?: number; - readonly lineHeight?: number; - readonly letterSpacing?: number; - readonly language?: string; - readonly direction?: 'auto' | 'ltr' | 'rtl'; - readonly features?: readonly FontFeature[]; -} - -export interface ParagraphSpan extends ParagraphStyle { - readonly start: number; - readonly end: number; - readonly font?: FontHandle; -} - -export interface ParagraphInput { - readonly text: string; - readonly font: FontHandle; - readonly spans?: readonly ParagraphSpan[]; - readonly style?: ParagraphStyle; -} - -export interface ParagraphConstraints { - /** Defaults to `{ mode: 'unconstrained' }`. */ - readonly width?: ParagraphAxisConstraint; - /** Defaults to `{ mode: 'unconstrained' }`. */ - readonly height?: ParagraphAxisConstraint; - readonly maxLines?: number; - readonly wrap?: 'none' | 'word' | 'character'; - readonly align?: 'start' | 'center' | 'end' | 'justify'; - readonly overflow?: 'visible' | 'clip' | 'ellipsis'; -} - -/** - * A prepared paragraph has no asynchronous methods. Font and shaper - * dependencies must be loaded before it is exposed to a synchronous host - * layout system. - */ -export interface Paragraph { - /** Resolve box metrics without materializing positioned glyph arrays. */ - measure(constraints?: ParagraphConstraints): ParagraphMeasurement; - /** Resolve the final box and materialize positioned glyph output. */ - layout(constraints?: ParagraphConstraints): ParagraphLayout; - /** - * Shaped glyph identity, before line breaking and positioning. A caller resolving font fallback needs to know which - * clusters shaped to `.notdef` and nothing else, and that answer must not depend on where lines happen to break. - */ - shaped(): ShapedGlyphIdentity; - update(input: ParagraphInput): void; - dispose(): void; -} - -/** Glyph identity in shaping order, covering every run of the paragraph. */ -export interface ShapedGlyphIdentity { - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; -} - -export interface ParagraphEngine { - create(input: ParagraphInput): Paragraph; -} - -export interface ParagraphEngineOptions { - readonly shaper: RuntimeShaper; -} - -interface ResolvedStyle { - readonly font: FontHandle; - readonly fontSize: number; - readonly lineHeight?: number; - readonly letterSpacing: number; - readonly language?: string; - readonly direction: 'auto' | 'ltr' | 'rtl'; - readonly bidiOverride?: 'ltr' | 'rtl'; - readonly features: readonly ResolvedFontFeature[]; -} - -interface StyleSegment { - readonly start: number; - readonly end: number; - readonly style: ResolvedStyle; -} - -/** The shaping properties one span states, before the cascade merges them. */ -interface StatedSpanStyle { - readonly font?: FontHandle; - readonly fontSize?: number; - readonly lineHeight?: number; - readonly letterSpacing?: number; - readonly language?: string; - readonly direction?: 'auto' | 'ltr' | 'rtl'; - readonly features?: readonly ResolvedFontFeature[]; -} - -interface PreparedRun extends StyleSegment { - readonly script: string; - readonly direction: 'ltr' | 'rtl'; - readonly bidiLevel: number; -} - -interface OwnedBidiAnalysis { - readonly levels: Uint8Array; - readonly classes: Uint8Array; - readonly paragraphStarts: Uint32Array; - readonly paragraphEnds: Uint32Array; - readonly paragraphLevels: Uint8Array; -} - -interface OwnedShape { - readonly fontHandles: Uint32Array; - readonly runFontSlots: Uint16Array; - readonly runGlyphStarts: Uint32Array; - readonly runGlyphCounts: Uint32Array; - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; - readonly xAdvances: Int32Array; - readonly yAdvances: Int32Array; - readonly xOffsets: Int32Array; - readonly yOffsets: Int32Array; - readonly glyphFlags: Uint16Array; -} - -/** - * Every extended grapheme cluster of the paragraph, as parallel arrays rather than one object per cluster. A paragraph - * holds tens of thousands of clusters and every update rebuilt all of them, which made short-lived cluster objects the - * largest single source of garbage on the layout path. - * - * The backing buffers are retained across updates by {@link measureClusters} and only ever grow, so a steady-state - * animation reuses them. `count` is the live length; the views are sliced to it, and the buffers behind them may be - * larger. - */ -interface MeasuredClusters { - readonly count: number; - readonly starts: Uint32Array; - readonly ends: Uint32Array; - /** - * Double precision. Line breaking accumulates a line advance from these one cluster at a time and the result is - * compared against the width limit, so rounding each cluster to single precision would move where lines break. - */ - readonly advances: Float64Array; - /** {@link CLUSTER_SAFE_BEFORE}, {@link CLUSTER_REQUIRED_BREAK}, and {@link CLUSTER_HARD_BREAK}. */ - readonly flags: Uint8Array; - /** Index into {@link PreparedParagraph.styles}, so a cluster carries its style without holding a reference. */ - readonly styleIndexes: Uint32Array; -} - -interface LineMetrics { - readonly height: number; - readonly baseline: number; -} - -interface LinePlan extends LineMetrics { - readonly clusterStart: number; - readonly clusterEnd: number; - readonly textStart: number; - readonly textEnd: number; - readonly advance: number; - readonly hardBreak: boolean; - readonly ellipsis?: EllipsisPlan; -} - -interface EllipsisPlan { - readonly sourceRun: number; - readonly shapeRun: number; - readonly textStart: number; - readonly textEnd: number; - readonly cluster: number; - readonly advance: number; - readonly level: number; -} - -interface PreparedEllipsis { - readonly sourceRun: number; - readonly shapeRun: number; - readonly textStart: number; - readonly textEnd: number; - readonly advance: number; -} - -interface PreparedParagraph { - readonly input: ParagraphInput; - readonly unicode: UnicodeTextAnalysis; - readonly bidi: OwnedBidiAnalysis; - readonly styles: readonly StyleSegment[]; - readonly runs: readonly PreparedRun[]; - readonly request: ShapeBatchRequest; - readonly shape: OwnedShape; - readonly ellipses: readonly PreparedEllipsis[]; - readonly clusters: MeasuredClusters; - /** - * Cluster index for every text offset, so resolving an offset to a cluster is a load rather than a binary search. - * Positioning resolves two offsets at every cluster boundary of every glyph, which made that search the hottest leaf - * in a browser profile of the layout path. - */ - readonly clusterIndexAt: Uint32Array; - readonly letterSpacingPrefix: Float64Array; - readonly spacePrefix: Uint32Array; -} - -interface NormalizedConstraints { - readonly width: ParagraphAxisConstraint; - readonly height: ParagraphAxisConstraint; - readonly maxLines?: number; - readonly wrap: 'none' | 'word' | 'character'; - readonly align: 'start' | 'center' | 'end' | 'justify'; - readonly overflow: 'visible' | 'clip' | 'ellipsis'; -} - -interface MeasuredPlan { - readonly measurement: ParagraphMeasurement; - readonly lines: readonly LinePlan[]; -} - -/** - * A fragment under construction. {@link collectLineFragments} appends fragments in logical order, then fills the - * shaping flags and the reshape decision in place once the line's first and last fragments are known, and reorders the - * line's own range in place. Published fragments are read-only. - */ -interface LineFragmentDraft { - line: number; - run: number; - start: number; - end: number; - flags: number; - level: number; - ellipsis?: EllipsisPlan; - reshape: boolean; -} - -type LineFragment = Readonly; - -interface PositionedGeometry { - readonly fontHandles: Uint32Array; - readonly glyphFontSlots: Uint16Array; - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; - readonly glyphFontSizes: Float32Array; - readonly x: Float32Array; - readonly y: Float32Array; - readonly glyphFlags: Uint16Array; - readonly lineTextStarts: Uint32Array; - readonly lineTextEnds: Uint32Array; - readonly lineGlyphStarts: Uint32Array; - readonly lineGlyphCounts: Uint32Array; - readonly lineBaselines: Float32Array; - readonly lineAdvances: Float32Array; -} - -interface PreparedPositioning { - readonly fragments: readonly LineFragment[]; - readonly reshaped?: OwnedShape; -} - -const DEFAULT_FONT_SIZE = 16; -const MAX_PARAGRAPH_CACHE_ENTRIES = 32; -const PRODUCE_UNSAFE_TO_CONCAT = 0x40; -const BEGINNING_OF_TEXT = 0x01; -const END_OF_TEXT = 0x02; -const GLYPH_UNSAFE_TO_BREAK = 0x01; -/** The cluster starts at a shaping boundary that shaping did not mark unsafe to break. */ -const CLUSTER_SAFE_BEFORE = 0x01; -/** Unicode line breaking requires a break after the cluster. */ -const CLUSTER_REQUIRED_BREAK = 0x02; -/** The cluster is a hard line separator rather than drawable text. */ -const CLUSTER_HARD_BREAK = 0x04; -/** A text offset that shaping treated as a run or cluster boundary. */ -const OFFSET_SHAPED_BOUNDARY = 0x01; -/** A text offset that shaping marked unsafe to break at. */ -const OFFSET_UNSAFE_TO_BREAK = 0x02; -const BIDI_BN = 9; -const BIDI_B = 10; -const BIDI_S = 11; -const BIDI_WS = 12; -const BIDI_LRE = 14; -const BIDI_LRO = 15; -const BIDI_RLE = 16; -const BIDI_RLO = 17; -const BIDI_PDF = 18; -const BIDI_LRI = 19; -const BIDI_RLI = 20; -const BIDI_FSI = 21; -const BIDI_PDI = 22; - -export function createParagraphEngine(options: ParagraphEngineOptions): ParagraphEngine { - if (options?.shaper === undefined) throw new TypeError('paragraph engine requires a shaper'); - return new ParagraphEngineImpl(options.shaper); -} - -class ParagraphEngineImpl implements ParagraphEngine { - readonly #shaper: RuntimeShaper; - - constructor(shaper: RuntimeShaper) { - this.#shaper = shaper; - } - - create(input: ParagraphInput): Paragraph { - return new ParagraphImpl(this.#shaper, input); - } -} - -class ParagraphImpl implements Paragraph { - readonly #shaper: RuntimeShaper; - readonly #measurements = new Map(); - readonly #linePlans = new Map(); - readonly #positioning = new Map(); - readonly #positionedLines = new Map(); - readonly #layouts = new Map(); - #prepared: PreparedParagraph; - #disposed = false; - - constructor(shaper: RuntimeShaper, input: ParagraphInput) { - this.#shaper = shaper; - this.#prepared = prepareParagraph(shaper, input); - } - - measure(constraints?: ParagraphConstraints): ParagraphMeasurement { - this.#assertActive(); - const normalized = normalizeConstraints(constraints); - return this.#measurePlan(normalized).measurement; - } - - layout(constraints?: ParagraphConstraints): ParagraphLayout { - this.#assertActive(); - const normalized = normalizeConstraints(constraints); - const key = constraintKey(normalized); - let layout = getRecent(this.#layouts, key); - if (layout !== undefined) return layout; - const measured = this.#measurePlan(normalized); - const positioningKey = positioningLinesKey(measured.lines); - const lineKey = geometryLinesKey(positioningKey, normalized.align, measured.measurement.width); - let geometry = getRecent(this.#positionedLines, lineKey); - if (geometry === undefined) { - let positioning = getRecent(this.#positioning, positioningKey); - if (positioning === undefined) { - positioning = preparePositioning(this.#shaper, this.#prepared, measured.lines); - retainRecent(this.#positioning, positioningKey, positioning); - } - geometry = positionPrepared( - this.#shaper, - this.#prepared, - measured.lines, - positioning, - normalized, - measured.measurement.width, - ); - retainRecent(this.#positionedLines, lineKey, geometry); - } - layout = Object.freeze({ - ...measurementForGeometry(normalized, measured.measurement, geometry), - ...geometry, - }); - retainRecent(this.#layouts, key, layout); - return layout; - } - - shaped(): ShapedGlyphIdentity { - this.#assertActive(); - const { shape, runs } = this.#prepared; - // The shaping request appends one ellipsis run per source run, clustered past the end of the text, so a caller - // inspecting glyph identity must not see them: they are how overflow is measured, not glyphs of this paragraph. - // Those runs are requested after every source run, so the first of them bounds the paragraph's own glyphs. - const end = - runs.length < shape.runGlyphStarts.length ? (shape.runGlyphStarts[runs.length] ?? 0) : shape.glyphIds.length; - return { glyphIds: shape.glyphIds.subarray(0, end), clusters: shape.clusters.subarray(0, end) }; - } - - update(input: ParagraphInput): void { - this.#assertActive(); - this.#prepared = prepareParagraph(this.#shaper, input, this.#prepared); - this.#measurements.clear(); - this.#linePlans.clear(); - this.#positioning.clear(); - this.#positionedLines.clear(); - this.#layouts.clear(); - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#measurements.clear(); - this.#linePlans.clear(); - this.#positioning.clear(); - this.#positionedLines.clear(); - this.#layouts.clear(); - } - - #measurePlan(constraints: NormalizedConstraints): MeasuredPlan { - const key = constraintKey(constraints); - let plan = getRecent(this.#measurements, key); - if (plan === undefined) { - const lineKey = linePlanConstraintKey(constraints); - let lines = getRecent(this.#linePlans, lineKey); - if (lines === undefined) { - lines = planLines(this.#shaper, this.#prepared, constraints); - retainRecent(this.#linePlans, lineKey, lines); - } - plan = measurePrepared(this.#prepared, constraints, lines); - retainRecent(this.#measurements, key, plan); - } - return plan; - } - - #assertActive(): void { - if (this.#disposed) throw new Error('paragraph has been disposed'); - } -} - -function getRecent(cache: Map, key: Key): Value | undefined { - const value = cache.get(key); - if (value === undefined) return undefined; - cache.delete(key); - cache.set(key, value); - return value; -} - -function retainRecent(cache: Map, key: Key, value: Value): void { - cache.delete(key); - cache.set(key, value); - if (cache.size <= MAX_PARAGRAPH_CACHE_ENTRIES) return; - const oldest = cache.keys().next(); - if (!oldest.done) cache.delete(oldest.value); -} - -function prepareParagraph( - shaper: RuntimeShaper, - input: ParagraphInput, - previous?: PreparedParagraph, -): PreparedParagraph { - const ownedInput = copyInput(input); - // Grapheme boundaries, line break opportunities, script items, and bidi levels are decided by the text and its base - // direction and by nothing else, so a resize, a colour change, or a letter-spacing change all recompute a result - // identical to the retained one. Both are owned, immutable products, so reusing them is a pointer copy. - const sameText = - previous !== undefined && - previous.input.text === ownedInput.text && - (previous.input.style?.direction ?? 'auto') === (ownedInput.style?.direction ?? 'auto'); - const unicode = sameText ? previous.unicode : analyzeUnicodeText(ownedInput.text); - const styles = resolveStyles(shaper, ownedInput, unicode.graphemeBoundaries); - const bidi = sameText - ? previous.bidi - : ownBidi(shaper.analyzeBidi(utf16(ownedInput.text), ownedInput.style?.direction ?? 'auto')); - const runs = prepareRuns(ownedInput.text, styles, unicode, bidi); - const shapedRequest = shapeRequest(ownedInput.text, runs); - const request = shapedRequest.request; - // Shaping is deterministic in its request, and the request carries no font size, line height, or letter spacing — - // those scale the shaped advances afterward. An animated resize therefore rebuilds an identical request, so reusing - // the retained shape skips the whole shaping pass while every measurement below still recomputes at the new size. - // A shape is plain owned typed arrays that nothing releases, so retaining one across preparations is safe. - const reused = previous !== undefined && sameShapeRequest(previous.request, request) ? previous.shape : undefined; - const shape = reused ?? ownShape(request.runs.length === 0 ? emptyShape() : shaper.shapeBatch(request)); - const ellipses = measureEllipses(shaper, runs, shape, shapedRequest.ellipses); - const clusters = measureClusters(shaper, ownedInput.text, unicode, styles, runs, shape, previous); - const clusterIndexes = indexClusters(ownedInput.text, styles, clusters, previous); - return { - input: ownedInput, - unicode, - bidi, - styles, - runs, - request, - shape, - ellipses, - clusters, - ...clusterIndexes, - }; -} - -function copyInput(input: ParagraphInput): ParagraphInput { - if (!isNonArrayObject(input)) throw new TypeError('paragraph input must be an object'); - if (typeof input.text !== 'string') throw new TypeError('paragraph text must be a string'); - if (input.style !== undefined && !isNonArrayObject(input.style)) { - throw new TypeError('paragraph style must be an object'); - } - if (input.spans !== undefined && !Array.isArray(input.spans)) { - throw new TypeError('paragraph spans must be an array'); - } - return { - text: input.text, - font: input.font, - ...(input.style === undefined ? {} : { style: copyStyle(input.style, 'paragraph style') }), - ...(input.spans === undefined - ? {} - : { - spans: input.spans.map((span) => { - if (!isNonArrayObject(span)) throw new TypeError('paragraph span must be an object'); - return { - ...copyStyle(span, 'paragraph span'), - start: span.start, - end: span.end, - ...(span.font === undefined ? {} : { font: span.font }), - }; - }), - }), - }; -} - -function copyStyle(style: ParagraphStyle, name: string): ParagraphStyle { - if (!isNonArrayObject(style)) throw new TypeError(`${name} must be an object`); - if (style.features !== undefined && !Array.isArray(style.features)) { - throw new TypeError(`${name} features must be an array`); - } - if ( - style.direction !== undefined && - style.direction !== 'auto' && - style.direction !== 'ltr' && - style.direction !== 'rtl' - ) { - throw new RangeError(`${name} direction must be auto, ltr, or rtl`); - } - return { - ...(style.fontSize === undefined ? {} : { fontSize: style.fontSize }), - ...(style.lineHeight === undefined ? {} : { lineHeight: style.lineHeight }), - ...(style.letterSpacing === undefined ? {} : { letterSpacing: style.letterSpacing }), - ...(style.language === undefined ? {} : { language: style.language }), - ...(style.direction === undefined ? {} : { direction: style.direction }), - ...(style.features === undefined - ? {} - : { - features: style.features.map((feature) => { - if (!isNonArrayObject(feature)) throw new TypeError(`${name} feature must be an object`); - return { ...feature }; - }), - }), - }; -} - -/** - * Shaping resolution consumes the one span cascade before run segmentation, so - * a span's font, size, language, direction, or features re-segment shaping and - * change advances rather than only scaling already-shaped glyphs. - */ -function resolveStyles( - shaper: RuntimeShaper, - input: ParagraphInput, - graphemeBoundaries: Uint32Array, -): readonly StyleSegment[] { - const legalBoundaries = new Set(graphemeBoundaries); - const entries: SpanCascadeEntry[] = []; - for (const span of input.spans ?? []) { - assertTextRange(span.start, span.end, input.text.length, 'paragraph span'); - if (!legalBoundaries.has(span.start) || !legalBoundaries.has(span.end)) { - throw new RangeError('paragraph span boundaries must be extended-grapheme boundaries'); - } - entries.push({ start: span.start, end: span.end, properties: resolveSpanStyle(shaper, span) }); - } - const root = resolveStyle(shaper, input.font, input.style ?? {}, 0, input.text.length); - if (input.text.length === 0) return [{ start: 0, end: 0, style: root }]; - const segments: StyleSegment[] = []; - for (const segment of resolveSpanCascade(entries, input.text.length, 'paragraph span')) { - const style = styleOver(root, segment.properties); - const previous = segments.at(-1); - if (previous !== undefined && previous.end === segment.start && equalStyles(previous.style, style)) { - segments[segments.length - 1] = { ...previous, end: segment.end }; - } else { - segments.push({ start: segment.start, end: segment.end, style }); - } - } - return segments; -} - -function resolveSpanStyle(shaper: RuntimeShaper, span: ParagraphSpan): StatedSpanStyle { - if (span.font !== undefined) shaper.registerFont(requireFont(shaper, span.font)); - const lineHeight = span.lineHeight === undefined ? undefined : finitePositive(span.lineHeight, 'lineHeight'); - const direction = span.direction; - const language = span.language === undefined ? undefined : normalizeLanguage(span.language); - return { - ...(span.font === undefined ? {} : { font: span.font }), - ...(span.fontSize === undefined ? {} : { fontSize: finitePositive(span.fontSize, 'fontSize') }), - ...(lineHeight === undefined ? {} : { lineHeight }), - ...(span.letterSpacing === undefined ? {} : { letterSpacing: finite(span.letterSpacing, 'letterSpacing') }), - ...(language === undefined ? {} : { language }), - ...(direction === undefined ? {} : { direction }), - ...(span.features === undefined ? {} : { features: resolveFeatures(span.features, span.start, span.end) }), - }; -} - -/** The paragraph is the outermost scope, so an unstated property inherits from it. */ -function styleOver(root: ResolvedStyle, stated: StatedSpanStyle): ResolvedStyle { - const lineHeight = stated.lineHeight ?? root.lineHeight; - const language = stated.language ?? root.language; - const direction = stated.direction ?? root.direction; - return { - font: stated.font ?? root.font, - fontSize: stated.fontSize ?? root.fontSize, - ...(lineHeight === undefined ? {} : { lineHeight }), - letterSpacing: stated.letterSpacing ?? root.letterSpacing, - ...(language === undefined ? {} : { language }), - direction, - ...(stated.direction === undefined || direction === 'auto' ? {} : { bidiOverride: direction }), - features: stated.features ?? root.features, - }; -} - -function resolveStyle( - shaper: RuntimeShaper, - fontHandle: FontHandle, - style: ParagraphStyle, - start: number, - end: number, -): ResolvedStyle { - const font = requireFont(shaper, fontHandle); - shaper.registerFont(font); - const fontSize = finitePositive(style.fontSize ?? DEFAULT_FONT_SIZE, 'fontSize'); - const lineHeight = style.lineHeight === undefined ? undefined : finitePositive(style.lineHeight, 'lineHeight'); - const letterSpacing = finite(style.letterSpacing ?? 0, 'letterSpacing'); - const language = normalizeLanguage(style.language); - const direction = style.direction ?? 'auto'; - return { - font: fontHandle, - fontSize, - ...(lineHeight === undefined ? {} : { lineHeight }), - letterSpacing, - ...(language === undefined ? {} : { language }), - direction, - features: resolveFeatures(style.features ?? [], start, end), - }; -} - -function resolveFeatures( - features: readonly FontFeature[], - containingStart: number, - containingEnd: number, -): readonly ResolvedFontFeature[] { - return features.flatMap((feature) => { - const start = feature.start ?? containingStart; - const end = feature.end ?? containingEnd; - // A feature that states no range covers whatever it is applied to, so an empty containing range makes it - // vacuous rather than invalid. Rejecting it would fail an ordinary feature-styled field before its first - // character is typed. An explicitly empty range is still a caller error. - if (feature.start === undefined && feature.end === undefined && start === end) return []; - assertTextRange(start, end, containingEnd, `feature ${feature.tag}`); - if (start < containingStart) throw new RangeError(`feature ${feature.tag} starts before its style range`); - const value = feature.value ?? 1; - if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { - throw new RangeError(`feature ${feature.tag} value must be a uint32`); - } - return [{ tag: feature.tag, value, start, end }]; - }); -} - -function prepareRuns( - text: string, - styles: readonly StyleSegment[], - unicode: UnicodeTextAnalysis, - bidi: OwnedBidiAnalysis, -): readonly PreparedRun[] { - const runs: PreparedRun[] = []; - const bidiItems = bidiRuns(bidi.levels); - let styleIndex = 0; - let scriptIndex = 0; - let bidiIndex = 0; - while (styleIndex < styles.length && scriptIndex < unicode.scriptItems.length && bidiIndex < bidiItems.length) { - const style = styles[styleIndex]; - const script = unicode.scriptItems[scriptIndex]; - const bidiRun = bidiItems[bidiIndex]; - if (style === undefined || script === undefined || bidiRun === undefined) break; - const start = Math.max(style.start, script.start, bidiRun.start); - const end = Math.min(style.end, script.end, bidiRun.end); - if (start < end) { - const direction = style.style.bidiOverride ?? directionForLevel(bidiRun.level); - const bidiLevel = - style.style.bidiOverride === undefined ? bidiRun.level : forceLevelDirection(bidiRun.level, direction); - for (const fragment of drawableFragments(text, start, end)) { - appendPreparedRun(runs, { - ...fragment, - style: style.style, - script: script.script, - direction, - bidiLevel, - }); - } - } - const boundary = Math.min(style.end, script.end, bidiRun.end); - if (style.end === boundary) styleIndex += 1; - if (script.end === boundary) scriptIndex += 1; - if (bidiRun.end === boundary) bidiIndex += 1; - } - if (runs.length === 0) { - const fallback = styles[0]; - if (fallback !== undefined) { - const level = bidi.levels[0] ?? bidi.paragraphLevels[0] ?? 0; - runs.push({ - start: fallback.start, - end: fallback.start, - style: fallback.style, - script: 'Zyyy', - direction: fallback.style.bidiOverride ?? directionForLevel(level), - bidiLevel: level, - }); - } - } - return runs; -} - -function appendPreparedRun(runs: PreparedRun[], run: PreparedRun): void { - const previous = runs.at(-1); - if ( - previous !== undefined && - previous.end === run.start && - previous.script === run.script && - previous.direction === run.direction && - previous.bidiLevel === run.bidiLevel && - equalStyles(previous.style, run.style) - ) { - runs[runs.length - 1] = { ...previous, end: run.end }; - } else { - runs.push(run); - } -} - -function bidiRuns(levels: Uint8Array): readonly { - readonly start: number; - readonly end: number; - readonly level: number; -}[] { - const runs = []; - let start = 0; - while (start < levels.length) { - const level = levels[start]; - if (level === undefined) break; - let end = start + 1; - while (end < levels.length && levels[end] === level) end += 1; - runs.push({ start, end, level }); - start = end; - } - return runs; -} - -function directionForLevel(level: number): 'ltr' | 'rtl' { - return (level & 1) === 0 ? 'ltr' : 'rtl'; -} - -function forceLevelDirection(level: number, direction: 'ltr' | 'rtl'): number { - return directionForLevel(level) === direction ? level : level + 1; -} - -function shapeRequest( - text: string, - runs: readonly PreparedRun[], -): { - readonly request: ShapeBatchRequest; - readonly ellipses: readonly Omit[]; -} { - const features: ResolvedFontFeature[] = []; - const shapeRuns = runs.map((run) => { - const selected = run.style.features.filter((feature) => feature.start < run.end && feature.end > run.start); - const featureStart = features.length; - features.push(...selected); - return { - font: run.style.font, - textStart: run.start, - textEnd: run.end, - direction: run.direction, - script: run.script, - ...(run.style.language === undefined ? {} : { language: run.style.language }), - clusterLevel: 0 as const, - flags: PRODUCE_UNSAFE_TO_CONCAT, - featureStart, - featureCount: selected.length, - }; - }); - const ellipses = runs.map((run, sourceRun) => { - const textStart = text.length + sourceRun; - const shapeRun = shapeRuns.length; - shapeRuns.push({ - font: run.style.font, - textStart, - textEnd: textStart + 1, - direction: run.direction, - script: run.script, - ...(run.style.language === undefined ? {} : { language: run.style.language }), - clusterLevel: 0 as const, - flags: PRODUCE_UNSAFE_TO_CONCAT, - featureStart: features.length, - featureCount: 0, - }); - return { sourceRun, shapeRun, textStart, textEnd: textStart + 1 }; - }); - return { - request: { textUtf16: utf16(`${text}${'…'.repeat(runs.length)}`), runs: shapeRuns, features }, - ellipses, - }; -} - -function measureEllipses( - shaper: RuntimeShaper, - runs: readonly PreparedRun[], - shape: OwnedShape, - ellipses: readonly Omit[], -): readonly PreparedEllipsis[] { - return ellipses.map((ellipsis) => { - const run = runs[ellipsis.sourceRun]; - const glyphStart = shape.runGlyphStarts[ellipsis.shapeRun]; - const glyphCount = shape.runGlyphCounts[ellipsis.shapeRun]; - if (run === undefined || glyphStart === undefined || glyphCount === undefined) { - throw new Error('shaper returned an incomplete ellipsis run'); - } - const font = requireFont(shaper, run.style.font); - const scale = run.style.fontSize / font.metrics.unitsPerEm; - let advance = 0; - for (let glyph = glyphStart; glyph < glyphStart + glyphCount; glyph += 1) { - advance += Math.abs(shape.xAdvances[glyph] ?? 0) * scale; - } - return { ...ellipsis, advance }; - }); -} - -function measureClusters( - shaper: RuntimeShaper, - text: string, - unicode: UnicodeTextAnalysis, - styles: readonly StyleSegment[], - runs: readonly PreparedRun[], - shape: OwnedShape, - previous?: PreparedParagraph, -): MeasuredClusters { - // Shaping results are gathered by text offset rather than into a map and two sets keyed by offset. Every glyph of the - // paragraph touched all three, so the hash entries were themselves a per-glyph allocation. Advances still accumulate - // in the shaped order and in double precision, so the totals are unchanged. - const offsets = text.length + 1; - const offsetAdvances = new Float64Array(offsets); - const offsetFlags = new Uint8Array(offsets); - for (let runIndex = 0; runIndex < runs.length; runIndex += 1) { - const run = runs[runIndex]; - const glyphStart = shape.runGlyphStarts[runIndex]; - const glyphCount = shape.runGlyphCounts[runIndex]; - if (run === undefined || glyphStart === undefined || glyphCount === undefined) { - throw new Error('shaper returned an incomplete run table'); - } - const font = requireFont(shaper, run.style.font); - const scale = run.style.fontSize / font.metrics.unitsPerEm; - offsetFlags[run.start] = (offsetFlags[run.start] ?? 0) | OFFSET_SHAPED_BOUNDARY; - offsetFlags[run.end] = (offsetFlags[run.end] ?? 0) | OFFSET_SHAPED_BOUNDARY; - for (let glyph = glyphStart; glyph < glyphStart + glyphCount; glyph += 1) { - const cluster = shape.clusters[glyph]; - const advance = shape.xAdvances[glyph]; - const flags = shape.glyphFlags[glyph]; - if (cluster === undefined || advance === undefined || flags === undefined) { - throw new Error('shaper returned an incomplete glyph table'); - } - offsetAdvances[cluster] = (offsetAdvances[cluster] ?? 0) + Math.abs(advance) * scale; - offsetFlags[cluster] = - (offsetFlags[cluster] ?? 0) | - OFFSET_SHAPED_BOUNDARY | - ((flags & GLYPH_UNSAFE_TO_BREAK) !== 0 ? OFFSET_UNSAFE_TO_BREAK : 0); - } - } - const requiredBreaks = new Uint8Array(offsets); - for (const entry of unicode.lineBreaks) requiredBreaks[entry.position] = entry.required ? 1 : 0; - - const count = Math.max(0, unicode.graphemeBoundaries.length - 1); - const retained = previous?.clusters; - const starts = reuseTypedArray(retained?.starts, count, (capacity) => new Uint32Array(capacity).subarray(0, count)); - const ends = reuseTypedArray(retained?.ends, count, (capacity) => new Uint32Array(capacity).subarray(0, count)); - const clusterAdvances = reuseTypedArray(retained?.advances, count, (capacity) => - new Float64Array(capacity).subarray(0, count), - ); - const flags = reuseTypedArray(retained?.flags, count, (capacity) => new Uint8Array(capacity).subarray(0, count)); - const styleIndexes = reuseTypedArray(retained?.styleIndexes, count, (capacity) => - new Uint32Array(capacity).subarray(0, count), - ); - let styleIndex = 0; - for (let index = 0; index < count; index += 1) { - const start = unicode.graphemeBoundaries[index] ?? 0; - const end = unicode.graphemeBoundaries[index + 1] ?? 0; - while ((styles[styleIndex]?.end ?? Number.POSITIVE_INFINITY) <= start) styleIndex += 1; - const styleSegment = styles[styleIndex]; - if (styleSegment === undefined || styleSegment.start > start) { - throw new Error(`paragraph offset ${start} has no resolved style`); - } - const hardBreak = isHardBreak(text, start); - const boundary = offsetFlags[start] ?? 0; - starts[index] = start; - ends[index] = end; - clusterAdvances[index] = (offsetAdvances[start] ?? 0) + (hardBreak ? 0 : styleSegment.style.letterSpacing); - styleIndexes[index] = styleIndex; - flags[index] = - ((boundary & (OFFSET_SHAPED_BOUNDARY | OFFSET_UNSAFE_TO_BREAK)) === OFFSET_SHAPED_BOUNDARY - ? CLUSTER_SAFE_BEFORE - : 0) | - (requiredBreaks[end] === 1 ? CLUSTER_REQUIRED_BREAK : 0) | - (hardBreak ? CLUSTER_HARD_BREAK : 0); - } - return { count, starts, ends, advances: clusterAdvances, flags, styleIndexes }; -} - -/** The style a cluster resolved to during measurement. */ -function clusterStyle(prepared: PreparedParagraph, index: number): ResolvedStyle { - const style = prepared.styles[prepared.clusters.styleIndexes[index] ?? 0]?.style; - if (style === undefined) throw new Error(`cluster ${index} has no resolved style`); - return style; -} - -/** Smallest retained index capacity. Ordinary paragraphs never pay a growth step on their first frames. */ -const MINIMUM_CLUSTER_INDEX_CAPACITY = 512; - -/** - * Reuses a retained buffer's memory when it already holds `length` elements, and otherwise grows to a high watermark - * that later preparations reuse. The returned view carries the live length, so binary searches over it stay correct - * while the backing allocation outlives any single preparation. - */ -function reuseTypedArray( - previous: Array | undefined, - length: number, - construct: (capacity: number) => Array, -): Array { - const capacity = previous === undefined ? 0 : previous.buffer.byteLength / previous.BYTES_PER_ELEMENT; - if (previous !== undefined && capacity >= length) { - const view = new (previous.constructor as new (buffer: ArrayBufferLike, offset: number, length: number) => Array)( - previous.buffer, - 0, - length, - ); - view.fill(0); - return view; - } - return construct(Math.max(length, MINIMUM_CLUSTER_INDEX_CAPACITY, capacity * 2)); -} - -function indexClusters( - text: string, - styles: readonly StyleSegment[], - clusters: MeasuredClusters, - previous?: PreparedParagraph, -): Pick { - const { count, starts, flags, styleIndexes } = clusters; - const letterSpacingPrefix = reuseTypedArray(previous?.letterSpacingPrefix, count + 1, (capacity) => - new Float64Array(capacity).subarray(0, count + 1), - ); - const spacePrefix = reuseTypedArray(previous?.spacePrefix, count + 1, (capacity) => - new Uint32Array(capacity).subarray(0, count + 1), - ); - const clusterIndexAt = reuseTypedArray(previous?.clusterIndexAt, text.length + 1, (capacity) => - new Uint32Array(capacity).subarray(0, text.length + 1), - ); - for (let index = 0; index < count; index += 1) { - const start = starts[index] ?? 0; - const letterSpacing = - ((flags[index] ?? 0) & CLUSTER_HARD_BREAK) !== 0 - ? 0 - : (styles[styleIndexes[index] ?? 0]?.style.letterSpacing ?? 0); - letterSpacingPrefix[index + 1] = (letterSpacingPrefix[index] ?? 0) + letterSpacing; - spacePrefix[index + 1] = (spacePrefix[index] ?? 0) + (text.charCodeAt(start) === 0x20 ? 1 : 0); - } - // The same answer a lower-bound search over the cluster starts gives, resolved once for every offset in one pass. - let cluster = 0; - for (let offset = 0; offset <= text.length; offset += 1) { - while (cluster < count && (starts[cluster] ?? 0) < offset) cluster += 1; - clusterIndexAt[offset] = cluster; - } - return { clusterIndexAt, letterSpacingPrefix, spacePrefix }; -} - -function planLines( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - constraints: NormalizedConstraints, -): readonly LinePlan[] { - const widthLimit = constraints.width.mode === 'unconstrained' ? Number.POSITIVE_INFINITY : constraints.width.size; - const { count, starts, ends, flags } = prepared.clusters; - const allowed = new Set(); - if (constraints.wrap === 'character') { - for (let index = 0; index < count; index += 1) { - const nextIsSafe = index + 1 === count || ((flags[index + 1] ?? 0) & CLUSTER_SAFE_BEFORE) !== 0; - if (nextIsSafe) allowed.add(ends[index] ?? 0); - } - } else if (constraints.wrap === 'word') { - const shapingBoundaries = new Set(); - for (let index = 0; index < count; index += 1) { - if (((flags[index] ?? 0) & CLUSTER_SAFE_BEFORE) !== 0) shapingBoundaries.add(starts[index] ?? 0); - } - shapingBoundaries.add(prepared.input.text.length); - for (const entry of prepared.unicode.lineBreaks) { - if (shapingBoundaries.has(entry.position)) allowed.add(entry.position); - } - } - - return breakLines(shaper, prepared, allowed, widthLimit, constraints.wrap); -} - -function measurePrepared( - prepared: PreparedParagraph, - constraints: NormalizedConstraints, - allLines: readonly LinePlan[], -): MeasuredPlan { - const lines = visibleLines(prepared, constraints, allLines); - const naturalContentWidth = allLines.reduce((maximum, line) => Math.max(maximum, line.advance), 0); - const contentHeight = allLines.reduce((sum, line) => sum + line.height, 0); - const displayAdvances = lines.map((line, index) => measuredLineAdvance(prepared, constraints, lines, line, index)); - const visibleWidth = displayAdvances.reduce((maximum, advance) => Math.max(maximum, advance), 0); - const contentWidth = Math.max(naturalContentWidth, visibleWidth); - const visibleHeight = lines.reduce((sum, line) => sum + line.height, 0); - const width = resolveAxis(constraints.width, visibleWidth); - const height = resolveAxis(constraints.height, visibleHeight); - let blockOffset = 0; - const baselines = lines.map((line) => { - const baseline = blockOffset + line.baseline; - blockOffset += line.height; - return baseline; - }); - const measurement = Object.freeze({ - width, - height, - contentWidth, - contentHeight, - firstBaseline: baselines[0] ?? 0, - lastBaseline: baselines.at(-1) ?? 0, - overflowed: lines.length < allLines.length || contentWidth > width || contentHeight > height, - }); - return { measurement, lines }; -} - -function measuredLineAdvance( - prepared: PreparedParagraph, - constraints: NormalizedConstraints, - lines: readonly LinePlan[], - line: LinePlan, - index: number, -): number { - if ( - constraints.align !== 'justify' || - constraints.width.mode !== 'exactly' || - line.hardBreak || - index >= lines.length - 1 || - justificationSpaces(prepared, line, line.textStart, line.textEnd) === 0 - ) { - return line.advance; - } - return Math.max(line.advance, constraints.width.size); -} - -function visibleLines( - prepared: PreparedParagraph, - constraints: NormalizedConstraints, - allLines: readonly LinePlan[], -): readonly LinePlan[] { - let count = constraints.maxLines === undefined ? allLines.length : Math.min(allLines.length, constraints.maxLines); - if (constraints.overflow === 'ellipsis' && constraints.height.mode !== 'unconstrained') { - let height = 0; - let fitting = 0; - for (const line of allLines) { - if (height + line.height > constraints.height.size) break; - height += line.height; - fitting += 1; - } - count = Math.min(count, fitting); - } - const lines = allLines.slice(0, count); - if (constraints.overflow !== 'ellipsis' || lines.length === 0) return lines; - const last = lines.at(-1); - if (last === undefined) return lines; - const widthLimit = constraints.width.mode === 'unconstrained' ? Number.POSITIVE_INFINITY : constraints.width.size; - const truncated = count < allLines.length; - if (!truncated && last.advance <= widthLimit) return lines; - return [...lines.slice(0, -1), ellipsizeLine(prepared, last, widthLimit)]; -} - -function ellipsizeLine(prepared: PreparedParagraph, line: LinePlan, widthLimit: number): LinePlan { - const { count, starts, advances, flags } = prepared.clusters; - const startAt = (index: number): number => (index < count ? (starts[index] ?? 0) : line.textStart); - let clusterEnd = line.clusterEnd; - let advance = line.advance; - while (clusterEnd > line.clusterStart && ((flags[clusterEnd - 1] ?? 0) & CLUSTER_HARD_BREAK) !== 0) { - clusterEnd -= 1; - } - let selected = ellipsisAt(prepared, line.textEnd); - while (clusterEnd > line.clusterStart && Number.isFinite(widthLimit) && advance + selected.advance > widthLimit) { - clusterEnd -= 1; - if (clusterEnd < count) advance -= advances[clusterEnd] ?? 0; - selected = ellipsisAt(prepared, startAt(clusterEnd)); - } - const textEnd = startAt(clusterEnd); - const levelOffset = Math.max(line.textStart, textEnd - 1); - const level = prepared.bidi.levels[levelOffset] ?? paragraphLevelAt(prepared.bidi, textEnd); - return { - ...line, - clusterEnd, - textEnd, - advance: Math.max(0, advance) + selected.advance, - hardBreak: false, - ellipsis: { - ...selected, - cluster: textEnd, - level, - }, - }; -} - -function ellipsisAt(prepared: PreparedParagraph, offset: number): PreparedEllipsis { - const sourceRun = prepared.runs.findIndex((run) => run.start < offset && offset <= run.end); - const fallbackRun = prepared.runs.findIndex((run) => run.start <= offset && offset < run.end); - const run = sourceRun >= 0 ? sourceRun : fallbackRun; - const ellipsis = prepared.ellipses.find((entry) => entry.sourceRun === run) ?? prepared.ellipses[0]; - if (ellipsis === undefined) throw new Error('paragraph has no ellipsis shaping run'); - return ellipsis; -} - -function breakLines( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - allowed: ReadonlySet, - widthLimit: number, - wrap: 'none' | 'word' | 'character', -): readonly LinePlan[] { - const { count, starts, ends, advances: clusterAdvances, flags } = prepared.clusters; - if (count === 0) return []; - const lines: LinePlan[] = []; - let lineStart = 0; - while (lineStart < count) { - let advance = 0; - let lastAllowed = -1; - let lastAllowedAdvance = 0; - let lastSafe = -1; - let lastSafeAdvance = 0; - let lineEnd = count; - let lineAdvance = 0; - for (let index = lineStart; index < count; index += 1) { - const clusterFlags = flags[index] ?? 0; - if (index > lineStart && (clusterFlags & CLUSTER_SAFE_BEFORE) !== 0) { - lastSafe = index; - lastSafeAdvance = advance; - } - const requiredBreak = (clusterFlags & CLUSTER_REQUIRED_BREAK) !== 0; - const nextAdvance = advance + (clusterAdvances[index] ?? 0); - if (wrap !== 'none' && Number.isFinite(widthLimit) && nextAdvance > widthLimit && index > lineStart) { - if (lastAllowed > lineStart) { - lineEnd = lastAllowed; - lineAdvance = lastAllowedAdvance; - } else if (lastSafe > lineStart) { - lineEnd = lastSafe; - lineAdvance = lastSafeAdvance; - } else { - advance = nextAdvance; - if (requiredBreak || index === count - 1) { - lineEnd = index + 1; - lineAdvance = advance; - break; - } - continue; - } - break; - } - advance = nextAdvance; - if (requiredBreak) { - lineEnd = index + 1; - lineAdvance = advance; - break; - } - if (allowed.has(ends[index] ?? 0)) { - lastAllowed = index + 1; - lastAllowedAdvance = advance; - } - if (index === count - 1) lineAdvance = advance; - } - if (lineEnd <= lineStart) { - lineEnd = lineStart + 1; - lineAdvance = clusterAdvances[lineStart] ?? 0; - } - const lastHardBreak = ((flags[lineEnd - 1] ?? 0) & CLUSTER_HARD_BREAK) !== 0; - const metrics = metricsForLine(shaper, prepared, lineStart, lineEnd, prepared.styles[0]?.style); - lines.push({ - clusterStart: lineStart, - clusterEnd: lineEnd, - textStart: starts[lineStart] ?? 0, - textEnd: (lastHardBreak ? starts[lineEnd - 1] : ends[lineEnd - 1]) ?? 0, - advance: lineAdvance, - hardBreak: lastHardBreak, - ...metrics, - }); - lineStart = lineEnd; - } - if (((flags[count - 1] ?? 0) & CLUSTER_HARD_BREAK) !== 0) { - const metrics = metricsForLine(shaper, prepared, count, count, prepared.styles[0]?.style); - lines.push({ - clusterStart: count, - clusterEnd: count, - textStart: prepared.input.text.length, - textEnd: prepared.input.text.length, - advance: 0, - hardBreak: false, - ...metrics, - }); - } - return lines; -} - -/** - * Line box metrics over the drawable clusters of `[lineStart, lineEnd)`. Both extents are maxima over the contributing - * styles, so the clusters are visited in place: the order is irrelevant and a repeated style contributes nothing new, - * which lets the common single-style line resolve its font and scale once. - */ -function metricsForLine( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - lineStart: number, - lineEnd: number, - fallback?: ResolvedStyle, -): LineMetrics { - const { flags, styleIndexes } = prepared.clusters; - let above = 0; - let below = 0; - let contributed = false; - let lastStyleIndex = -1; - for (let index = lineStart; index < lineEnd; index += 1) { - if (((flags[index] ?? 0) & CLUSTER_HARD_BREAK) !== 0) continue; - const styleIndex = styleIndexes[index] ?? 0; - if (contributed && styleIndex === lastStyleIndex) continue; - lastStyleIndex = styleIndex; - contributed = true; - const extents = styleLineExtents(shaper, clusterStyle(prepared, index)); - above = Math.max(above, extents.above); - below = Math.max(below, extents.below); - } - if (!contributed && fallback !== undefined) { - const extents = styleLineExtents(shaper, fallback); - above = Math.max(above, extents.above); - below = Math.max(below, extents.below); - } - return { height: above + below, baseline: above }; -} - -function styleLineExtents( - shaper: RuntimeShaper, - style: ResolvedStyle, -): { readonly above: number; readonly below: number } { - const font = requireFont(shaper, style.font); - const scale = style.fontSize / font.metrics.unitsPerEm; - const ascent = font.metrics.ascender * scale; - const descent = -font.metrics.descender * scale; - const natural = (font.metrics.ascender - font.metrics.descender + font.metrics.lineGap) * scale; - const height = style.lineHeight === undefined ? natural : style.fontSize * style.lineHeight; - const leading = Math.max(0, height - ascent - descent); - return { above: ascent + leading / 2, below: descent + leading / 2 }; -} - -function normalizeConstraints(constraints: ParagraphConstraints = {}): NormalizedConstraints { - if (!isNonArrayObject(constraints)) throw new TypeError('paragraph constraints must be an object'); - const width = normalizeAxis(constraints.width, 'width'); - const height = normalizeAxis(constraints.height, 'height'); - const maxLines = constraints.maxLines; - if (maxLines !== undefined && (!Number.isSafeInteger(maxLines) || maxLines <= 0)) { - throw new RangeError('maxLines must be a positive safe integer'); - } - const wrap = constraints.wrap ?? 'word'; - if (wrap !== 'none' && wrap !== 'word' && wrap !== 'character') { - throw new RangeError('wrap must be none, word, or character'); - } - const align = constraints.align ?? 'start'; - if (align !== 'start' && align !== 'center' && align !== 'end' && align !== 'justify') { - throw new RangeError('align must be start, center, end, or justify'); - } - const overflow = constraints.overflow ?? 'visible'; - if (overflow !== 'visible' && overflow !== 'clip' && overflow !== 'ellipsis') { - throw new RangeError('overflow must be visible, clip, or ellipsis'); - } - return { - width, - height, - ...(maxLines === undefined ? {} : { maxLines }), - wrap, - align, - overflow, - }; -} - -function normalizeAxis(constraint: ParagraphAxisConstraint | undefined, name: string): ParagraphAxisConstraint { - if (constraint === undefined) return { mode: 'unconstrained' }; - if (!isNonArrayObject(constraint)) throw new TypeError(`${name} constraint must be an object`); - if (constraint.mode === 'unconstrained') return { mode: 'unconstrained' }; - if (constraint.mode !== 'at-most' && constraint.mode !== 'exactly') { - throw new RangeError(`${name} mode must be unconstrained, at-most, or exactly`); - } - return { mode: constraint.mode, size: finiteNonnegative(constraint.size, `${name} size`) }; -} - -function constraintKey(constraints: NormalizedConstraints): string { - return JSON.stringify(constraints); -} - -function linePlanConstraintKey(constraints: NormalizedConstraints): string { - return JSON.stringify({ - width: constraints.width, - wrap: constraints.wrap, - }); -} - -function geometryLinesKey(positioningKey: string, align: NormalizedConstraints['align'], boxWidth: number): string { - return JSON.stringify({ - positioningKey, - align, - boxWidth, - }); -} - -function positioningLinesKey(lines: readonly LinePlan[]): string { - return JSON.stringify( - lines.map((line) => ({ - clusterStart: line.clusterStart, - clusterEnd: line.clusterEnd, - textStart: line.textStart, - textEnd: line.textEnd, - hardBreak: line.hardBreak, - ...(line.ellipsis === undefined - ? {} - : { - ellipsis: { - sourceRun: line.ellipsis.sourceRun, - shapeRun: line.ellipsis.shapeRun, - cluster: line.ellipsis.cluster, - level: line.ellipsis.level, - }, - }), - })), - ); -} - -function positionPrepared( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - lines: readonly LinePlan[], - positioning: PreparedPositioning, - constraints: NormalizedConstraints, - boxWidth: number, -): PositionedGeometry { - if (lines.length === 0) return emptyGeometry(); - const { fragments, reshaped } = positioning; - const reshapeRunByFragment = new Map(); - let reshapeRun = 0; - for (const [fragmentIndex, fragment] of fragments.entries()) { - if (!fragment.reshape) continue; - reshapeRunByFragment.set(fragmentIndex, reshapeRun); - reshapeRun += 1; - } - - const fontHandles: number[] = []; - const fontSlots = new Map(); - // Every output glyph comes from one source glyph, so the shaped runs bound the output. The arrays are written in - // place and sliced to the final count, which removes both the per-glyph push and the copy that `TypedArray.from` - // made out of every accumulator. - let capacity = prepared.shape.glyphIds.length + (reshaped?.glyphIds.length ?? 0); - let glyphFontSlots = new Uint16Array(capacity); - let glyphIds = new Uint16Array(capacity); - let clusters = new Uint32Array(capacity); - let glyphFontSizes = new Float32Array(capacity); - // Both axes accumulate in double precision and narrow once, when the geometry is handed out. Alignment and - // justification already read `x` back and add to it, and single precision would round at the store and again at the - // adjustment and drift from the golden layout. The same becomes true of `y` the moment vertical alignment or a - // vertical writing mode lands, so the rule is the axis, not today's caller. - let x = new Float64Array(capacity); - let y = new Float64Array(capacity); - let glyphFlags = new Uint16Array(capacity); - const justifying = constraints.align === 'justify'; - let justificationCounts = justifying ? new Uint32Array(capacity) : undefined; - const lineTextStarts = new Uint32Array(lines.length); - const lineTextEnds = new Uint32Array(lines.length); - const lineGlyphStarts = new Uint32Array(lines.length); - const lineGlyphCounts = new Uint32Array(lines.length); - const lineBaselines = new Float32Array(lines.length); - const lineAdvances = new Float32Array(lines.length); - let count = 0; - let blockOffset = 0; - let fragmentIndex = 0; - - const reserve = (needed: number): void => { - if (needed <= capacity) return; - let next = Math.max(capacity * 2, 64); - while (next < needed) next *= 2; - capacity = next; - glyphFontSlots = grownTypedArray(glyphFontSlots, next); - glyphIds = grownTypedArray(glyphIds, next); - clusters = grownTypedArray(clusters, next); - glyphFontSizes = grownTypedArray(glyphFontSizes, next); - x = grownTypedArray(x, next); - y = grownTypedArray(y, next); - glyphFlags = grownTypedArray(glyphFlags, next); - if (justificationCounts !== undefined) justificationCounts = grownTypedArray(justificationCounts, next); - }; - - for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) { - const line = lines[lineIndex]!; - const lineGlyphStart = count; - let passedSpaces = 0; - let cursor = 0; - const baseline = blockOffset + line.baseline; - while (fragments[fragmentIndex]?.line === lineIndex) { - const fragment = fragments[fragmentIndex]; - if (fragment === undefined) break; - const run = prepared.runs[fragment.run]; - if (run === undefined) throw new Error('line fragment references a missing shaping run'); - const reshapedRun = reshapeRunByFragment.get(fragmentIndex); - const source = reshapedRun === undefined ? prepared.shape : reshaped; - const sourceRun = fragment.ellipsis?.shapeRun ?? reshapedRun ?? fragment.run; - if (source === undefined) throw new Error('boundary reshape result is missing'); - const glyphStart = source.runGlyphStarts[sourceRun]; - const glyphCount = source.runGlyphCounts[sourceRun]; - if (glyphStart === undefined || glyphCount === undefined) { - throw new Error('shaper returned an incomplete positioned run'); - } - let slot = fontSlots.get(run.style.font); - if (slot === undefined) { - slot = fontHandles.length; - fontHandles.push(run.style.font); - fontSlots.set(run.style.font, slot); - } - const font = requireFont(shaper, run.style.font); - const scale = run.style.fontSize / font.metrics.unitsPerEm; - const selectedStart = fragment.ellipsis?.textStart ?? fragment.start; - const selectedEnd = fragment.ellipsis?.textEnd ?? fragment.end; - const selected = glyphRange(source, glyphStart, glyphCount, selectedStart, selectedEnd); - reserve(count + (selected.end - selected.start)); - let clusterBoundary = run.direction === 'ltr' ? fragment.start : fragment.end; - for (let glyph = selected.start; glyph < selected.end; glyph += 1) { - const cluster = source.clusters[glyph]; - const glyphId = source.glyphIds[glyph]; - const xAdvance = source.xAdvances[glyph]; - const xOffset = source.xOffsets[glyph]; - const yOffset = source.yOffsets[glyph]; - const flags = source.glyphFlags[glyph]; - if ( - cluster === undefined || - glyphId === undefined || - xAdvance === undefined || - xOffset === undefined || - yOffset === undefined || - flags === undefined - ) { - throw new Error('shaper returned an incomplete positioned glyph'); - } - glyphFontSlots[count] = slot; - glyphIds[count] = glyphId; - clusters[count] = fragment.ellipsis?.cluster ?? cluster; - glyphFontSizes[count] = run.style.fontSize; - if (justificationCounts !== undefined) justificationCounts[count] = passedSpaces; - x[count] = cursor + xOffset * scale; - y[count] = baseline - yOffset * scale; - glyphFlags[count] = flags; - count += 1; - cursor += Math.abs(xAdvance) * scale; - const nextCluster = glyph + 1 < selected.end ? source.clusters[glyph + 1] : selectedEnd; - if (nextCluster !== cluster && fragment.ellipsis === undefined) { - const rangeStart = run.direction === 'ltr' ? cluster : Math.min(cluster, clusterBoundary); - const rangeEnd = run.direction === 'ltr' ? (nextCluster ?? fragment.end) : Math.max(cluster, clusterBoundary); - cursor += spacingBetween(prepared, rangeStart, rangeEnd); - passedSpaces += justificationSpaces(prepared, line, rangeStart, rangeEnd); - clusterBoundary = cluster; - } - } - fragmentIndex += 1; - } - const available = Math.max(0, boxWidth - cursor); - if (justifying && !line.hardBreak && lineIndex < lines.length - 1 && passedSpaces > 0) { - const perSpace = available / passedSpaces; - for (let glyph = lineGlyphStart; glyph < count; glyph += 1) { - x[glyph] = (x[glyph] ?? 0) + (justificationCounts?.[glyph] ?? 0) * perSpace; - } - cursor += available; - } else { - const paragraphDirection = directionForLevel(paragraphLevelAt(prepared.bidi, line.textStart)); - const offset = alignmentOffset(constraints.align, paragraphDirection, available); - if (offset !== 0) { - for (let glyph = lineGlyphStart; glyph < count; glyph += 1) { - x[glyph] = (x[glyph] ?? 0) + offset; - } - } - } - lineTextStarts[lineIndex] = line.textStart; - lineTextEnds[lineIndex] = line.textEnd; - lineGlyphStarts[lineIndex] = lineGlyphStart; - lineGlyphCounts[lineIndex] = count - lineGlyphStart; - lineBaselines[lineIndex] = baseline; - lineAdvances[lineIndex] = cursor; - blockOffset += line.height; - } - - return { - fontHandles: Uint32Array.from(fontHandles), - glyphFontSlots: glyphFontSlots.subarray(0, count), - glyphIds: glyphIds.subarray(0, count), - clusters: clusters.subarray(0, count), - glyphFontSizes: glyphFontSizes.subarray(0, count), - x: new Float32Array(x.subarray(0, count)), - y: new Float32Array(y.subarray(0, count)), - glyphFlags: glyphFlags.subarray(0, count), - lineTextStarts, - lineTextEnds, - lineGlyphStarts, - lineGlyphCounts, - lineBaselines, - lineAdvances, - }; -} - -function grownTypedArray( - array: T, - capacity: number, -): T { - const next = new (array.constructor as new (length: number) => T)(capacity); - next.set(array as unknown as ArrayLike & ArrayBufferView as never); - return next; -} - -function preparePositioning( - shaper: RuntimeShaper, - prepared: PreparedParagraph, - lines: readonly LinePlan[], -): PreparedPositioning { - const fragments = collectLineFragments(prepared, lines); - const ranges: ReshapeRange[] = []; - for (const fragment of fragments) { - if (!fragment.reshape) continue; - const run = prepared.runs[fragment.run]; - if (run === undefined) throw new Error('line fragment references a missing shaping run'); - ranges.push({ - run: fragment.run, - itemStart: fragment.start, - itemEnd: fragment.end, - contextStart: run.start, - contextEnd: run.end, - flags: fragment.flags, - }); - } - const reshaped = ranges.length === 0 ? undefined : ownShape(shaper.reshapeRanges({ ...prepared.request, ranges })); - return { fragments, ...(reshaped === undefined ? {} : { reshaped }) }; -} - -function alignmentOffset(align: NormalizedConstraints['align'], direction: 'ltr' | 'rtl', available: number): number { - if (align === 'center') return available / 2; - if (align === 'end') return direction === 'ltr' ? available : 0; - if (align === 'start') return direction === 'rtl' ? available : 0; - return direction === 'rtl' ? available : 0; -} - -function justificationSpaces(prepared: PreparedParagraph, line: LinePlan, start: number, end: number): number { - let trimmedEnd = line.textEnd; - while (trimmedEnd > line.textStart && prepared.input.text.charCodeAt(trimmedEnd - 1) === 0x20) { - trimmedEnd -= 1; - } - return clusterRangeSum(prepared, prepared.spacePrefix, start, Math.min(end, trimmedEnd)); -} - -function collectLineFragments(prepared: PreparedParagraph, lines: readonly LinePlan[]): readonly LineFragment[] { - const fragments: LineFragmentDraft[] = []; - // One scratch buffer for the whole paragraph rather than a fresh copy of every line's levels. The reordered levels - // of a line are read only while that line's fragments are built, so the lines share it. - const levels = new Uint8Array(prepared.input.text.length); - for (const [lineIndex, line] of lines.entries()) { - const levelCount = reorderedLineLevels(prepared.bidi, line.textStart, line.textEnd, levels); - const logicalStart = fragments.length; - for (const [runIndex, run] of prepared.runs.entries()) { - const start = Math.max(line.textStart, run.start); - const end = Math.min(line.textEnd, run.end); - if (start >= end) continue; - let fragmentStart = start; - while (fragmentStart < end) { - const level = fragmentLevel(run, levels, levelCount, fragmentStart - line.textStart); - let fragmentEnd = fragmentStart + 1; - while (fragmentEnd < end) { - if (fragmentLevel(run, levels, levelCount, fragmentEnd - line.textStart) !== level) break; - fragmentEnd += 1; - } - fragments.push({ - line: lineIndex, - run: runIndex, - start: fragmentStart, - end: fragmentEnd, - level, - flags: 0, - reshape: false, - }); - fragmentStart = fragmentEnd; - } - } - const logicalEnd = fragments.length; - for (let index = logicalStart; index < logicalEnd; index += 1) { - const fragment = fragments[index]; - if (fragment === undefined) continue; - const first = index === logicalStart; - const last = index === logicalEnd - 1; - const run = prepared.runs[fragment.run]; - if (run === undefined) throw new Error('line fragment references a missing shaping run'); - fragment.flags = PRODUCE_UNSAFE_TO_CONCAT | (first ? BEGINNING_OF_TEXT : 0) | (last ? END_OF_TEXT : 0); - // A reshape can only change the answer when it shapes with LESS context than the paragraph shape already used. - // Boundary reshaping requests the whole run as context, which is exactly the context that produced the retained - // shape, so the shaper returns the glyphs it already returned. The buffer's beginning- and end-of-text flags do - // not rescue it either: they describe the buffer edge, and the surrounding text shipped as context overrides - // them. Measured byte-identical over 640 ranges and 20,280 glyphs across Latin word wrap, Arabic word wrap, and - // Arabic character wrap narrow enough to break inside joined words, and the whole alignment, clipping, - // max-lines, ellipsis, and justification contract lays out identically with it disabled. - // - // Narrowing the context is a real future need — a truncated line whose last letter should take its final form, - // or a line shaped as an isolated unit — and `ReshapeRange` stays for it. Set this where that context is - // narrowed, not on every unsafe boundary. - fragment.reshape = false; - } - if (line.ellipsis !== undefined) { - fragments.push({ - line: lineIndex, - run: line.ellipsis.sourceRun, - start: line.ellipsis.cluster, - end: line.ellipsis.cluster, - level: line.ellipsis.level, - flags: PRODUCE_UNSAFE_TO_CONCAT, - reshape: false, - ellipsis: line.ellipsis, - }); - } - reorderFragments(fragments, logicalStart, fragments.length); - } - return fragments; -} - -/** The bidi level a fragment resolves to, honouring a span that overrode the run's direction. */ -function fragmentLevel(run: PreparedRun, levels: Uint8Array, levelCount: number, localOffset: number): number { - const resolved = (localOffset < levelCount ? levels[localOffset] : undefined) ?? run.bidiLevel; - return run.style.bidiOverride === undefined ? resolved : forceLevelDirection(resolved, run.direction); -} - -/** - * Writes the line's levels, with the trailing and separator resets of UAX #9 rule L1 applied, into the first - * `end - start` entries of `levels` and returns how many it wrote. - */ -function reorderedLineLevels(bidi: OwnedBidiAnalysis, start: number, end: number, levels: Uint8Array): number { - const count = Math.max(0, Math.min(end, bidi.levels.length) - start); - for (let index = 0; index < count; index += 1) levels[index] = bidi.levels[start + index] ?? 0; - const classes = bidi.classes; - const paragraphLevel = paragraphLevelAt(bidi, start); - let resetFrom: number | undefined = 0; - let resetTo: number | undefined; - let previousLevel = paragraphLevel; - for (let index = 0; index < count; index += 1) { - const bidiClass = classes[start + index]; - if (bidiClass === BIDI_B || bidiClass === BIDI_S) { - resetTo = index + 1; - resetFrom ??= index; - } else if ( - bidiClass === BIDI_WS || - bidiClass === BIDI_FSI || - bidiClass === BIDI_LRI || - bidiClass === BIDI_RLI || - bidiClass === BIDI_PDI - ) { - resetFrom ??= index; - } else if ( - bidiClass === BIDI_RLE || - bidiClass === BIDI_LRE || - bidiClass === BIDI_RLO || - bidiClass === BIDI_LRO || - bidiClass === BIDI_PDF || - bidiClass === BIDI_BN - ) { - resetFrom ??= index; - levels[index] = previousLevel; - } else { - resetFrom = undefined; - } - if (resetFrom !== undefined && resetTo !== undefined) { - levels.fill(paragraphLevel, resetFrom, resetTo); - resetFrom = undefined; - resetTo = undefined; - } - previousLevel = levels[index] ?? paragraphLevel; - } - if (resetFrom !== undefined) levels.fill(paragraphLevel, resetFrom, count); - return count; -} - -function paragraphLevelAt(bidi: OwnedBidiAnalysis, offset: number): number { - for (let index = 0; index < bidi.paragraphStarts.length; index += 1) { - const start = bidi.paragraphStarts[index]; - const end = bidi.paragraphEnds[index]; - if (start !== undefined && end !== undefined && start <= offset && offset < end) { - return bidi.paragraphLevels[index] ?? 0; - } - } - return bidi.paragraphLevels.at(-1) ?? 0; -} - -/** Reorders `visual[rangeStart, rangeEnd)` from logical into visual order in place, by UAX #9 rule L2. */ -function reorderFragments( - visual: Fragment[], - rangeStart: number, - rangeEnd: number, -): void { - let maximum = 0; - let lowestOdd = Number.POSITIVE_INFINITY; - for (let index = rangeStart; index < rangeEnd; index += 1) { - const level = visual[index]?.level ?? 0; - maximum = Math.max(maximum, level); - if ((level & 1) === 1) lowestOdd = Math.min(lowestOdd, level); - } - if (!Number.isFinite(lowestOdd)) return; - for (let level = maximum; level >= lowestOdd; level -= 1) { - let start = rangeStart; - while (start < rangeEnd) { - while (start < rangeEnd && (visual[start]?.level ?? -1) < level) start += 1; - let end = start; - while (end < rangeEnd && (visual[end]?.level ?? -1) >= level) end += 1; - reverse(visual, start, end); - start = end; - } - } -} - -function reverse(values: Value[], start: number, end: number): void { - for (let left = start, right = end - 1; left < right; left += 1, right -= 1) { - const value = values[left]; - if (value === undefined) break; - values[left] = values[right] as Value; - values[right] = value; - } -} - -/** - * The selected glyphs of a run are always one contiguous ascending span, so the selection is two indices. Materializing - * it as an array allocated one entry per glyph to describe a range that two integers already describe. - */ -function glyphRange( - shape: OwnedShape, - glyphStart: number, - glyphCount: number, - textStart: number, - textEnd: number, -): { readonly start: number; readonly end: number } { - if (glyphCount === 0 || textEnd <= textStart) return EMPTY_GLYPH_RANGE; - const firstCluster = shape.clusters[glyphStart]; - const lastCluster = shape.clusters[glyphStart + glyphCount - 1]; - if (firstCluster === undefined || lastCluster === undefined) { - throw new Error('shaper returned an incomplete glyph cluster table'); - } - const ascending = firstCluster <= lastCluster; - const selectedStart = ascending - ? glyphLowerBound(shape.clusters, glyphStart, glyphCount, textStart) - : glyphBelow(shape.clusters, glyphStart, glyphCount, textEnd); - const selectedEnd = ascending - ? glyphLowerBound(shape.clusters, glyphStart, glyphCount, textEnd) - : glyphBelow(shape.clusters, glyphStart, glyphCount, textStart); - return { start: selectedStart, end: selectedEnd }; -} - -const EMPTY_GLYPH_RANGE = { start: 0, end: 0 } as const; - -function glyphLowerBound(clusters: Uint32Array, glyphStart: number, glyphCount: number, target: number): number { - let low = glyphStart; - let high = glyphStart + glyphCount; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - if ((clusters[middle] ?? 0) < target) low = middle + 1; - else high = middle; - } - return low; -} - -function glyphBelow(clusters: Uint32Array, glyphStart: number, glyphCount: number, target: number): number { - let low = glyphStart; - let high = glyphStart + glyphCount; - while (low < high) { - const middle = low + Math.floor((high - low) / 2); - if ((clusters[middle] ?? 0) >= target) low = middle + 1; - else high = middle; - } - return low; -} - -function spacingBetween(prepared: PreparedParagraph, start: number, end: number): number { - return clusterRangeSum(prepared, prepared.letterSpacingPrefix, start, end); -} - -function clusterRangeSum( - prepared: PreparedParagraph, - prefix: Uint32Array | Float64Array, - start: number, - end: number, -): number { - if (end <= start) return 0; - // An offset past the table addresses no cluster, and answering `0` would invert the prefix difference rather than - // return nothing. Shaped clusters cross the Wasm boundary, so a malformed one must degrade, not silently mis-space. - const index = prepared.clusterIndexAt; - const last = index.length - 1; - const first = index[Math.min(start, last)] ?? 0; - const afterLast = index[Math.min(end, last)] ?? 0; - return (prefix[afterLast] ?? 0) - (prefix[first] ?? 0); -} - -function measurementForGeometry( - constraints: NormalizedConstraints, - measured: ParagraphMeasurement, - geometry: PositionedGeometry, -): ParagraphMeasurement { - const contentWidth = maxArray(geometry.lineAdvances); - const requiredContentWidth = Math.max(contentWidth, measured.contentWidth); - const width = resolveAxis(constraints.width, requiredContentWidth); - const height = measured.height; - return { - width, - height, - contentWidth: requiredContentWidth, - contentHeight: measured.contentHeight, - firstBaseline: geometry.lineBaselines[0] ?? 0, - lastBaseline: geometry.lineBaselines.at(-1) ?? 0, - overflowed: measured.overflowed || requiredContentWidth > width || measured.contentHeight > height, - }; -} - -function maxArray(values: Float32Array): number { - let maximum = 0; - for (const value of values) maximum = Math.max(maximum, value); - return maximum; -} - -function emptyGeometry(): PositionedGeometry { - return { - fontHandles: new Uint32Array(), - glyphFontSlots: new Uint16Array(), - glyphIds: new Uint16Array(), - clusters: new Uint32Array(), - glyphFontSizes: new Float32Array(), - x: new Float32Array(), - y: new Float32Array(), - glyphFlags: new Uint16Array(), - lineTextStarts: new Uint32Array(), - lineTextEnds: new Uint32Array(), - lineGlyphStarts: new Uint32Array(), - lineGlyphCounts: new Uint32Array(), - lineBaselines: new Float32Array(), - lineAdvances: new Float32Array(), - }; -} - -function resolveAxis(constraint: ParagraphAxisConstraint, content: number): number { - if (constraint.mode === 'unconstrained') return content; - if (constraint.mode === 'at-most') return Math.min(content, constraint.size); - return constraint.size; -} - -function requireFont(shaper: RuntimeShaper, handle: FontHandle): RegisteredFont { - const font = shaper.registry.getByHandle(handle); - if (font === undefined) throw new RangeError(`font handle ${handle} is not active in the registry`); - return font; -} - -function drawableFragments( - text: string, - start: number, - end: number, -): readonly { readonly start: number; readonly end: number }[] { - const fragments = []; - let fragmentStart = start; - let offset = start; - while (offset < end) { - const codePoint = text.codePointAt(offset); - if (codePoint === undefined) break; - const length = codePoint > 0xffff ? 2 : 1; - if (isHardBreakCodePoint(codePoint)) { - if (fragmentStart < offset) fragments.push({ start: fragmentStart, end: offset }); - offset += length; - if (codePoint === 0x0d && text.charCodeAt(offset) === 0x0a) offset += 1; - fragmentStart = offset; - } else { - offset += length; - } - } - if (fragmentStart < end) fragments.push({ start: fragmentStart, end }); - return fragments; -} - -function isHardBreak(text: string, offset: number): boolean { - const codePoint = text.codePointAt(offset); - return codePoint !== undefined && isHardBreakCodePoint(codePoint); -} - -function isHardBreakCodePoint(codePoint: number): boolean { - return ( - codePoint === 0x0a || - codePoint === 0x0b || - codePoint === 0x0c || - codePoint === 0x0d || - codePoint === 0x85 || - codePoint === 0x2028 || - codePoint === 0x2029 - ); -} - -function equalStyles(left: ResolvedStyle, right: ResolvedStyle): boolean { - return ( - left.font === right.font && - left.fontSize === right.fontSize && - left.lineHeight === right.lineHeight && - left.letterSpacing === right.letterSpacing && - left.language === right.language && - left.direction === right.direction && - left.bidiOverride === right.bidiOverride && - equalFeatures(left.features, right.features) - ); -} - -function equalFeatures(left: readonly ResolvedFontFeature[], right: readonly ResolvedFontFeature[]): boolean { - return ( - left.length === right.length && - left.every((feature, index) => { - const other = right[index]; - return ( - other !== undefined && - feature.tag === other.tag && - feature.value === other.value && - feature.start === other.start && - feature.end === other.end - ); - }) - ); -} - -/** - * Whether two shape requests would produce identical shaped output. Compares exactly what the shaper reads, so a - * paragraph whose size, line height, or letter spacing changed rebuilds an equal request and reuses its shape. - */ -function sameShapeRequest(left: ShapeBatchRequest, right: ShapeBatchRequest): boolean { - if (left.textUtf16.length !== right.textUtf16.length) return false; - for (let index = 0; index < left.textUtf16.length; index += 1) { - if (left.textUtf16[index] !== right.textUtf16[index]) return false; - } - if (left.runs.length !== right.runs.length) return false; - for (let index = 0; index < left.runs.length; index += 1) { - const a = left.runs[index]!; - const b = right.runs[index]!; - if ( - a.font !== b.font || - a.textStart !== b.textStart || - a.textEnd !== b.textEnd || - a.direction !== b.direction || - a.script !== b.script || - a.language !== b.language || - a.clusterLevel !== b.clusterLevel || - a.flags !== b.flags || - a.featureStart !== b.featureStart || - a.featureCount !== b.featureCount - ) { - return false; - } - } - if (left.features.length !== right.features.length) return false; - for (let index = 0; index < left.features.length; index += 1) { - const a = left.features[index]!; - const b = right.features[index]!; - if (a.tag !== b.tag || a.value !== b.value || a.start !== b.start || a.end !== b.end) return false; - } - return true; -} - -function ownShape(shape: ShapedBatchViews): OwnedShape { - return { - fontHandles: shape.fontHandles.slice(), - runFontSlots: shape.runFontSlots.slice(), - runGlyphStarts: shape.runGlyphStarts.slice(), - runGlyphCounts: shape.runGlyphCounts.slice(), - glyphIds: shape.glyphIds.slice(), - clusters: shape.clusters.slice(), - xAdvances: shape.xAdvances.slice(), - yAdvances: shape.yAdvances.slice(), - xOffsets: shape.xOffsets.slice(), - yOffsets: shape.yOffsets.slice(), - glyphFlags: shape.glyphFlags.slice(), - }; -} - -function ownBidi(bidi: BidiAnalysisViews): OwnedBidiAnalysis { - return { - levels: bidi.levels.slice(), - classes: bidi.classes.slice(), - paragraphStarts: bidi.paragraphStarts.slice(), - paragraphEnds: bidi.paragraphEnds.slice(), - paragraphLevels: bidi.paragraphLevels.slice(), - }; -} - -function utf16(text: string): Uint16Array { - const result = new Uint16Array(text.length); - for (let index = 0; index < text.length; index += 1) result[index] = text.charCodeAt(index); - return result; -} - -function emptyShape(): OwnedShape { - return { - fontHandles: new Uint32Array(), - runFontSlots: new Uint16Array(), - runGlyphStarts: new Uint32Array(), - runGlyphCounts: new Uint32Array(), - glyphIds: new Uint16Array(), - clusters: new Uint32Array(), - xAdvances: new Int32Array(), - yAdvances: new Int32Array(), - xOffsets: new Int32Array(), - yOffsets: new Int32Array(), - glyphFlags: new Uint16Array(), - }; -} - -function assertTextRange(start: number, end: number, textLength: number, name: string): void { - if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start >= end || end > textLength) { - throw new RangeError(`${name} must be a non-empty UTF-16 range inside the paragraph`); - } -} - -function finite(value: number, name: string): number { - if (!Number.isFinite(value)) throw new RangeError(`${name} must be finite`); - return value; -} - -function isNonArrayObject(value: T): value is T & object { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} - -function finitePositive(value: number, name: string): number { - if (!Number.isFinite(value) || value <= 0) throw new RangeError(`${name} must be finite and positive`); - return value; -} - -function finiteNonnegative(value: number, name: string): number { - if (!Number.isFinite(value) || value < 0) throw new RangeError(`${name} must be finite and nonnegative`); - return value; -} - -function normalizeLanguage(language: string | undefined): string | undefined { - if (language === undefined) return undefined; - if (typeof language !== 'string') throw new TypeError('language must be a string'); - const normalized = language.trim().toLowerCase(); - if (normalized.length === 0) throw new RangeError('language must not be empty'); - return normalized; -} diff --git a/packages/text/src/r3f.ts b/packages/text/src/r3f.ts index 42e8b785..a9988f79 100644 --- a/packages/text/src/r3f.ts +++ b/packages/text/src/r3f.ts @@ -16,8 +16,7 @@ import { import type { GlyphPaintInput } from './formatted-text.js'; import type { FontSelection, LoadedFont } from './loaded-font.js'; -import type { ParagraphContentBox } from './paragraph-batch.js'; -import type { ParagraphStyle } from './paragraph.js'; +import type { ParagraphContentBox, ParagraphStyle } from './text-properties.js'; import type { AnyRasterTechnique } from './raster-technique.js'; import type { LoadedFontRequest } from './text-runtime.js'; import { diff --git a/packages/text/src/shaper.ts b/packages/text/src/shaper.ts index 477747b9..28f31e63 100644 --- a/packages/text/src/shaper.ts +++ b/packages/text/src/shaper.ts @@ -1,9 +1,8 @@ -import type { FontMetrics, RegisteredFont } from './font.js'; +import type { RegisteredFont } from './font.js'; import { textShaperAbi } from './generated/text-shaper-abi.js'; -import type { FontHandle, FontKey, Sha256Hex } from './identity.js'; +import type { FontHandle } from './identity.js'; import { getRegisteredFontData } from './internal/registered-font.js'; import { FontRegistry } from './loader.js'; -import type { ResolvedFontFeature } from './font-feature.js'; export type TextShaperWasmSource = BufferSource | WebAssembly.Module; @@ -19,95 +18,14 @@ export interface RuntimeShaperMemoryReport { readonly wasmMemoryBytes: number; } -export interface ShapeRunRequest { - readonly font: FontHandle; - readonly textStart: number; - readonly textEnd: number; - readonly direction: 'ltr' | 'rtl'; - readonly script: string; - readonly language?: string; - readonly clusterLevel: 0 | 1 | 2 | 3; - readonly flags: number; - readonly featureStart: number; - readonly featureCount: number; -} - -export interface ShapeBatchRequest { - readonly textUtf16: Uint16Array; - readonly runs: readonly ShapeRunRequest[]; - readonly features: readonly ResolvedFontFeature[]; -} - -export interface ReshapeRange { - readonly run: number; - readonly itemStart: number; - readonly itemEnd: number; - readonly contextStart: number; - readonly contextEnd: number; - readonly flags: number; -} - -export interface ReshapeBatchRequest extends ShapeBatchRequest { - readonly ranges: readonly ReshapeRange[]; -} - -export interface ShapedBatchViews { - readonly fontHandles: Uint32Array; - readonly runFontSlots: Uint16Array; - readonly runGlyphStarts: Uint32Array; - readonly runGlyphCounts: Uint32Array; - readonly glyphIds: Uint16Array; - readonly clusters: Uint32Array; - readonly xAdvances: Int32Array; - readonly yAdvances: Int32Array; - readonly xOffsets: Int32Array; - readonly yOffsets: Int32Array; - readonly glyphFlags: Uint16Array; -} - -export type BidiDirection = 'auto' | 'ltr' | 'rtl'; - -/** Borrowed direct-memory UAX #9 output, indexed by UTF-16 code unit. */ -export interface BidiAnalysisViews { - readonly levels: Uint8Array; - readonly classes: Uint8Array; - readonly paragraphStarts: Uint32Array; - readonly paragraphEnds: Uint32Array; - readonly paragraphLevels: Uint8Array; -} - export interface RuntimeShaper { readonly registry: FontRegistry; registerFont(font: RegisteredFont): void; disposeFont(font: RegisteredFont): void; - analyzeBidi(textUtf16: Uint16Array, direction?: BidiDirection): BidiAnalysisViews; - shapeBatch(request: ShapeBatchRequest): ShapedBatchViews; - reshapeRanges(request: ReshapeBatchRequest): ShapedBatchViews; memoryReport(): RuntimeShaperMemoryReport; dispose(): void; } -/** @internal Worker-side shaping registration that bypasses renderer and loader ownership. */ -export interface RuntimeShaperFontData { - readonly key: FontKey; - readonly handle: FontHandle; - readonly shapingHash: Sha256Hex; - readonly glyphCount: number; - readonly metrics: FontMetrics; - readonly fontFaceIndex: number; - readonly sourceHash: string; - readonly unicodeVersion: string; - readonly shapingSfnt: Uint8Array; - readonly glyphExtents: Uint8Array; - readonly glyphExtentsAvailability: Uint8Array; -} - -/** @internal */ -export function registerRuntimeShaperFontData(shaper: RuntimeShaper, data: RuntimeShaperFontData): void { - if (!(shaper instanceof RuntimeShaperImpl)) throw new TypeError('runtime shaper was not created by this package'); - shaper._registerFontData(data); -} - /** @internal Shared direct-memory access for the retained text-engine host. */ export function runtimeShaperEngineExports(shaper: RuntimeShaper): ShaperExports { if (!(shaper instanceof RuntimeShaperImpl)) throw new TypeError('runtime shaper was not created by this package'); @@ -115,103 +33,6 @@ export function runtimeShaperEngineExports(shaper: RuntimeShaper): ShaperExports return shaper._engineExports(); } -interface LayoutBase { - readonly size: number; - readonly alignment: number; - readonly [field: string]: number; -} - -interface ShapeRequestLayout extends LayoutBase { - readonly textOffset: number; - readonly textLength: number; - readonly runsOffset: number; - readonly runCount: number; - readonly featuresOffset: number; - readonly featureCount: number; - readonly languagesOffset: number; - readonly languagesLength: number; -} - -interface ReshapeRequestLayout extends LayoutBase { - readonly rangesOffset: number; - readonly rangeCount: number; -} - -interface BidiRequestLayout extends LayoutBase { - readonly textOffset: number; - readonly textLength: number; - readonly direction: number; -} - -interface FeatureLayout extends LayoutBase { - readonly tag: number; - readonly value: number; - readonly start: number; - readonly end: number; -} - -interface RunLayout extends LayoutBase { - readonly fontHandle: number; - readonly textStart: number; - readonly textEnd: number; - readonly script: number; - readonly languageOffset: number; - readonly featureStart: number; - readonly featureCount: number; - readonly direction: number; - readonly clusterLevel: number; - readonly flags: number; -} - -interface ReshapeRangeLayout extends LayoutBase { - readonly run: number; - readonly itemStart: number; - readonly itemEnd: number; - readonly contextStart: number; - readonly contextEnd: number; - readonly flags: number; -} - -interface ResultLayout extends LayoutBase { - readonly byteLength: number; - readonly fontHandlesOffset: number; - readonly fontHandleCount: number; - readonly runFontSlotsOffset: number; - readonly runGlyphStartsOffset: number; - readonly runGlyphCountsOffset: number; - readonly runCount: number; - readonly glyphIdsOffset: number; - readonly clustersOffset: number; - readonly xAdvancesOffset: number; - readonly yAdvancesOffset: number; - readonly xOffsetsOffset: number; - readonly yOffsetsOffset: number; - readonly glyphFlagsOffset: number; - readonly glyphCount: number; -} - -interface BidiResultLayout extends LayoutBase { - readonly byteLength: number; - readonly levelsOffset: number; - readonly classesOffset: number; - readonly textLength: number; - readonly paragraphStartsOffset: number; - readonly paragraphEndsOffset: number; - readonly paragraphLevelsOffset: number; - readonly paragraphCount: number; -} - -interface ShaperAbiLayouts { - readonly shapeRequest: ShapeRequestLayout; - readonly reshapeRequest: ReshapeRequestLayout; - readonly bidiRequest: BidiRequestLayout; - readonly feature: FeatureLayout; - readonly run: RunLayout; - readonly reshapeRange: ReshapeRangeLayout; - readonly result: ResultLayout; - readonly bidiResult: BidiResultLayout; -} - interface ShaperExports { readonly memory: WebAssembly.Memory; readonly allocate: (length: number) => number; @@ -229,11 +50,6 @@ interface ShaperExports { readonly fontCount: () => number; readonly retainedFontBytes: () => number; readonly planCount: () => number; - readonly shapeBatch: (pointer: number, length: number) => number; - readonly reshapeRanges: (pointer: number, length: number) => number; - readonly analyzeBidi: (pointer: number, length: number) => number; - readonly resultPointer: () => number; - readonly resultLength: () => number; readonly registerFontBinding: ( bindingHandle: number, shapingFontHandle: number, @@ -264,13 +80,8 @@ interface ShaperExports { interface ShaperModule { readonly exports: ShaperExports; - readonly layouts: ShaperAbiLayouts; } -const encoder = new TextEncoder(); -const noLanguage = 0xffff_ffff; -const bufferFlagsMask = 0xff; - export async function createRuntimeShaper(options: RuntimeShaperOptions = {}): Promise { const source = options.wasm ?? (await fetchDefaultWasm()); const module = source instanceof WebAssembly.Module ? source : await WebAssembly.compile(source); @@ -282,7 +93,6 @@ export async function createRuntimeShaper(options: RuntimeShaperOptions = {}): P class RuntimeShaperImpl implements RuntimeShaper { readonly registry: FontRegistry; readonly #exports: ShaperExports; - readonly #layouts: ShaperAbiLayouts; readonly #registered = new Map(); readonly #unsubscribe: () => void; #disposed = false; @@ -290,7 +100,6 @@ class RuntimeShaperImpl implements RuntimeShaper { constructor(registry: FontRegistry, module: ShaperModule) { this.registry = registry; this.#exports = module.exports; - this.#layouts = module.layouts; this.#unsubscribe = registry._onFontDispose((font) => this.#disposeHandle(font.handle)); } @@ -301,35 +110,25 @@ class RuntimeShaperImpl implements RuntimeShaper { } if (this.#registered.get(font.handle) === font) return; const data = getRegisteredFontData(font); - this.#registerFontBytes({ - key: font.key, - handle: font.handle, - shapingHash: font.shapingHash, - glyphCount: font.glyphCount, - metrics: font.metrics, - ...data, - }); + this.#registerFontBytes(font.handle, data.shapingSfnt, data.glyphExtents, data.glyphExtentsAvailability); this.#registered.set(font.handle, font); } - /** @internal */ - _registerFontData(data: RuntimeShaperFontData): void { - this.#assertActive(); - if (this.#registered.has(data.handle)) return; - this.#registerFontBytes(data); - this.#registered.set(data.handle, undefined); - } - - #registerFontBytes(data: RuntimeShaperFontData): void { + #registerFontBytes( + handle: FontHandle, + shapingSfnt: Uint8Array, + glyphExtents: Uint8Array, + glyphExtentsAvailability: Uint8Array, + ): void { let sfnt: { readonly pointer: number; readonly length: number } | undefined; let extents: { readonly pointer: number; readonly length: number } | undefined; let availability: { readonly pointer: number; readonly length: number } | undefined; try { - sfnt = copyIntoWasm(this.#exports, data.shapingSfnt); - extents = copyIntoWasm(this.#exports, data.glyphExtents); - availability = copyIntoWasm(this.#exports, data.glyphExtentsAvailability); + sfnt = copyIntoWasm(this.#exports, shapingSfnt); + extents = copyIntoWasm(this.#exports, glyphExtents); + availability = copyIntoWasm(this.#exports, glyphExtentsAvailability); const status = this.#exports.registerFont( - data.handle, + handle, sfnt.pointer, sfnt.length, extents.pointer, @@ -352,31 +151,6 @@ class RuntimeShaperImpl implements RuntimeShaper { if (this.#registered.get(font.handle) === font) this.#disposeHandle(font.handle); } - analyzeBidi(textUtf16: Uint16Array, direction: BidiDirection = 'auto'): BidiAnalysisViews { - this.#assertActive(); - const bytes = packBidiRequest(this.#layouts.bidiRequest, textUtf16, direction); - const allocation = copyIntoWasm(this.#exports, bytes); - try { - const status = this.#exports.analyzeBidi(allocation.pointer, allocation.length); - if (status !== 0) throw shaperStatusError(status, 'analyze bidi text'); - return readBidiResultViews(this.#exports, this.#layouts.bidiResult); - } finally { - this.#exports.deallocate(allocation.pointer, allocation.length); - } - } - - shapeBatch(request: ShapeBatchRequest): ShapedBatchViews { - this.#assertActive(); - this.#assertFontsRegistered(request.runs); - return this.#call(request, undefined); - } - - reshapeRanges(request: ReshapeBatchRequest): ShapedBatchViews { - this.#assertActive(); - this.#assertFontsRegistered(request.runs); - return this.#call(request, request.ranges); - } - memoryReport(): RuntimeShaperMemoryReport { this.#assertActive(); return { @@ -404,31 +178,6 @@ class RuntimeShaperImpl implements RuntimeShaper { this.#assertActive(); } - #call(request: ShapeBatchRequest, ranges: readonly ReshapeRange[] | undefined): ShapedBatchViews { - const bytes = packRequest(this.#layouts, request, ranges); - const allocation = copyIntoWasm(this.#exports, bytes); - try { - const status = - ranges === undefined - ? this.#exports.shapeBatch(allocation.pointer, allocation.length) - : this.#exports.reshapeRanges(allocation.pointer, allocation.length); - if (status !== 0) { - throw shaperStatusError(status, ranges === undefined ? 'shape batch' : 'reshape ranges'); - } - return readResultViews(this.#exports, this.#layouts.result); - } finally { - this.#exports.deallocate(allocation.pointer, allocation.length); - } - } - - #assertFontsRegistered(runs: readonly ShapeRunRequest[]): void { - for (const run of runs) { - if (!this.#registered.has(run.font)) { - throw new TypeError(`font handle ${run.font} is not registered with this shaper`); - } - } - } - #disposeHandle(handle: FontHandle): void { if (!this.#registered.delete(handle)) return; const status = this.#exports.disposeFont(handle); @@ -465,11 +214,6 @@ function readModule(instance: WebAssembly.Instance): ShaperModule { fontCount: exportedFunction(instance, functions.fontCount), retainedFontBytes: exportedFunction(instance, functions.retainedFontBytes), planCount: exportedFunction(instance, functions.planCount), - shapeBatch: exportedFunction(instance, functions.shapeBatch), - reshapeRanges: exportedFunction(instance, functions.reshapeRanges), - analyzeBidi: exportedFunction(instance, functions.analyzeBidi), - resultPointer: exportedFunction(instance, functions.resultPointer), - resultLength: exportedFunction(instance, functions.resultLength), registerFontBinding: exportedFunction(instance, functions.registerFontBinding), registerFontStack: exportedFunction(instance, functions.registerFontStack), disposeFontStack: exportedFunction(instance, functions.disposeFontStack), @@ -482,222 +226,9 @@ function readModule(instance: WebAssembly.Instance): ShaperModule { requestCapacity: exportedFunction(instance, functions.requestCapacity), textUpdate: exportedFunction(instance, functions.textUpdate), }, - layouts: textShaperAbi.layouts, - }; -} - -function packBidiRequest(layout: BidiRequestLayout, textUtf16: Uint16Array, direction: BidiDirection): Uint8Array { - if (!(textUtf16 instanceof Uint16Array)) { - throw new TypeError('bidi textUtf16 must be a Uint16Array'); - } - const directions: Readonly> = { auto: 0, ltr: 1, rtl: 2 }; - const directionCode = directions[direction]; - if (directionCode === undefined) throw new RangeError('bidi direction must be auto, ltr, or rtl'); - uint32(textUtf16.length, 'bidi text UTF-16 length'); - const textOffset = align(layout.size, 2); - const bytes = new Uint8Array(checkedAdd(textOffset, checkedMultiply(textUtf16.length, 2, 'bidi text bytes'))); - const view = new DataView(bytes.buffer); - writeUint32(view, layout.textOffset, textOffset); - writeUint32(view, layout.textLength, textUtf16.length); - view.setUint8(layout.direction, directionCode); - for (let index = 0; index < textUtf16.length; index += 1) { - view.setUint16(textOffset + index * 2, textUtf16[index]!, true); - } - return bytes; -} - -function packRequest( - layouts: ShaperAbiLayouts, - request: ShapeBatchRequest, - ranges: readonly ReshapeRange[] | undefined, -): Uint8Array { - if (!(request.textUtf16 instanceof Uint16Array)) { - throw new TypeError('shape textUtf16 must be a Uint16Array'); - } - if (request.runs.length === 0) throw new RangeError('shape batch must contain a run'); - if (ranges !== undefined && ranges.length === 0) { - throw new RangeError('reshape batch must contain a range'); - } - uint32(request.textUtf16.length, 'text UTF-16 length'); - uint32(request.runs.length, 'run count'); - uint32(request.features.length, 'feature count'); - if (ranges !== undefined) uint32(ranges.length, 'reshape range count'); - - const languages = new Map(); - const languageBytes: number[] = []; - for (const run of request.runs) { - if (run.language === undefined || languages.has(run.language)) continue; - const bytes = encoder.encode(run.language); - if (bytes.length === 0 || bytes.length > 0xffff) { - throw new RangeError('shape language must encode to 1..65535 UTF-8 bytes'); - } - const offset = languageBytes.length; - uint32(offset, 'language offset'); - languages.set(run.language, offset); - languageBytes.push(bytes.length & 0xff, bytes.length >>> 8, ...bytes); - } - - const header = ranges === undefined ? layouts.shapeRequest : layouts.reshapeRequest; - let length = header.size; - const textOffset = align(length, 2); - length = checkedAdd(textOffset, checkedMultiply(request.textUtf16.length, 2, 'text bytes')); - const runsOffset = align(length, 4); - length = checkedAdd(runsOffset, checkedMultiply(request.runs.length, layouts.run.size, 'run bytes')); - const featuresOffset = align(length, 4); - length = checkedAdd(featuresOffset, checkedMultiply(request.features.length, layouts.feature.size, 'feature bytes')); - const languagesOffset = length; - length = checkedAdd(length, languageBytes.length); - const rangesOffset = ranges === undefined ? 0 : align(length, 4); - if (ranges !== undefined) { - length = checkedAdd(rangesOffset, checkedMultiply(ranges.length, layouts.reshapeRange.size, 'reshape range bytes')); - } - const bytes = new Uint8Array(length); - const view = new DataView(bytes.buffer); - writeUint32(view, layouts.shapeRequest.textOffset, textOffset); - writeUint32(view, layouts.shapeRequest.textLength, request.textUtf16.length); - writeUint32(view, layouts.shapeRequest.runsOffset, runsOffset); - writeUint32(view, layouts.shapeRequest.runCount, request.runs.length); - writeUint32(view, layouts.shapeRequest.featuresOffset, featuresOffset); - writeUint32(view, layouts.shapeRequest.featureCount, request.features.length); - writeUint32(view, layouts.shapeRequest.languagesOffset, languagesOffset); - writeUint32(view, layouts.shapeRequest.languagesLength, languageBytes.length); - if (ranges !== undefined) { - writeUint32(view, layouts.reshapeRequest.rangesOffset, rangesOffset); - writeUint32(view, layouts.reshapeRequest.rangeCount, ranges.length); - } - for (let index = 0; index < request.textUtf16.length; index++) { - view.setUint16(textOffset + index * 2, request.textUtf16[index]!, true); - } - request.features.forEach((feature, index) => { - const offset = featuresOffset + index * layouts.feature.size; - writeUint32(view, offset + layouts.feature.tag, tag(feature.tag, 'feature')); - writeUint32(view, offset + layouts.feature.value, uint32(feature.value, 'feature value')); - writeUint32(view, offset + layouts.feature.start, uint32(feature.start, 'feature start')); - writeUint32(view, offset + layouts.feature.end, uint32(feature.end, 'feature end')); - if (feature.start > feature.end || feature.end > request.textUtf16.length) { - throw new RangeError('feature range is outside textUtf16'); - } - }); - request.runs.forEach((run, index) => { - const offset = runsOffset + index * layouts.run.size; - const featureStart = uint32(run.featureStart, 'run feature start'); - const featureCount = uint16(run.featureCount, 'run feature count'); - if (featureStart + featureCount > request.features.length) { - throw new RangeError('run feature range is outside features'); - } - writeUint32(view, offset + layouts.run.fontHandle, uint32(run.font, 'font handle')); - writeUint32(view, offset + layouts.run.textStart, uint32(run.textStart, 'run text start')); - writeUint32(view, offset + layouts.run.textEnd, uint32(run.textEnd, 'run text end')); - if (run.textStart > run.textEnd || run.textEnd > request.textUtf16.length) { - throw new RangeError('run range is outside textUtf16'); - } - if (run.direction !== 'ltr' && run.direction !== 'rtl') { - throw new RangeError('run direction must be ltr or rtl'); - } - if (!Number.isInteger(run.clusterLevel) || run.clusterLevel < 0 || run.clusterLevel > 3) { - throw new RangeError('run cluster level must be an integer from 0 through 3'); - } - writeUint32(view, offset + layouts.run.script, tag(run.script, 'script')); - writeUint32( - view, - offset + layouts.run.languageOffset, - run.language === undefined ? noLanguage : languages.get(run.language)!, - ); - writeUint32(view, offset + layouts.run.featureStart, featureStart); - view.setUint16(offset + layouts.run.featureCount, featureCount, true); - view.setUint8(offset + layouts.run.direction, run.direction === 'ltr' ? 0 : 1); - view.setUint8(offset + layouts.run.clusterLevel, run.clusterLevel); - writeUint32(view, offset + layouts.run.flags, flags(run.flags, 'run flags')); - }); - bytes.set(languageBytes, languagesOffset); - ranges?.forEach((range, index) => { - const offset = rangesOffset + index * layouts.reshapeRange.size; - writeUint32(view, offset + layouts.reshapeRange.run, uint32(range.run, 'range run')); - writeUint32(view, offset + layouts.reshapeRange.itemStart, uint32(range.itemStart, 'range item start')); - writeUint32(view, offset + layouts.reshapeRange.itemEnd, uint32(range.itemEnd, 'range item end')); - writeUint32(view, offset + layouts.reshapeRange.contextStart, uint32(range.contextStart, 'range context start')); - writeUint32(view, offset + layouts.reshapeRange.contextEnd, uint32(range.contextEnd, 'range context end')); - writeUint32(view, offset + layouts.reshapeRange.flags, flags(range.flags, 'range flags')); - const run = request.runs[range.run]; - if ( - run === undefined || - range.contextStart > range.itemStart || - range.itemStart > range.itemEnd || - range.itemEnd > range.contextEnd || - range.contextStart < run.textStart || - range.contextEnd > run.textEnd - ) { - throw new RangeError('reshape range is outside its run context'); - } - }); - return bytes; -} - -function readResultViews(exports: ShaperExports, layout: ResultLayout): ShapedBatchViews { - const pointer = exports.resultPointer(); - const length = exports.resultLength(); - if (length < layout.size) throw new TypeError('text shaper returned a truncated result header'); - const bytes = checkedMemoryView(exports.memory, pointer, length); - const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (readUint32(header, layout.byteLength) !== length) { - throw new TypeError('text shaper result length does not match its header'); - } - const fontHandleCount = readUint32(header, layout.fontHandleCount); - const runCount = readUint32(header, layout.runCount); - const glyphCount = readUint32(header, layout.glyphCount); - return { - fontHandles: resultArray(Uint32Array, bytes, readUint32(header, layout.fontHandlesOffset), fontHandleCount), - runFontSlots: resultArray(Uint16Array, bytes, readUint32(header, layout.runFontSlotsOffset), runCount), - runGlyphStarts: resultArray(Uint32Array, bytes, readUint32(header, layout.runGlyphStartsOffset), runCount), - runGlyphCounts: resultArray(Uint32Array, bytes, readUint32(header, layout.runGlyphCountsOffset), runCount), - glyphIds: resultArray(Uint16Array, bytes, readUint32(header, layout.glyphIdsOffset), glyphCount), - clusters: resultArray(Uint32Array, bytes, readUint32(header, layout.clustersOffset), glyphCount), - xAdvances: resultArray(Int32Array, bytes, readUint32(header, layout.xAdvancesOffset), glyphCount), - yAdvances: resultArray(Int32Array, bytes, readUint32(header, layout.yAdvancesOffset), glyphCount), - xOffsets: resultArray(Int32Array, bytes, readUint32(header, layout.xOffsetsOffset), glyphCount), - yOffsets: resultArray(Int32Array, bytes, readUint32(header, layout.yOffsetsOffset), glyphCount), - glyphFlags: resultArray(Uint16Array, bytes, readUint32(header, layout.glyphFlagsOffset), glyphCount), - }; -} - -function readBidiResultViews(exports: ShaperExports, layout: BidiResultLayout): BidiAnalysisViews { - const pointer = exports.resultPointer(); - const length = exports.resultLength(); - if (length < layout.size) throw new TypeError('text shaper returned a truncated bidi header'); - const bytes = checkedMemoryView(exports.memory, pointer, length); - const header = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); - if (readUint32(header, layout.byteLength) !== length) { - throw new TypeError('text shaper bidi result length does not match its header'); - } - const textLength = readUint32(header, layout.textLength); - const paragraphCount = readUint32(header, layout.paragraphCount); - return { - levels: resultArray(Uint8Array, bytes, readUint32(header, layout.levelsOffset), textLength), - classes: resultArray(Uint8Array, bytes, readUint32(header, layout.classesOffset), textLength), - paragraphStarts: resultArray(Uint32Array, bytes, readUint32(header, layout.paragraphStartsOffset), paragraphCount), - paragraphEnds: resultArray(Uint32Array, bytes, readUint32(header, layout.paragraphEndsOffset), paragraphCount), - paragraphLevels: resultArray(Uint8Array, bytes, readUint32(header, layout.paragraphLevelsOffset), paragraphCount), }; } -type ResultArrayConstructor = { - readonly BYTES_PER_ELEMENT: number; - new (buffer: ArrayBuffer, byteOffset: number, length: number): ArrayType; -}; - -function resultArray( - Constructor: ResultArrayConstructor, - result: Uint8Array, - offset: number, - count: number, -): ArrayType { - const length = checkedMultiply(count, Constructor.BYTES_PER_ELEMENT, 'result array bytes'); - if (offset % Constructor.BYTES_PER_ELEMENT !== 0 || offset + length > result.byteLength) { - throw new TypeError('text shaper returned an invalid result-array range'); - } - return new Constructor(result.buffer, result.byteOffset + offset, count); -} - function exportedFunction(instance: WebAssembly.Instance, name: string): (...args: number[]) => number { const value = instance.exports[name]; if (typeof value !== 'function') throw new TypeError(`text shaper is missing export ${name}`); @@ -724,32 +255,6 @@ function checkedMemoryView(memory: WebAssembly.Memory, pointer: number, length: return new Uint8Array(memory.buffer as ArrayBuffer, pointer, length); } -function tag(value: string, label: string): number { - if (value.length !== 4) throw new RangeError(`${label} tag must contain exactly four bytes`); - let packed = 0; - for (let index = 0; index < 4; index++) { - const byte = value.charCodeAt(index); - if (byte < 0x20 || byte > 0x7e) { - throw new RangeError(`${label} tag must contain printable ASCII bytes`); - } - packed = (packed << 8) | byte; - } - return packed >>> 0; -} - -function flags(value: number, label: string): number { - const packed = uint32(value, label); - if ((packed & ~bufferFlagsMask) !== 0) throw new RangeError(`${label} contains unknown bits`); - return packed; -} - -function uint16(value: number, label: string): number { - if (!Number.isInteger(value) || value < 0 || value > 0xffff) { - throw new RangeError(`${label} must be an unsigned 16-bit integer`); - } - return value; -} - function uint32(value: number, label: string): number { if (!Number.isInteger(value) || value < 0 || value > 0xffff_ffff) { throw new RangeError(`${label} must be an unsigned 32-bit integer`); @@ -757,34 +262,6 @@ function uint32(value: number, label: string): number { return value; } -function align(value: number, alignment: number): number { - return checkedAdd(value, (alignment - (value % alignment)) % alignment); -} - -function checkedMultiply(left: number, right: number, label: string): number { - const value = left * right; - if (!Number.isSafeInteger(value) || value > 0xffff_ffff) { - throw new RangeError(`${label} exceeds the V0 address space`); - } - return value; -} - -function checkedAdd(left: number, right: number): number { - const value = left + right; - if (!Number.isSafeInteger(value) || value > 0xffff_ffff) { - throw new RangeError('shape request exceeds the V0 address space'); - } - return value; -} - -function writeUint32(view: DataView, offset: number, value: number): void { - view.setUint32(offset, value, true); -} - -function readUint32(view: DataView, offset: number): number { - return view.getUint32(offset, true); -} - function shaperStatusError(status: number, action: string): Error { const labels: Record = { 1: 'invalid font handle', diff --git a/packages/text/src/text-preparation-worker.ts b/packages/text/src/text-preparation-worker.ts deleted file mode 100644 index 3ffc127f..00000000 --- a/packages/text/src/text-preparation-worker.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { prepareParagraphLayout } from './paragraph-batch.js'; -import { FontRegistry } from './loader.js'; -import { createRuntimeShaper } from './shaper.js'; -import type { - TextPreparationFailureV1, - TextPreparationRequestV1, - TextPreparationSuccessV1, - TextPreparationWorkerMessageV1, -} from './internal/text-preparation-worker-protocol.js'; - -const scope = globalThis as unknown as DedicatedWorkerGlobalScope; -const cancelled = new Set(); -const registry = new FontRegistry(); -const shaperPromise = createRuntimeShaper({ registry }); - -scope.addEventListener('message', (event: MessageEvent) => { - const message = event.data; - if (message.type === 'pmndrs-text-cancel-v1') { - cancelled.add(message.id); - return; - } - if (message.type === 'pmndrs-text-prepare-v1') void prepare(message); -}); - -async function prepare(request: TextPreparationRequestV1): Promise { - try { - const shaper = await shaperPromise; - for (const font of request.fonts) shaper.registerFont(registry._registerShapingFont(font)); - if (cancelled.delete(request.id)) return; - const layouts: TextPreparationSuccessV1['layouts'][number][] = []; - let stagedGlyphs = 0; - for (let index = 0; index < request.paragraphs.length; index += 1) { - if (cancelled.delete(request.id)) return; - const paragraph = request.paragraphs[index]!; - const layout = prepareParagraphLayout(shaper, paragraph.input); - stagedGlyphs += layout.glyphIds.length; - layouts.push({ batch: paragraph.batch, paragraph: paragraph.paragraph, layout }); - scope.postMessage({ - type: 'pmndrs-text-progress-v1', - id: request.id, - preparedParagraphs: index + 1, - totalParagraphs: request.paragraphs.length, - stagedGlyphs, - }); - } - const response: TextPreparationSuccessV1 = { type: 'pmndrs-text-success-v1', id: request.id, layouts }; - scope.postMessage(response, transferLayouts(layouts)); - } catch (cause) { - const error = cause instanceof Error ? cause : new Error(String(cause)); - const response: TextPreparationFailureV1 = { - type: 'pmndrs-text-failure-v1', - id: request.id, - error: { name: error.name, message: error.message, ...(error.stack === undefined ? {} : { stack: error.stack }) }, - }; - scope.postMessage(response); - } finally { - cancelled.delete(request.id); - } -} - -function transferLayouts(layouts: TextPreparationSuccessV1['layouts']): Transferable[] { - const buffers = new Set(); - for (const { layout } of layouts) { - for (const value of Object.values(layout)) if (ArrayBuffer.isView(value)) buffers.add(value.buffer as ArrayBuffer); - } - return [...buffers]; -} diff --git a/packages/text/src/text-properties.ts b/packages/text/src/text-properties.ts new file mode 100644 index 00000000..ca3561a8 --- /dev/null +++ b/packages/text/src/text-properties.ts @@ -0,0 +1,49 @@ +import type { FontFeature } from './font-feature.js'; +import type { FormattedText, GlyphPaintInput, ParagraphSpan } from './formatted-text.js'; +import type { FontSelection } from './loaded-font.js'; +import type { AnyRasterTechnique } from './raster-technique.js'; + +export interface GlyphBufferCapacity { + readonly size: number; + readonly policy: 'grow' | 'chunk' | 'fixed'; +} + +/** A layout-system-neutral axis constraint. */ +export type ParagraphAxisConstraint = + | { readonly mode: 'unconstrained' } + | { readonly mode: 'at-most'; readonly size: number } + | { readonly mode: 'exact'; readonly size: number }; + +export interface ParagraphContentBox { + readonly width?: ParagraphAxisConstraint; + readonly height?: ParagraphAxisConstraint; + readonly maxLines?: number; + readonly wrap?: 'none' | 'word' | 'character'; + readonly align?: 'start' | 'center' | 'end' | 'justify'; + readonly overflow?: 'visible' | 'clip' | 'ellipsis'; +} + +export interface ParagraphStyle { + readonly fontSize?: number; + readonly lineHeight?: number; + readonly letterSpacing?: number; + readonly language?: string; + readonly direction?: 'auto' | 'ltr' | 'rtl'; + readonly features?: readonly FontFeature[]; +} + +export interface ParagraphBaseProperties { + readonly font: FontSelection; + readonly contentBox?: ParagraphContentBox; + readonly style?: ParagraphStyle; + readonly paint?: GlyphPaintInput; + readonly rasterPixelRatio?: number; + readonly order?: number; +} + +export type ParagraphContentProperties = + | Readonly<{ text: string; spans?: readonly ParagraphSpan[] }> + | Readonly<{ text: FormattedText; spans?: never }>; + +export type ParagraphProperties = ParagraphBaseProperties & + ParagraphContentProperties; diff --git a/packages/text/src/text-runtime.ts b/packages/text/src/text-runtime.ts index 119a1510..45e48ea8 100644 --- a/packages/text/src/text-runtime.ts +++ b/packages/text/src/text-runtime.ts @@ -20,44 +20,10 @@ import type { RuntimeRasterBakerModule, } from './raster.js'; import { createRuntimeShaper, type RuntimeShaper } from './shaper.js'; -import { - createParagraphBatch, - type ParagraphBatch, - type ParagraphBatchController, - type ParagraphBatchOptions, - type ParagraphBatchPreparation, - type ParagraphId, - type PreparedParagraphBatchCandidate, - type PreparedParagraphBatchRevision, - type TextPreparationError, -} from './paragraph-batch.js'; -import { - isTextPreparationWorkerResultV1, - type TextPreparationRequestV1, - type TextPreparationSuccessV1, -} from './internal/text-preparation-worker-protocol.js'; - -export interface TextPreparationWorker { - postMessage(message: unknown, transfer?: readonly Transferable[]): void; - addEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - removeEventListener(type: 'message', listener: (event: MessageEvent) => void): void; - addEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - removeEventListener(type: 'error', listener: (event: ErrorEvent) => void): void; - terminate(): void; -} - -export function createTextPreparationWorker(): TextPreparationWorker { - if (typeof Worker === 'undefined') throw new TypeError('this environment does not provide module Workers'); - return new Worker(new URL('./text-preparation-worker.js', import.meta.url), { type: 'module' }); -} export interface TextRuntimeOptions { readonly registry?: FontRegistry; - readonly shaper?: RuntimeShaper; - readonly async?: Readonly<{ - readonly worker?: TextPreparationWorker; - readonly createWorker?: () => TextPreparationWorker; - }>; + readonly wasm?: BufferSource | WebAssembly.Module; } export type LoadedFontInput = @@ -77,41 +43,8 @@ export interface LoadedFontRequest { readonly raster: TechniqueRasterRequest; } -export interface TextRuntimeRevision { - readonly revision: number; - readonly paragraphBatches: readonly PreparedParagraphBatchRevision[]; -} - -export interface AsyncTextUpdateOptions { - readonly signal?: AbortSignal; - readonly priority?: 'background' | 'normal' | 'urgent'; - readonly onProgress?: (progress: TextUpdateProgress) => void; -} - -export interface TextUpdateProgress { - readonly revision: number; - readonly preparedParagraphs: number; - readonly totalParagraphs: number; - readonly stagedGlyphs: number; -} - -export type TextUpdateOutcome = - | { readonly status: 'published'; readonly value: TextRuntimeRevision } - | { readonly status: 'superseded'; readonly revision: number; readonly byRevision: number } - | { readonly status: 'aborted'; readonly revision: number; readonly reason?: unknown }; - -export type TextUpdateResult = - | { readonly ok: true; readonly value: TextUpdateOutcome } - | { readonly ok: false; readonly error: TextPreparationError }; - -export type TextUpdateCallback = (result: TextUpdateResult) => void; - export interface TextRuntime { readonly registry: FontRegistry; - readonly shaper: RuntimeShaper; - readonly current: TextRuntimeRevision; - readonly hasPendingChanges: boolean; - readonly isPreparing: boolean; readonly disposed: boolean; loadFont( @@ -119,15 +52,6 @@ export interface TextRuntime { options?: { readonly signal?: AbortSignal }, ): Promise>; - createParagraphBatch( - options: ParagraphBatchOptions, - ): ParagraphBatch; - - update(): TextRuntimeRevision; - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - subscribe(listener: (revision: TextRuntimeRevision) => void): () => void; dispose(): void; } @@ -136,94 +60,38 @@ interface PendingTechniqueLoad { readonly promise: Promise>; } -interface PendingTextUpdate { - readonly revision: number; - readonly snapshots: readonly ParagraphBatchPreparation[]; - readonly options: AsyncTextUpdateOptions; - readonly complete: (result: TextUpdateResult) => void; - readonly abort: () => void; - settled: boolean; - preparedParagraphs: number; - stagedGlyphs: number; -} - -type WorkerLayouts = ReadonlyMap>; - -interface PendingWorkerPreparation { - readonly operation: PendingTextUpdate; - readonly resolve: (layouts: WorkerLayouts) => void; - readonly reject: (error: unknown) => void; -} - export async function createTextRuntime(options: TextRuntimeOptions = {}): Promise { - if (options.async?.worker !== undefined && options.async.createWorker !== undefined) { - throw new TypeError('text runtime async options accept worker or createWorker, not both'); - } - const registry = options.registry ?? options.shaper?.registry ?? new FontRegistry(); - if (options.shaper !== undefined && options.shaper.registry !== registry) { - throw new TypeError('text runtime registry and shaper must share one ownership domain'); - } - const shaper = options.shaper ?? (await createRuntimeShaper({ registry })); + const registry = options.registry ?? new FontRegistry(); + const shaper = await createRuntimeShaper({ registry, ...(options.wasm === undefined ? {} : { wasm: options.wasm }) }); try { - return new TextRuntimeImpl(registry, shaper, options.async?.worker, options.async?.createWorker); + return new TextRuntimeImpl(registry, shaper); } catch (error) { - if (options.shaper === undefined) shaper.dispose(); - options.async?.worker?.terminate(); + shaper.dispose(); throw error; } } +/** @internal Rust-engine access for renderer integrations owned by this package. */ +export function textRuntimeShaper(runtime: TextRuntime): RuntimeShaper { + if (!(runtime instanceof TextRuntimeImpl)) throw new TypeError('text runtime was not created by this package'); + return runtime._shaper(); +} + class TextRuntimeImpl implements TextRuntime { readonly registry: FontRegistry; - readonly shaper: RuntimeShaper; + readonly #shaper: RuntimeShaper; readonly #defaultLoader: FontLoader; readonly #sourceLoaders = new Map(); readonly #loaded = new Map>>>(); readonly #pending = new Map>>(); - readonly #listeners = new Set<(revision: TextRuntimeRevision) => void>(); - readonly #paragraphBatches = new Set(); - readonly #pendingUpdates = new Map(); - readonly #pendingWorkerPreparations = new Map(); - readonly #workerFontHandles = new Set(); - readonly #createWorker: (() => TextPreparationWorker) | undefined; - #worker: TextPreparationWorker | undefined; - #workerMessageListener: ((event: MessageEvent) => void) | undefined; - #workerErrorListener: ((event: ErrorEvent) => void) | undefined; - #current: TextRuntimeRevision = Object.freeze({ revision: 0, paragraphBatches: Object.freeze([]) }); - #updateRequestRevision = 0; #disposed = false; - constructor( - registry: FontRegistry, - shaper: RuntimeShaper, - worker: TextPreparationWorker | undefined, - createWorker: (() => TextPreparationWorker) | undefined, - ) { + constructor(registry: FontRegistry, shaper: RuntimeShaper) { this.registry = registry; - this.shaper = shaper; - this.#createWorker = createWorker; - this.#worker = worker; - if (worker !== undefined) this.#listenToWorker(worker); + this.#shaper = shaper; this.#defaultLoader = new FontLoader({ registry }); } - get runtime(): TextRuntime { - return this; - } - - get current(): TextRuntimeRevision { - return this.#current; - } - - get hasPendingChanges(): boolean { - for (const batch of this.#paragraphBatches) if (batch.dirty) return true; - return false; - } - - get isPreparing(): boolean { - return this.#pendingUpdates.size !== 0; - } - get disposed(): boolean { return this.#disposed; } @@ -237,7 +105,7 @@ class TextRuntimeImpl implements TextRuntime { const font = await this.#loadRegisteredFont(request.input, options.signal); this.#assertActive(); options.signal?.throwIfAborted(); - this.shaper.registerFont(font); + this.#shaper.registerFont(font); const descriptor = techniqueOperations(request.raster.technique).descriptor( request.raster.options as RasterOptionsArgument>, ); @@ -269,372 +137,22 @@ class TextRuntimeImpl implements TextRuntime { return consumePending(promise, options.signal); } - createParagraphBatch( - options: ParagraphBatchOptions, - ): ParagraphBatch { - this.#assertActive(); - const controller = createParagraphBatch(this, options); - this.#paragraphBatches.add(controller); - return controller.publicBatch as ParagraphBatch; - } - - dirty(): void { - this.#assertActive(); - } - - remove(batch: ParagraphBatchController): void { - this.#paragraphBatches.delete(batch); - } - - update(): TextRuntimeRevision { - this.#assertActive(); - const requestRevision = ++this.#updateRequestRevision; - this.#supersedePending(requestRevision); - const dirty = [...this.#paragraphBatches].filter((batch) => batch.dirty); - if (dirty.length === 0) return this.#current; - const snapshots = this.#capture(dirty); - const candidates: PreparedParagraphBatchCandidate[] = []; - try { - for (const snapshot of snapshots) candidates.push(snapshot.controller.prepare(snapshot)); - return this.#publish(candidates); - } catch (error) { - this.#discard(candidates); - throw error; - } finally { - this.#release(snapshots); - } - } - - updateAsync(): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions): Promise; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - updateAsync( - optionsOrCallback: AsyncTextUpdateOptions | TextUpdateCallback = {}, - callback?: TextUpdateCallback, - ): Promise | void { - this.#assertActive(); - const options = typeof optionsOrCallback === 'function' ? {} : optionsOrCallback; - const listener = typeof optionsOrCallback === 'function' ? optionsOrCallback : callback; - if (listener !== undefined) { - this.#startAsyncUpdate(options, listener); - return; - } - return new Promise((resolve, reject) => { - this.#startAsyncUpdate(options, (result) => { - if (result.ok) resolve(result.value); - else reject(result.error); - }); - }); - } - - #startAsyncUpdate(options: AsyncTextUpdateOptions, complete: (result: TextUpdateResult) => void): void { - if ( - options.priority !== undefined && - options.priority !== 'background' && - options.priority !== 'normal' && - options.priority !== 'urgent' - ) - throw new TypeError('text update priority is invalid'); - const revision = ++this.#updateRequestRevision; - this.#supersedePending(revision); - if (options.signal?.aborted === true) { - queueMicrotask(() => - complete({ ok: true, value: { status: 'aborted', revision, reason: options.signal?.reason } }), - ); - return; - } - let snapshots: readonly ParagraphBatchPreparation[]; - try { - snapshots = this.#capture([...this.#paragraphBatches].filter((batch) => batch.dirty)); - } catch (cause) { - const error = preparationError(cause); - queueMicrotask(() => complete({ ok: false, error })); - return; - } - let operation!: PendingTextUpdate; - const abort = (): void => { - this.#settle(operation, { - ok: true, - value: { status: 'aborted', revision, reason: options.signal?.reason }, - }); - }; - operation = { - revision, - snapshots, - options, - complete, - abort, - settled: false, - preparedParagraphs: 0, - stagedGlyphs: 0, - }; - this.#pendingUpdates.set(revision, operation); - options.signal?.addEventListener('abort', abort, { once: true }); - queueMicrotask(() => void this.#runAsyncUpdate(operation)); - } - - async #runAsyncUpdate(operation: PendingTextUpdate): Promise { - if (operation.settled) return; - const totalParagraphs = operation.snapshots.reduce((total, snapshot) => total + snapshot.paragraphs.length, 0); - let preparedParagraphs = 0; - let stagedGlyphs = 0; - const candidates: PreparedParagraphBatchCandidate[] = []; - try { - this.#reportProgress(operation, preparedParagraphs, totalParagraphs, stagedGlyphs); - if (operation.settled) return; - const layoutsFromWorker = await this.#prepareLayoutsInWorker(operation); - if (operation.settled) return; - for (let index = 0; index < operation.snapshots.length; index += 1) { - const snapshot = operation.snapshots[index]!; - const candidate = snapshot.controller.prepare(snapshot, layoutsFromWorker?.get(index)); - candidates.push(candidate); - preparedParagraphs += snapshot.paragraphs.length; - stagedGlyphs += candidate.revision.glyphRuns.reduce((total, run) => total + run.count, 0); - this.#reportProgress(operation, preparedParagraphs, totalParagraphs, stagedGlyphs); - if (operation.settled) { - this.#discard(candidates); - return; - } - } - if (operation.revision !== this.#updateRequestRevision) { - this.#discard(candidates); - this.#settle(operation, { - ok: true, - value: { - status: 'superseded', - revision: operation.revision, - byRevision: this.#updateRequestRevision, - }, - }); - return; - } - const value = candidates.length === 0 ? this.#current : this.#publish(candidates); - this.#settle(operation, { ok: true, value: { status: 'published', value } }); - } catch (cause) { - this.#discard(candidates); - if (!operation.settled) this.#settle(operation, { ok: false, error: preparationError(cause) }); - } - } - - #prepareLayoutsInWorker(operation: PendingTextUpdate): Promise { - const worker = this.#ensureWorker(); - if (worker === undefined) return Promise.resolve(undefined); - const paragraphs: TextPreparationRequestV1['paragraphs'][number][] = []; - for (let batch = 0; batch < operation.snapshots.length; batch += 1) { - for (const layout of operation.snapshots[batch]!.layouts) { - if (layout.input !== undefined) paragraphs.push({ batch, paragraph: layout.paragraph, input: layout.input }); - } - } - if (paragraphs.length === 0) return Promise.resolve(new Map()); - const fonts: TextPreparationRequestV1['fonts'][number][] = []; - for (const snapshot of operation.snapshots) { - for (const loaded of snapshot.leases) { - const handle = loaded.font.handle; - if (this.#workerFontHandles.has(handle)) continue; - this.#workerFontHandles.add(handle); - const data = getRegisteredFontData(loaded.font); - fonts.push({ - key: loaded.font.key, - handle, - shapingHash: loaded.font.shapingHash, - glyphCount: loaded.font.glyphCount, - metrics: loaded.font.metrics, - fontFaceIndex: data.fontFaceIndex, - sourceHash: data.sourceHash, - unicodeVersion: data.unicodeVersion, - shapingSfnt: data.shapingSfnt, - glyphExtents: data.glyphExtents, - glyphExtentsAvailability: data.glyphExtentsAvailability, - }); - } - } - return new Promise((resolve, reject) => { - this.#pendingWorkerPreparations.set(operation.revision, { operation, resolve, reject }); - const request: TextPreparationRequestV1 = { - type: 'pmndrs-text-prepare-v1', - id: operation.revision, - fonts, - paragraphs, - }; - try { - worker.postMessage(request); - } catch (error) { - this.#pendingWorkerPreparations.delete(operation.revision); - for (const font of fonts) this.#workerFontHandles.delete(font.handle); - reject(error); - } - }); - } - - #receiveWorker(value: unknown): void { - if (!isTextPreparationWorkerResultV1(value)) { - this.#failWorker(new TypeError('text preparation Worker returned an invalid message')); - return; - } - const pending = this.#pendingWorkerPreparations.get(value.id); - if (pending === undefined) return; - if (value.type === 'pmndrs-text-progress-v1') { - try { - this.#reportProgress( - pending.operation, - value.preparedParagraphs, - pending.operation.snapshots.reduce((total, snapshot) => total + snapshot.paragraphs.length, 0), - value.stagedGlyphs, - ); - } catch (error) { - this.#pendingWorkerPreparations.delete(value.id); - pending.reject(error); - } - return; - } - this.#pendingWorkerPreparations.delete(value.id); - if (value.type === 'pmndrs-text-failure-v1') { - const error = new Error(value.error.message); - error.name = value.error.name; - if (value.error.stack !== undefined) error.stack = value.error.stack; - pending.reject(error); - return; - } - try { - pending.resolve(workerLayouts(value, pending.operation.snapshots)); - } catch (error) { - pending.reject(error); - } - } - - #failWorker(error: unknown): void { - const worker = this.#worker; - if (this.#workerMessageListener !== undefined) worker?.removeEventListener('message', this.#workerMessageListener); - if (this.#workerErrorListener !== undefined) worker?.removeEventListener('error', this.#workerErrorListener); - worker?.terminate(); - this.#worker = undefined; - this.#workerMessageListener = undefined; - this.#workerErrorListener = undefined; - this.#workerFontHandles.clear(); - for (const pending of this.#pendingWorkerPreparations.values()) pending.reject(error); - this.#pendingWorkerPreparations.clear(); - } - - #reportProgress( - operation: PendingTextUpdate, - preparedParagraphs: number, - totalParagraphs: number, - stagedGlyphs: number, - ): void { - operation.preparedParagraphs = Math.max(operation.preparedParagraphs, preparedParagraphs); - operation.stagedGlyphs = Math.max(operation.stagedGlyphs, stagedGlyphs); - operation.options.onProgress?.({ - revision: operation.revision, - preparedParagraphs: operation.preparedParagraphs, - totalParagraphs, - stagedGlyphs: operation.stagedGlyphs, - }); - } - - #ensureWorker(): TextPreparationWorker | undefined { - if (this.#worker !== undefined || this.#createWorker === undefined) return this.#worker; - const worker = this.#createWorker(); - this.#worker = worker; - this.#listenToWorker(worker); - return worker; - } - - #listenToWorker(worker: TextPreparationWorker): void { - this.#workerMessageListener = (event) => this.#receiveWorker(event.data); - this.#workerErrorListener = (event) => - this.#failWorker(event.error ?? new Error(event.message || 'text preparation Worker failed')); - worker.addEventListener('message', this.#workerMessageListener); - worker.addEventListener('error', this.#workerErrorListener); - } - - #cancelWorkerPreparation(operation: PendingTextUpdate): void { - const pending = this.#pendingWorkerPreparations.get(operation.revision); - if (pending === undefined) return; - this.#pendingWorkerPreparations.delete(operation.revision); - this.#worker?.postMessage({ type: 'pmndrs-text-cancel-v1', id: operation.revision }); - pending.reject(new DOMException('Text preparation was cancelled', 'AbortError')); - } - - #capture(batches: readonly ParagraphBatchController[]): readonly ParagraphBatchPreparation[] { - const snapshots: ParagraphBatchPreparation[] = []; - try { - for (const batch of batches) snapshots.push(batch.capture()); - return snapshots; - } catch (error) { - this.#release(snapshots); - throw error; - } - } - - #release(snapshots: readonly ParagraphBatchPreparation[]): void { - for (const snapshot of snapshots) snapshot.controller.release(snapshot); - } - - #discard(candidates: readonly PreparedParagraphBatchCandidate[]): void { - for (const candidate of candidates) candidate.snapshot.controller.discard(candidate); - } - - #publish(candidates: readonly PreparedParagraphBatchCandidate[]): TextRuntimeRevision { - for (const candidate of candidates) candidate.snapshot.controller.publish(candidate); - this.#current = Object.freeze({ - revision: this.#current.revision + 1, - paragraphBatches: Object.freeze([...this.#paragraphBatches].map((batch) => batch.publicBatch.current)), - }); - for (const listener of this.#listeners) listener(this.#current); - return this.#current; - } - - #supersedePending(byRevision: number): void { - for (const operation of [...this.#pendingUpdates.values()]) - this.#settle(operation, { - ok: true, - value: { status: 'superseded', revision: operation.revision, byRevision }, - }); - } - - #settle(operation: PendingTextUpdate, result: TextUpdateResult): void { - if (operation.settled) return; - operation.settled = true; - this.#pendingUpdates.delete(operation.revision); - operation.options.signal?.removeEventListener('abort', operation.abort); - this.#cancelWorkerPreparation(operation); - this.#release(operation.snapshots); - operation.complete(result); - } - - subscribe(listener: (revision: TextRuntimeRevision) => void): () => void { - this.#assertActive(); - this.#listeners.add(listener); - return () => this.#listeners.delete(listener); - } - dispose(): void { if (this.#disposed) return; this.#disposed = true; - this.#updateRequestRevision += 1; - for (const operation of [...this.#pendingUpdates.values()]) - this.#settle(operation, { - ok: true, - value: { status: 'aborted', revision: operation.revision, reason: new Error('text runtime was disposed') }, - }); for (const techniques of this.#pending.values()) { for (const loads of techniques.values()) { for (const pending of loads.values()) pending.controller.abort(); } } this.#pending.clear(); - for (const batch of [...this.#paragraphBatches]) batch.publicBatch.dispose(); - this.#paragraphBatches.clear(); for (const techniques of [...this.#loaded.values()]) { for (const fonts of [...techniques.values()]) { for (const font of [...fonts.values()]) disposeLoadedFontFromRuntime(font); } } this.#loaded.clear(); - this.#failWorker(new Error('text runtime was disposed')); - this.shaper.dispose(); - this.#listeners.clear(); + this.#shaper.dispose(); } async #loadRegisteredFont(input: LoadedFontInput, signal: AbortSignal | undefined): Promise { @@ -794,42 +312,12 @@ class TextRuntimeImpl implements TextRuntime { #assertActive(): void { if (this.#disposed) throw new Error('text runtime has been disposed'); } -} -function preparationError(cause: unknown): TextPreparationError { - if ( - typeof cause === 'object' && - cause !== null && - 'kind' in cause && - (cause.kind === 'capacity-exceeded' || cause.kind === 'preparation-failed') - ) - return cause as TextPreparationError; - return Object.freeze({ kind: 'preparation-failed', cause }); -} - -function workerLayouts( - value: TextPreparationSuccessV1, - snapshots: readonly ParagraphBatchPreparation[], -): WorkerLayouts { - const expected = new Set(); - for (let batch = 0; batch < snapshots.length; batch += 1) - for (const layout of snapshots[batch]!.layouts) - if (layout.input !== undefined) expected.add(`${String(batch)}:${String(layout.paragraph)}`); - const batches = new Map>(); - for (const entry of value.layouts) { - const key = `${String(entry.batch)}:${String(entry.paragraph)}`; - if (!expected.delete(key)) throw new TypeError('text preparation Worker returned an unexpected paragraph'); - let paragraphs = batches.get(entry.batch); - if (paragraphs === undefined) { - paragraphs = new Map(); - batches.set(entry.batch, paragraphs); - } - const paragraph = entry.paragraph as ParagraphId; - if (paragraphs.has(paragraph)) throw new TypeError('text preparation Worker returned a duplicate paragraph'); - paragraphs.set(paragraph, entry.layout); + /** @internal */ + _shaper(): RuntimeShaper { + this.#assertActive(); + return this.#shaper; } - if (expected.size !== 0) throw new TypeError('text preparation Worker omitted a paragraph'); - return batches; } async function decodeTechnique( diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 87e6f6b5..397404ef 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -9,8 +9,7 @@ export type { UnboundSpanTag, } from './formatted-text.js'; export type { FontSelection, FontStack, LoadedFont } from './loaded-font.js'; -export type { GlyphBufferCapacity, ParagraphContentBox } from './paragraph-batch.js'; -export type { ParagraphStyle } from './paragraph.js'; +export type { GlyphBufferCapacity, ParagraphContentBox, ParagraphStyle } from './text-properties.js'; export { bitmapShader } from './three/bitmap-shader.js'; export type { ThreeBitmapInstanceNodes, diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts index ba23ad96..33cabbe4 100644 --- a/packages/text/src/three/engine-runtime.ts +++ b/packages/text/src/three/engine-runtime.ts @@ -3,7 +3,7 @@ import { bitmap, type BitmapData, type BitmapPageData } from '../raster/bitmap-t import { msdf, type MsdfData } from '../raster/msdf.js'; import { slug, type SlugData, type SlugPageData } from '../raster/slug-technique.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; -import type { TextRuntime } from '../text-runtime.js'; +import { textRuntimeShaper, type TextRuntime } from '../text-runtime.js'; import { firstPartyFontBindingBytes } from '../internal/font-binding-wire.js'; import { firstPartyThreeRenderPolicyBytes, type ThreeTransformMode } from '../internal/render-policy-wire.js'; import { TextEngineHost, type TextEngineSession, type TextEngineSessionOptions } from '../internal/text-engine-host.js'; @@ -61,8 +61,11 @@ export class ThreeTextEngineCoordinator { #nextMaterialHandle = 1; #disposed = false; - constructor(runtime: Pick, options: ThreeTextEngineCoordinatorOptions = {}) { - this.host = new TextEngineHost(runtime.shaper); + constructor( + shaper: ConstructorParameters[0], + options: ThreeTextEngineCoordinatorOptions = {}, + ) { + this.host = new TextEngineHost(shaper); const planPrograms = compiledThreeRasterPlanPrograms(this.host.wireIdentities); this.#planPrograms = new Map(planPrograms.map((program) => [program.technique.id, program])); this.host.registerPolicy( @@ -232,7 +235,7 @@ export class ThreeTextEngineCoordinator { export function threeTextEngineCoordinator(runtime: TextRuntime): ThreeTextEngineCoordinator { let coordinator = coordinators.get(runtime); if (coordinator === undefined) { - coordinator = new ThreeTextEngineCoordinator(runtime); + coordinator = new ThreeTextEngineCoordinator(textRuntimeShaper(runtime)); coordinators.set(runtime, coordinator); } return coordinator; diff --git a/packages/text/src/three/font-loader.ts b/packages/text/src/three/font-loader.ts index 9fcc1c4f..3fb6d043 100644 --- a/packages/text/src/three/font-loader.ts +++ b/packages/text/src/three/font-loader.ts @@ -3,17 +3,11 @@ import * as THREE from 'three/webgpu'; import type { LoadedFont } from '../loaded-font.js'; import { observeLoadedFontDispose } from '../loaded-font.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; -import { - createTextRuntime, - type LoadedFontRequest, - type TextPreparationWorker, - type TextRuntime, -} from '../text-runtime.js'; +import { createTextRuntime, type LoadedFontRequest, type TextRuntime } from '../text-runtime.js'; import type { FontRegistry, RuntimeFontBake } from '../loader.js'; export interface ThreeFontLoaderOptions { readonly runtimeBake?: RuntimeFontBake; - readonly createWorker?: () => TextPreparationWorker; /** * Registers loaded fonts in a registry the application already owns. Without one the runtime creates its own, and a * caller holding registry-scoped state cannot reach the fonts this loader produces. @@ -114,7 +108,6 @@ export class FontLoader extends THREE.Loader, Loa domain = { manager: this.manager, runtime: createTextRuntime({ - ...(this.#options.createWorker === undefined ? {} : { async: { createWorker: this.#options.createWorker } }), ...(this.#options.registry === undefined ? {} : { registry: this.#options.registry }), }), fonts: new Set(), diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index e7095a77..a88a0ce0 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -15,7 +15,7 @@ import type { ParagraphContentBox, ParagraphProperties, ParagraphStyle, -} from '../index.js'; +} from '../text-properties.js'; import type { AnyRasterTechnique } from '../raster-technique.js'; import type { TextRuntime } from '../text-runtime.js'; import { @@ -51,13 +51,10 @@ const STYLE_CHANGE = 1 << 1; const GEOMETRY_CHANGE = 1 << 2; const ALL_SEMANTIC_CHANGES = TEXT_CHANGE | STYLE_CHANGE | GEOMETRY_CHANGE; -export type TextSpan = Omit, 'renderVariant'> & +export type TextSpan = ParagraphSpan & Readonly<{ material?: ThreeTextMaterial }>; -type TextBaseProperties = Omit< - ParagraphBaseProperties, - 'renderVariant' ->; +type TextBaseProperties = ParagraphBaseProperties; type TextContentProperties = | Readonly<{ text: string; spans?: readonly TextSpan[] }> diff --git a/packages/text/src/typegpu.ts b/packages/text/src/typegpu.ts deleted file mode 100644 index 6f2ae7d2..00000000 --- a/packages/text/src/typegpu.ts +++ /dev/null @@ -1,643 +0,0 @@ -import type { TgpuRoot } from 'typegpu'; - -import type { FormattedText, GlyphPaintInput, ParagraphSpan, TextInput } from './formatted-text.js'; -import type { ParagraphLayout } from './layout.js'; -import type { - GlyphBufferCapacity, - GlyphOriginUpdate, - GlyphSnapshot, - Paragraph, - ParagraphBatch, - ParagraphContentBox, - ParagraphId, - ParagraphProperties, - ParagraphSnapshot, - ParagraphUpdate, - TextPreparationError, -} from './paragraph-batch.js'; -import type { ParagraphStyle } from './paragraph.js'; -import type { - ParagraphBatchAttachment, - ParagraphBatchTarget, - ParagraphBatchTargetError, - ParagraphBatchTargetRevision, -} from './paragraph-batch-attachment.js'; -import type { AnyRasterTechnique } from './raster-technique.js'; -import { - createTextRuntime, - type AsyncTextUpdateOptions, - type LoadedFontRequest, - type TextRuntime, - type TextRuntimeOptions, - type TextRuntimeRevision, - type TextUpdateCallback, - type TextUpdateOutcome, -} from './text-runtime.js'; -import type { FontSelection, LoadedFont } from './loaded-font.js'; - -export interface TypeGpuFrame { - readonly viewProjection: Float32Array; - readonly viewport: readonly [width: number, height: number]; - readonly pixelRatio: number; -} - -export interface TypeGpuParagraphState { - readonly transform: Float32Array; - readonly visible: boolean; -} - -export interface TypeGpuParagraphBatchTargetRevision extends ParagraphBatchTargetRevision { - readonly draws: readonly Draw[]; -} - -/** - * Program-owned rendering target. Core supplies revisions; the program owns the exact TypeGPU - * buffers, bind groups, pipelines, resources, partial writes, and draw encoding they require. - */ -export interface TypeGpuParagraphBatchTarget< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision = TypeGpuParagraphBatchTargetRevision, -> extends ParagraphBatchTarget { - readonly root: TgpuRoot; - setParagraphState(paragraph: ParagraphId, state: TypeGpuParagraphState | undefined): void; - encode(pass: GPURenderPassEncoder, revision: Revision, frame: TypeGpuFrame): void; -} - -export interface TypeGpuTargetOptions { - readonly root: TgpuRoot; - readonly technique: Technique; - readonly colorFormat: GPUTextureFormat; - readonly depthStencil?: GPUDepthStencilState; - readonly sampleCount: number; -} - -declare const typeGpuRasterProgramTypes: unique symbol; - -interface TypeGpuRasterProgramTypeMap { - readonly variant: Variant; - readonly draw: Draw; - readonly revision: Revision; -} - -export interface AnyTypeGpuRasterProgram { - readonly technique: Technique; - readonly [typeGpuRasterProgramTypes]?: TypeGpuRasterProgramTypeMap< - unknown, - unknown, - TypeGpuParagraphBatchTargetRevision - >; - dispose(): void; -} - -export interface TypeGpuRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, -> extends AnyTypeGpuRasterProgram { - readonly [typeGpuRasterProgramTypes]?: TypeGpuRasterProgramTypeMap; - createTarget( - options: TypeGpuTargetOptions, - ): TypeGpuParagraphBatchTarget; -} - -export type TypeGpuProgramTypesOf> = NonNullable< - Program[typeof typeGpuRasterProgramTypes] ->; -export type TypeGpuVariantOf> = - TypeGpuProgramTypesOf['variant']; -export type TypeGpuDrawOf> = - TypeGpuProgramTypesOf['draw']; -export type TypeGpuRevisionOf> = - TypeGpuProgramTypesOf['revision']; - -export function defineTypeGpuRasterProgram< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, ->( - program: TypeGpuRasterProgram, -): TypeGpuRasterProgram { - return program; -} - -export interface TypeGpuRasterShader { - readonly technique: Technique; - readonly vertex: Vertex; - readonly fragment: Fragment; - readonly resources: Resources; -} - -export function defineTypeGpuRasterShader< - Technique extends AnyRasterTechnique, - Vertex, - Fragment, - Resources, - const Shader extends TypeGpuRasterShader, ->(shader: Shader): Shader { - return shader; -} - -export interface TypeGpuTextEngineOptions { - readonly root: TgpuRoot; - readonly colorFormat: GPUTextureFormat; - readonly depthStencil?: GPUDepthStencilState; - readonly sampleCount?: number; - readonly runtime?: TextRuntimeOptions; -} - -export interface TypeGpuParagraphBatchOptions< - Technique extends AnyRasterTechnique, - Program extends AnyTypeGpuRasterProgram, -> { - readonly technique: Technique; - readonly program: Program; - readonly capacity?: GlyphBufferCapacity; - readonly rasterPixelRatio?: number; - readonly renderVariant?: TypeGpuVariantOf; -} - -export type TypeGpuParagraphProperties = ParagraphProperties< - Technique, - Variant -> & - Readonly<{ transform?: ArrayLike; visible?: boolean }>; - -export type TypeGpuParagraphUpdate = ParagraphUpdate< - Technique, - Variant -> & - Readonly<{ transform?: ArrayLike; visible?: boolean }>; - -export interface TypeGpuParagraphSnapshot extends ParagraphSnapshot< - Technique, - Variant -> { - readonly transform: Float32Array; - readonly visible: boolean; -} - -export interface TypeGpuParagraph { - readonly id: ParagraphId; - readonly disposed: boolean; - readonly layout: ParagraphLayout | undefined; - font: FontSelection; - text: TextInput; - spans: readonly ParagraphSpan[]; - contentBox: ParagraphContentBox; - style: ParagraphStyle; - paint: GlyphPaintInput; - rasterPixelRatio: number; - order: number; - renderVariant: Variant | undefined; - visible: boolean; - set(properties: TypeGpuParagraphUpdate): void; - setSpan(index: number, span: ParagraphSpan): void; - removeSpan(index: number): void; - setTransform(columnMajorMatrix4: ArrayLike): void; - snapshotGlyphs(): GlyphSnapshot; - setGlyphOrigins(update: GlyphOriginUpdate): void; - clearGlyphOriginOverrides(): void; - snapshotProperties(): TypeGpuParagraphSnapshot; - dispose(): void; -} - -export interface TypeGpuParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - Program extends AnyTypeGpuRasterProgram, -> { - readonly technique: Technique; - readonly program: Program; - readonly current: TypeGpuRevisionOf | undefined; - readonly error: TextPreparationError | ParagraphBatchTargetError | undefined; - readonly disposed: boolean; - rasterPixelRatio: number; - renderVariant: Variant | undefined; - add(properties: TypeGpuParagraphProperties): TypeGpuParagraph; - has(paragraph: TypeGpuParagraph): boolean; - setCapacity(capacity: GlyphBufferCapacity): void; - retry(): void; - encode(pass: GPURenderPassEncoder, frame: TypeGpuFrame): void; - dispose(): void; -} - -export interface TypeGpuTextEngine { - readonly root: TgpuRoot; - readonly current: TextRuntimeRevision; - readonly disposed: boolean; - loadFont( - request: LoadedFontRequest, - options?: { readonly signal?: AbortSignal }, - ): Promise>; - createParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, - Program extends TypeGpuRasterProgram, - >( - options: TypeGpuParagraphBatchOptions, - ): TypeGpuParagraphBatch, Program>; - update(): TextRuntimeRevision; - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - dispose(): void; -} - -export async function createTypeGpuTextEngine(options: TypeGpuTextEngineOptions): Promise { - const sampleCount = options.sampleCount ?? 1; - if (!Number.isSafeInteger(sampleCount) || sampleCount < 1) - throw new RangeError('sampleCount must be a positive integer'); - const runtime = await createTextRuntime(options.runtime); - return new TypeGpuTextEngineImpl(runtime, options.root, options.colorFormat, options.depthStencil, sampleCount); -} - -class TypeGpuTextEngineImpl implements TypeGpuTextEngine { - readonly root: TgpuRoot; - readonly #runtime: TextRuntime; - readonly #colorFormat: GPUTextureFormat; - readonly #depthStencil: GPUDepthStencilState | undefined; - readonly #sampleCount: number; - readonly #batches = new Set< - TypeGpuParagraphBatchImpl> - >(); - #disposed = false; - - constructor( - runtime: TextRuntime, - root: TgpuRoot, - colorFormat: GPUTextureFormat, - depthStencil: GPUDepthStencilState | undefined, - sampleCount: number, - ) { - this.#runtime = runtime; - this.root = root; - this.#colorFormat = colorFormat; - this.#depthStencil = depthStencil; - this.#sampleCount = sampleCount; - } - - get current(): TextRuntimeRevision { - return this.#runtime.current; - } - get disposed(): boolean { - return this.#disposed; - } - loadFont( - request: LoadedFontRequest, - options?: { readonly signal?: AbortSignal }, - ): Promise> { - this.#assertActive(); - return this.#runtime.loadFont(request, options); - } - createParagraphBatch< - Technique extends AnyRasterTechnique, - Variant, - Draw, - Revision extends TypeGpuParagraphBatchTargetRevision, - Program extends TypeGpuRasterProgram, - >( - options: TypeGpuParagraphBatchOptions, - ): TypeGpuParagraphBatch, Program> { - this.#assertActive(); - if (options.program.technique !== options.technique) - throw new TypeError('TypeGPU program uses another raster technique'); - const batch = new TypeGpuParagraphBatchImpl, Program>( - this.#runtime, - this.root, - this.#colorFormat, - this.#depthStencil, - this.#sampleCount, - options, - (value) => this.#batches.delete(value), - ); - this.#batches.add( - batch as TypeGpuParagraphBatchImpl>, - ); - return batch; - } - update(): TextRuntimeRevision { - this.#assertActive(); - return this.#runtime.update(); - } - updateAsync(options?: AsyncTextUpdateOptions): Promise; - updateAsync(callback: TextUpdateCallback): void; - updateAsync(options: AsyncTextUpdateOptions, callback: TextUpdateCallback): void; - updateAsync( - options?: AsyncTextUpdateOptions | TextUpdateCallback, - callback?: TextUpdateCallback, - ): Promise | void { - this.#assertActive(); - if (typeof options === 'function') return this.#runtime.updateAsync(options); - if (callback !== undefined) return this.#runtime.updateAsync(options ?? {}, callback); - return this.#runtime.updateAsync(options); - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const batch of [...this.#batches]) batch.dispose(); - this.#runtime.dispose(); - } - #assertActive(): void { - if (this.#disposed) throw new Error('TypeGPU text engine has been disposed'); - } -} - -class TypeGpuParagraphBatchImpl< - Technique extends AnyRasterTechnique, - Variant, - Program extends AnyTypeGpuRasterProgram, -> implements TypeGpuParagraphBatch { - readonly technique: Technique; - readonly program: Program; - readonly #batch: ParagraphBatch; - readonly #target: TypeGpuParagraphBatchTarget, TypeGpuRevisionOf>; - readonly #attachment: ParagraphBatchAttachment>; - readonly #paragraphs = new Map>(); - readonly #onDispose: ( - batch: TypeGpuParagraphBatchImpl>, - ) => void; - #disposed = false; - - constructor( - runtime: TextRuntime, - root: TgpuRoot, - colorFormat: GPUTextureFormat, - depthStencil: GPUDepthStencilState | undefined, - sampleCount: number, - options: TypeGpuParagraphBatchOptions, - onDispose: ( - batch: TypeGpuParagraphBatchImpl>, - ) => void, - ) { - this.technique = options.technique; - this.program = options.program; - this.#onDispose = onDispose; - this.#batch = runtime.createParagraphBatch({ - technique: options.technique, - ...(options.capacity === undefined ? {} : { capacity: options.capacity }), - ...(options.rasterPixelRatio === undefined ? {} : { rasterPixelRatio: options.rasterPixelRatio }), - ...(options.renderVariant === undefined ? {} : { renderVariant: options.renderVariant as Variant }), - }); - const program = options.program as unknown as TypeGpuRasterProgram< - Technique, - Variant, - TypeGpuDrawOf, - TypeGpuRevisionOf - >; - this.#target = program.createTarget({ - root, - technique: options.technique, - colorFormat, - ...(depthStencil === undefined ? {} : { depthStencil }), - sampleCount, - }); - this.#attachment = this.#batch.attach(this.#target); - } - - get current(): TypeGpuRevisionOf | undefined { - return this.#attachment.current; - } - get error(): TextPreparationError | ParagraphBatchTargetError | undefined { - return this.#batch.preparationError ?? this.#attachment.error; - } - get disposed(): boolean { - return this.#disposed; - } - get rasterPixelRatio(): number { - return this.#batch.rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - this.#assertActive(); - this.#batch.rasterPixelRatio = value; - } - get renderVariant(): Variant | undefined { - return this.#batch.renderVariant; - } - set renderVariant(value: Variant | undefined) { - this.#assertActive(); - this.#batch.renderVariant = value; - } - add(properties: TypeGpuParagraphProperties): TypeGpuParagraph { - this.#assertActive(); - const { transform, visible, ...coreProperties } = properties; - const paragraph = this.#batch.add(coreProperties as ParagraphProperties); - const value = new TypeGpuParagraphImpl(paragraph, transform, visible, this.#target, () => { - this.#paragraphs.delete(paragraph.id); - }); - this.#paragraphs.set(paragraph.id, value); - return value; - } - has(paragraph: TypeGpuParagraph): boolean { - return this.#paragraphs.get(paragraph.id) === paragraph && !paragraph.disposed; - } - setCapacity(capacity: GlyphBufferCapacity): void { - this.#assertActive(); - this.#batch.setCapacity(capacity); - } - retry(): void { - this.#assertActive(); - this.#attachment.retry(); - } - encode(pass: GPURenderPassEncoder, frame: TypeGpuFrame): void { - this.#assertActive(); - assertFrame(frame); - this.#attachment.prepare(); - const revision = this.#attachment.commit(); - if (revision !== undefined) this.#target.encode(pass, revision, frame); - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const paragraph of [...this.#paragraphs.values()]) paragraph.dispose(); - this.#attachment.dispose(); - this.#batch.dispose(); - this.#onDispose( - this as TypeGpuParagraphBatchImpl>, - ); - } - #assertActive(): void { - if (this.#disposed) throw new Error('TypeGPU paragraph batch has been disposed'); - } -} - -const IDENTITY_MATRIX = Object.freeze([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]); - -class TypeGpuParagraphImpl implements TypeGpuParagraph< - Technique, - Variant -> { - readonly #paragraph: Paragraph; - readonly #target: Pick, 'setParagraphState'>; - readonly #onDispose: () => void; - #transform: Float32Array; - #visible: boolean; - #disposed = false; - - constructor( - paragraph: Paragraph, - transform: ArrayLike | undefined, - visible: boolean | undefined, - target: Pick, 'setParagraphState'>, - onDispose: () => void, - ) { - this.#paragraph = paragraph; - this.#target = target; - this.#onDispose = onDispose; - this.#transform = copyTransform(transform ?? IDENTITY_MATRIX); - this.#visible = visible ?? true; - this.#publishTargetState(); - } - get id(): ParagraphId { - return this.#paragraph.id; - } - get disposed(): boolean { - return this.#disposed; - } - get layout(): ParagraphLayout | undefined { - return this.#paragraph.committed?.layout; - } - get font(): FontSelection { - return this.#paragraph.font; - } - set font(value: FontSelection) { - this.#paragraph.font = value; - } - get text(): string { - return this.#paragraph.snapshotProperties().text; - } - set text(value: TextInput) { - this.#paragraph.text = value; - } - get spans(): readonly ParagraphSpan[] { - return this.#paragraph.spans; - } - set spans(value: readonly ParagraphSpan[]) { - this.#paragraph.spans = value; - } - get contentBox(): ParagraphContentBox { - return this.#paragraph.contentBox; - } - set contentBox(value: ParagraphContentBox) { - this.#paragraph.contentBox = value; - } - get style(): ParagraphStyle { - return this.#paragraph.style; - } - set style(value: ParagraphStyle) { - this.#paragraph.style = value; - } - get paint(): GlyphPaintInput { - return this.#paragraph.paint; - } - set paint(value: GlyphPaintInput) { - this.#paragraph.paint = value; - } - get rasterPixelRatio(): number { - return this.#paragraph.rasterPixelRatio; - } - set rasterPixelRatio(value: number) { - this.#paragraph.rasterPixelRatio = value; - } - get order(): number { - return this.#paragraph.order; - } - set order(value: number) { - this.#paragraph.order = value; - } - get renderVariant(): Variant | undefined { - return this.#paragraph.renderVariant; - } - set renderVariant(value: Variant | undefined) { - this.#paragraph.renderVariant = value; - } - get visible(): boolean { - return this.#visible; - } - set visible(value: boolean) { - this.#assertActive(); - if (this.#visible === value) return; - this.#visible = value; - this.#publishTargetState(); - } - set(properties: TypeGpuParagraphUpdate): void { - this.#assertActive(); - const { transform, visible, ...coreProperties } = properties; - this.#paragraph.set(coreProperties as ParagraphUpdate); - if (transform !== undefined) this.#transform = copyTransform(transform); - if (visible !== undefined) this.#visible = visible; - if (transform !== undefined || visible !== undefined) this.#publishTargetState(); - } - setSpan(index: number, span: ParagraphSpan): void { - this.#paragraph.setSpan(index, span); - } - removeSpan(index: number): void { - this.#paragraph.removeSpan(index); - } - setTransform(columnMajorMatrix4: ArrayLike): void { - this.#assertActive(); - this.#transform = copyTransform(columnMajorMatrix4); - this.#publishTargetState(); - } - snapshotGlyphs(): GlyphSnapshot { - return this.#paragraph.snapshotGlyphs(); - } - setGlyphOrigins(update: GlyphOriginUpdate): void { - this.#paragraph.setGlyphOrigins(update); - } - clearGlyphOriginOverrides(): void { - this.#paragraph.clearGlyphOriginOverrides(); - } - snapshotProperties(): TypeGpuParagraphSnapshot { - return Object.freeze({ - ...this.#paragraph.snapshotProperties(), - transform: this.#transform.slice(), - visible: this.#visible, - }); - } - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - this.#target.setParagraphState(this.id, undefined); - this.#paragraph.dispose(); - this.#onDispose(); - } - #publishTargetState(): void { - this.#target.setParagraphState( - this.id, - Object.freeze({ transform: this.#transform.slice(), visible: this.#visible }), - ); - } - #assertActive(): void { - if (this.#disposed) throw new Error('TypeGPU paragraph has been disposed'); - } -} - -function copyTransform(value: ArrayLike): Float32Array { - if (value.length !== 16) throw new TypeError('paragraph transform must contain exactly 16 values'); - const result = Float32Array.from(value); - for (const component of result) - if (!Number.isFinite(component)) throw new TypeError('paragraph transform must be finite'); - return result; -} - -function assertFrame(frame: TypeGpuFrame): void { - copyTransform(frame.viewProjection); - if ( - frame.viewport.length !== 2 || - !Number.isFinite(frame.viewport[0]) || - frame.viewport[0] <= 0 || - !Number.isFinite(frame.viewport[1]) || - frame.viewport[1] <= 0 - ) - throw new TypeError('TypeGPU frame viewport must contain two positive finite values'); - if (!Number.isFinite(frame.pixelRatio) || frame.pixelRatio <= 0) - throw new TypeError('TypeGPU frame pixelRatio must be positive and finite'); -} - -export type { FormattedText }; diff --git a/packages/text/tests/fuzz/paragraph-policy.test.mjs b/packages/text/tests/fuzz/paragraph-policy.test.mjs deleted file mode 100644 index ec37a953..00000000 --- a/packages/text/tests/fuzz/paragraph-policy.test.mjs +++ /dev/null @@ -1,280 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; - -test('fixed-seed paragraph policy mutations stay safe, finite, and deterministic', async () => { - const { font, shaper } = await runtime( - new URL('../../../../apps/benchmarks/fixtures/fonts/amiri-1.002/Amiri-Regular.ttf', import.meta.url), - ); - const paragraph = createParagraphEngine({ shaper }).create({ text: '', font: font.handle }); - const cases = fuzzCases(0x5e_ed_53_03, 64); - const first = []; - const second = []; - - for (const results of [first, second]) { - for (const entry of cases) { - if (!entry.text.isWellFormed()) { - assert.throws( - () => paragraph.update({ text: entry.text, font: font.handle, style: entry.style }), - /well-formed UTF-16/, - ); - results.push('invalid-utf16'); - continue; - } - paragraph.update({ - text: entry.text, - font: font.handle, - style: entry.style, - }); - const measured = paragraph.measure(entry.constraints); - assert.equal(paragraph.measure(entry.constraints), measured); - assertMeasurement(measured); - const layout = paragraph.layout(entry.constraints); - assert.equal(paragraph.layout(entry.constraints), layout); - assertMeasurement(layout); - assertLayoutBoundaries(entry.text, layout); - results.push(hashLayout(layout)); - } - } - assert.deepEqual(second, first); - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -test('fixed-seed CJK boundary mutations stay safe, finite, and deterministic', async () => { - const { font, shaper } = await runtime( - new URL( - '../../../../apps/benchmarks/fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', - import.meta.url, - ), - ); - const paragraph = createParagraphEngine({ shaper }).create({ text: '', font: font.handle }); - const cases = cjkFuzzCases(0x5e_ed_54_04, 32); - assert.deepEqual(new Set(cases.map(({ style }) => style.language)), new Set(['zh-hans', 'zh-hant', 'ja', 'ko'])); - assert.deepEqual( - new Set(cases.map(({ constraints }) => constraints.width.mode)), - new Set(['unconstrained', 'at-most', 'exactly']), - ); - assert.deepEqual( - new Set(cases.map(({ constraints }) => constraints.height.mode)), - new Set(['unconstrained', 'at-most', 'exactly']), - ); - const first = []; - const second = []; - - for (const results of [first, second]) { - for (const entry of cases) { - paragraph.update({ text: entry.text, font: font.handle, style: entry.style }); - const measured = paragraph.measure(entry.constraints); - const layout = paragraph.layout(entry.constraints); - assertMeasurement(measured); - assertMeasurement(layout); - assertLayoutBoundaries(entry.text, layout); - results.push(hashLayout(layout)); - } - } - assert.deepEqual(second, first); - - for (const text of ['漢\ud800字', '漢\udc00字', '禰\udb40', '禰\udd00', '禰\udd00\udb40']) { - assert.throws(() => paragraph.update({ text, font: font.handle, style: { language: 'ja' } }), /well-formed UTF-16/); - } - for (const text of ['\u{FE00}', '\u{E0100}', '漢\u{FE00}\u{FE01}', '禰\u{E0100}\u{E0101}']) { - paragraph.update({ text, font: font.handle, style: { language: 'ja' } }); - const firstLayout = paragraph.layout({ width: { mode: 'exactly', size: 1 }, wrap: 'word' }); - assertLayoutBoundaries(text, firstLayout); - assert.equal(paragraph.layout({ width: { mode: 'exactly', size: 1 }, wrap: 'word' }), firstLayout); - } - for (const language of ['ja--jp', 'ja\0jp', 'ja_jp', '123']) { - assert.throws( - () => paragraph.update({ text: '漢字', font: font.handle, style: { language } }), - /invalid batch request/, - ); - } - paragraph.update({ text: '漢字', font: font.handle, style: { language: 'ja' } }); - for (const constraints of [ - { width: { mode: 'at-most', size: -1 } }, - { width: { mode: 'exactly', size: Number.NaN } }, - { width: { mode: 'exactly', size: Number.POSITIVE_INFINITY } }, - { height: { mode: 'at-most', size: -1 } }, - { maxLines: 0 }, - { maxLines: 1.5 }, - ]) { - assert.throws(() => paragraph.layout(constraints), RangeError); - } - - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -function fuzzCases(seed, count) { - const random = xorshift(seed); - const tokens = ['abc', 'مرحبا', '123', ' ', '\n', 'لا', 'e\u0301', '\ud800', '…', '(אב)']; - const wraps = ['none', 'word', 'character']; - const aligns = ['start', 'center', 'end', 'justify']; - const overflows = ['visible', 'clip', 'ellipsis']; - const directions = ['auto', 'ltr', 'rtl']; - return Array.from({ length: count }, () => { - let text = ''; - const tokenCount = 1 + integer(random, 10); - for (let index = 0; index < tokenCount; index += 1) text += tokens[integer(random, tokens.length)]; - const widthMode = integer(random, 3); - const heightMode = integer(random, 3); - return { - text, - style: { - fontSize: 8 + integer(random, 65), - lineHeight: 0.75 + integer(random, 200) / 100, - letterSpacing: (integer(random, 81) - 40) / 10, - direction: directions[integer(random, directions.length)], - language: integer(random, 2) === 0 ? 'ar' : 'en', - }, - constraints: { - width: axis(widthMode, random), - height: axis(heightMode, random), - ...(integer(random, 3) === 0 ? { maxLines: 1 + integer(random, 5) } : {}), - wrap: wraps[integer(random, wraps.length)], - align: aligns[integer(random, aligns.length)], - overflow: overflows[integer(random, overflows.length)], - }, - }; - }); -} - -function cjkFuzzCases(seed, count) { - const random = xorshift(seed); - const tokens = [ - '漢字', - 'かなカナ', - '한글', - '한글', - '、。「」()', - ' ', - '\u{2000B}', - '、\u{FE00}。\u{FE01}', - '禰\u{E0100}', - ]; - const languages = ['zh-hans', 'zh-hant', 'ja', 'ko']; - const wraps = ['none', 'word', 'character']; - return Array.from({ length: count }, () => { - const shuffled = [...tokens]; - for (let index = shuffled.length - 1; index > 0; index -= 1) { - const selected = integer(random, index + 1); - const value = shuffled[index]; - shuffled[index] = shuffled[selected]; - shuffled[selected] = value; - } - const widthMode = integer(random, 3); - const heightMode = integer(random, 3); - return { - text: shuffled.join(''), - style: { - fontSize: 10 + integer(random, 55), - lineHeight: 0.75 + integer(random, 200) / 100, - letterSpacing: (integer(random, 41) - 20) / 10, - language: languages[integer(random, languages.length)], - }, - constraints: { - width: axis(widthMode, random), - height: axis(heightMode, random), - ...(integer(random, 3) === 0 ? { maxLines: 1 + integer(random, 5) } : {}), - wrap: wraps[integer(random, wraps.length)], - }, - }; - }); -} - -function axis(mode, random) { - if (mode === 0) return { mode: 'unconstrained' }; - return { mode: mode === 1 ? 'at-most' : 'exactly', size: integer(random, 321) }; -} - -function xorshift(seed) { - let state = seed >>> 0; - return () => { - state ^= state << 13; - state ^= state >>> 17; - state ^= state << 5; - return state >>> 0; - }; -} - -function integer(random, limit) { - return random() % limit; -} - -function assertMeasurement(value) { - for (const field of ['width', 'height', 'contentWidth', 'contentHeight', 'firstBaseline', 'lastBaseline']) { - assert.equal(Number.isFinite(value[field]), true, field); - assert.ok(value[field] >= 0, field); - } - assert.equal(typeof value.overflowed, 'boolean'); -} - -function assertLayoutBoundaries(text, layout) { - assert.equal(layout.glyphIds.length, layout.clusters.length); - assert.equal(layout.glyphIds.length, layout.x.length); - assert.equal(layout.glyphIds.length, layout.y.length); - assert.equal(layout.glyphIds.length, layout.glyphFlags.length); - assert.equal(layout.lineTextStarts.length, layout.lineTextEnds.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphStarts.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphCounts.length); - assert.equal(layout.lineTextStarts.length, layout.lineBaselines.length); - assert.equal(layout.lineTextStarts.length, layout.lineAdvances.length); - for (const offset of [...layout.clusters, ...layout.lineTextStarts, ...layout.lineTextEnds]) { - assert.ok(offset <= text.length); - assert.equal(isUtf16Boundary(text, offset), true, `UTF-16 boundary ${offset}`); - } - for (const value of [...layout.x, ...layout.y, ...layout.lineAdvances]) { - assert.equal(Number.isFinite(value), true); - } -} - -function isUtf16Boundary(text, offset) { - if (offset === 0 || offset === text.length) return true; - const previous = text.charCodeAt(offset - 1); - const current = text.charCodeAt(offset); - return !(previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff); -} - -function hashLayout(layout) { - let hash = 2_166_136_261; - for (const values of [ - layout.glyphIds, - layout.clusters, - layout.x, - layout.y, - layout.glyphFlags, - layout.lineTextStarts, - layout.lineTextEnds, - layout.lineGlyphStarts, - layout.lineGlyphCounts, - layout.lineBaselines, - layout.lineAdvances, - ]) { - const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength); - for (const value of bytes) hash = Math.imul(hash ^ value, 16_777_619); - } - return hash >>> 0; -} - -async function runtime(sourceUrl) { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(sourceUrl), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} diff --git a/packages/text/tests/fuzz/shaper-request.test.mjs b/packages/text/tests/fuzz/shaper-request.test.mjs deleted file mode 100644 index 7ed6baf8..00000000 --- a/packages/text/tests/fuzz/shaper-request.test.mjs +++ /dev/null @@ -1,184 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createFontBaker } from '../../../font-baker/dist/index.js'; -import { validateFontArtifact } from '../../../font-baker/dist/validator.js'; - -const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); -const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); - -test('fixed-seed shaper request mutations fail safely and deterministically', async () => { - const [wasm, abi, source, bakerWasm] = await Promise.all([ - readFile(wasmUrl), - readFile(abiUrl, 'utf8').then(JSON.parse), - readFile(new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - ]); - const artifact = (await createFontBaker(bakerWasm)).bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const font = await validateFontArtifact(artifact); - const module = await WebAssembly.compile(wasm); - const corpus = mutationCorpus(); - const first = await execute(module, abi, corpus, font); - const second = await execute(module, abi, corpus, font); - assert.deepEqual(first, second); - assert.deepEqual(first.slice(0, 2), [0, 0], 'both seed requests must reach shaping'); - assert.ok(first.every((status) => Number.isSafeInteger(status) && status >= 0 && status <= 7)); - assert.ok( - first.slice(2).some((status) => status === 0), - 'mutations must reach valid shaping paths', - ); - assert.ok( - first.slice(2).some((status) => status !== 0), - 'mutations must retain malformed paths', - ); -}); - -test('raw shaper allocations reject forged releases and recover after invalid requests', async () => { - const wasm = await readFile(wasmUrl); - const module = await WebAssembly.compile(wasm); - const instance = await WebAssembly.instantiate(module, {}); - const memory = instance.exports.memory; - const allocate = instance.exports.pmndrs_text_shaper_alloc; - const deallocate = instance.exports.pmndrs_text_shaper_dealloc; - const shapeBatch = instance.exports.pmndrs_text_shaper_shape_batch; - assert.ok(memory instanceof WebAssembly.Memory); - assert.equal(typeof allocate, 'function'); - assert.equal(typeof deallocate, 'function'); - assert.equal(typeof shapeBatch, 'function'); - - assert.equal(allocate(64 * 1024 * 1024 + 1), 0); - const bytes = shapeRequest(); - const pointer = allocate(bytes.byteLength); - assert.notEqual(pointer, 0); - new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); - deallocate(pointer + 1, bytes.byteLength - 1); - deallocate(pointer, bytes.byteLength - 1); - assert.equal(shapeBatch(pointer, bytes.byteLength), 5); - deallocate(pointer, bytes.byteLength); - deallocate(pointer, bytes.byteLength); - - const recovered = allocate(bytes.byteLength); - assert.notEqual(recovered, 0); - deallocate(recovered, bytes.byteLength); -}); - -async function execute(module, abi, corpus, font) { - const instance = await WebAssembly.instantiate(module, {}); - const memory = instance.exports.memory; - const allocate = instance.exports[abi.functions.allocate]; - const deallocate = instance.exports[abi.functions.deallocate]; - const registerFont = instance.exports[abi.functions.registerFont]; - const disposeFont = instance.exports[abi.functions.disposeFont]; - const shapeBatch = instance.exports[abi.functions.shapeBatch]; - const reshapeRanges = instance.exports[abi.functions.reshapeRanges]; - const fontInputs = [font.shapingSfnt, font.glyphExtents, font.glyphExtentsAvailability]; - const fontPointers = fontInputs.map((bytes) => copyBytes(memory, allocate, bytes)); - assert.equal( - registerFont( - 1, - fontPointers[0], - fontInputs[0].byteLength, - fontPointers[1], - fontInputs[1].byteLength, - fontPointers[2], - fontInputs[2].byteLength, - ), - 0, - ); - for (let index = 0; index < fontInputs.length; index += 1) { - deallocate(fontPointers[index], fontInputs[index].byteLength); - } - const statuses = []; - for (const { bytes, reshape } of corpus) { - const pointer = allocate(bytes.length); - new Uint8Array(memory.buffer, pointer, bytes.length).set(bytes); - statuses.push((reshape ? reshapeRanges : shapeBatch)(pointer, bytes.length)); - deallocate(pointer, bytes.length); - } - const outOfBounds = memory.buffer.byteLength - 2; - statuses.push(shapeBatch(outOfBounds, 8)); - statuses.push(reshapeRanges(outOfBounds, 8)); - assert.equal(disposeFont(1), 0); - return statuses; -} - -function copyBytes(memory, allocate, bytes) { - const pointer = allocate(bytes.byteLength); - assert.notEqual(pointer, 0); - new Uint8Array(memory.buffer, pointer, bytes.byteLength).set(bytes); - return pointer; -} - -function mutationCorpus() { - const bases = [shapeRequest(), reshapeRequest()]; - const corpus = bases.map((bytes, reshape) => ({ bytes, reshape: reshape === 1 })); - let state = 0x504d_4e44; - for (const [baseIndex, base] of bases.entries()) { - for (let mutation = 0; mutation < 128; mutation++) { - const bytes = base.slice(); - state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; - const offset = state % bytes.length; - state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; - bytes[offset] ^= state & 0xff || 1; - corpus.push({ bytes, reshape: baseIndex === 1 }); - } - } - return corpus; -} - -function shapeRequest() { - const bytes = new Uint8Array(68); - const view = new DataView(bytes.buffer); - view.setUint32(0, 32, true); - view.setUint32(4, 1, true); - view.setUint32(8, 36, true); - view.setUint32(12, 1, true); - view.setUint32(16, 68, true); - view.setUint32(20, 0, true); - view.setUint32(24, 68, true); - view.setUint32(28, 0, true); - view.setUint16(32, 0x41, true); - writeRun(view, 36); - return bytes; -} - -function reshapeRequest() { - const bytes = new Uint8Array(100); - const view = new DataView(bytes.buffer); - view.setUint32(0, 40, true); - view.setUint32(4, 1, true); - view.setUint32(8, 44, true); - view.setUint32(12, 1, true); - view.setUint32(16, 76, true); - view.setUint32(20, 0, true); - view.setUint32(24, 76, true); - view.setUint32(28, 0, true); - view.setUint32(32, 76, true); - view.setUint32(36, 1, true); - view.setUint16(40, 0x41, true); - writeRun(view, 44); - view.setUint32(76, 0, true); - view.setUint32(80, 0, true); - view.setUint32(84, 1, true); - view.setUint32(88, 0, true); - view.setUint32(92, 1, true); - view.setUint32(96, 0x40, true); - return bytes; -} - -function writeRun(view, offset) { - view.setUint32(offset, 1, true); - view.setUint32(offset + 4, 0, true); - view.setUint32(offset + 8, 1, true); - view.setUint32(offset + 12, 0x4c61_746e, true); - view.setUint32(offset + 16, 0xffff_ffff, true); - view.setUint32(offset + 20, 0, true); - view.setUint16(offset + 24, 0, true); - view.setUint8(offset + 26, 0); - view.setUint8(offset + 27, 0); - view.setUint32(offset + 28, 0x40, true); -} diff --git a/packages/text/tests/integration/bidi-analysis.test.mjs b/packages/text/tests/integration/bidi-analysis.test.mjs deleted file mode 100644 index 0896d67f..00000000 --- a/packages/text/tests/integration/bidi-analysis.test.mjs +++ /dev/null @@ -1,61 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createRuntimeShaper } from '../../dist/index.js'; - -const wasm = await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)); - -test('direct-memory bidi ABI returns exact Unicode 17 levels and classes', async () => { - const shaper = await createRuntimeShaper({ wasm }); - try { - const text = 'אב(גד[&ef].)gh'; - const result = shaper.analyzeBidi(utf16(text), 'ltr'); - assert.deepEqual([...result.levels], [1, 1, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0]); - assert.deepEqual([...result.classes], [1, 1, 13, 1, 1, 13, 13, 0, 0, 13, 7, 13, 0, 0]); - assert.deepEqual([...result.paragraphStarts], [0]); - assert.deepEqual([...result.paragraphEnds], [14]); - assert.deepEqual([...result.paragraphLevels], [0]); - } finally { - shaper.dispose(); - } -}); - -test('bidi ABI is UTF-16-indexed and honors auto and explicit paragraph direction', async () => { - const shaper = await createRuntimeShaper({ wasm }); - try { - const text = utf16('\u{10940}😀 A\u2029אב'); - const automatic = own(shaper.analyzeBidi(text)); - assert.equal(automatic.levels.length, text.length); - assert.equal(automatic.levels[0], automatic.levels[1]); - assert.equal(automatic.levels[2], automatic.levels[3]); - assert.deepEqual([...automatic.paragraphStarts], [0, 7]); - assert.deepEqual([...automatic.paragraphEnds], [7, 9]); - assert.deepEqual([...automatic.paragraphLevels], [1, 1]); - - const ltr = own(shaper.analyzeBidi(text, 'ltr')); - const rtl = own(shaper.analyzeBidi(text, 'rtl')); - assert.deepEqual([...ltr.paragraphLevels], [0, 0]); - assert.deepEqual([...rtl.paragraphLevels], [1, 1]); - assert.throws(() => shaper.analyzeBidi(text, 'sideways'), /auto, ltr, or rtl/); - assert.throws(() => shaper.analyzeBidi(new Uint8Array(text), 'auto'), /Uint16Array/); - } finally { - shaper.dispose(); - } -}); - -function utf16(value) { - const units = []; - for (let index = 0; index < value.length; index += 1) units.push(value.charCodeAt(index)); - return Uint16Array.from(units); -} - -function own(result) { - return { - levels: result.levels.slice(), - classes: result.classes.slice(), - paragraphStarts: result.paragraphStarts.slice(), - paragraphEnds: result.paragraphEnds.slice(), - paragraphLevels: result.paragraphLevels.slice(), - }; -} diff --git a/packages/text/tests/integration/cjk-universality.test.mjs b/packages/text/tests/integration/cjk-universality.test.mjs deleted file mode 100644 index fcbef979..00000000 --- a/packages/text/tests/integration/cjk-universality.test.mjs +++ /dev/null @@ -1,299 +0,0 @@ -import assert from 'node:assert/strict'; -import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { graphemeSegments } from 'unicode-segmenter/grapheme'; - -import { hashParagraphLayout } from '../../../../apps/benchmarks/src/benchmark/paragraph-layout-digest.ts'; - -const fixtureRoot = new URL('../../../../apps/benchmarks/fixtures/', import.meta.url); -const fontUrl = new URL('fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', fixtureRoot); -const contractUrl = new URL('contracts/paragraph-cjk-layout-v0.json', fixtureRoot); -const oracleUrl = new URL('shaping/noto-sans-cjk/harfrust.json', fixtureRoot); -const layoutFields = [ - 'glyphFontSlots', - 'glyphIds', - 'clusters', - 'glyphFontSizes', - 'x', - 'y', - 'glyphFlags', - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', -]; - -test('proves CJK shaping and paragraph universality through the public pipeline', async () => { - const [source, contract, oracle, bakerWasm, shaperWasm] = await Promise.all([ - readFile(fontUrl), - readJson(contractUrl), - readJson(oracleUrl), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - assert.equal(createHash('sha256').update(source).digest('hex'), contract.font.sourceSha256); - - const baker = await createFontBaker(bakerWasm); - const baked = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }); - const artifact = baked.artifacts[0]; - assert.ok(artifact); - assert.equal(artifact.sha256, contract.font.artifactSha256); - - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact.bytes); - assert.equal(font.shapingHash, contract.font.shapingHash); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - - const localizedGlyphs = assertOracleShaping(shaper, font.handle, oracle); - assert.notDeepEqual(localizedGlyphs.get('zh-Hans'), localizedGlyphs.get('zh-Hant')); - assert.notDeepEqual(localizedGlyphs.get('zh-Hans'), localizedGlyphs.get('ja')); - assert.deepEqual(localizedGlyphs.get('ja'), localizedGlyphs.get('ko')); - - const calls = { shape: 0, reshape: 0 }; - const requests = []; - const observed = observeShaper(shaper, calls, requests); - const engine = createParagraphEngine({ shaper: observed }); - const retained = []; - - for (const [caseId, expected] of Object.entries(contract.cases)) { - const before = { ...calls }; - const requestStart = requests.length; - const paragraph = engine.create({ - text: expected.text, - font: font.handle, - style: expected.style, - }); - const preparationRequests = requests.slice(requestStart); - assertContextualRuns(caseId, expected, preparationRequests); - - for (const constraintId of ['natural', 'wide', 'narrow']) { - const constraints = contract.constraints[constraintId]; - const measurement = paragraph.measure(constraints); - const layout = paragraph.layout(constraints); - const golden = expected.layouts[constraintId]; - assert.equal(paragraph.measure(constraints), measurement, `${caseId}.${constraintId} measure`); - assert.equal(paragraph.layout(constraints), layout, `${caseId}.${constraintId} layout`); - assertEquivalentMeasurement(measurement, layout, `${caseId}.${constraintId} measurement`); - assertGoldenLayout(layout, golden, `${caseId}.${constraintId}`); - assertSafeBoundaries(expected.text, layout, `${caseId}.${constraintId}`); - retained.push({ label: `${caseId}.${constraintId}`, layout, hash: golden.hash }); - } - - assert.deepEqual( - { shape: calls.shape - before.shape, reshape: calls.reshape - before.reshape }, - expected.calls, - `${caseId} boundary calls`, - ); - paragraph.dispose(); - } - - const boundaryText = '甲\u{2000B}、\u{FE00}。\u{FE01}禰\u{E0100}한'; - const boundaryParagraph = engine.create({ - text: boundaryText, - font: font.handle, - style: { fontSize: 32, language: 'ja' }, - }); - const boundaryLayout = boundaryParagraph.layout({ - width: { mode: 'exactly', size: 64 }, - wrap: 'word', - }); - assertSafeBoundaries(boundaryText, boundaryLayout, 'supplementary/SVS/IVS/Jamo boundary probe'); - boundaryParagraph.dispose(); - - const interference = engine.create({ - text: '骨直辺', - font: font.handle, - style: { language: 'zh-hant' }, - }); - interference.layout({ width: { mode: 'exactly', size: 40 }, wrap: 'word' }); - for (const { label, layout, hash } of retained) { - assert.equal(hashParagraphLayout(layout), hash, `${label} owns its positioned result`); - } - - interference.dispose(); - shaper.dispose(); - font.dispose(); -}); - -function assertOracleShaping(shaper, fontHandle, oracle) { - const localized = new Map(); - for (const expected of oracle.cases) { - const textUtf16 = utf16(expected.text); - const shaped = shaper.shapeBatch({ - textUtf16, - features: [], - runs: [ - { - font: fontHandle, - textStart: 0, - textEnd: textUtf16.length, - direction: expected.segment.direction, - script: expected.segment.script, - language: expected.segment.language, - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }); - assert.deepEqual([...shaped.fontHandles], [fontHandle], expected.id); - assert.deepEqual([...shaped.runGlyphStarts], [0], expected.id); - assert.deepEqual([...shaped.runGlyphCounts], [expected.glyphs.length], expected.id); - assert.deepEqual( - { - glyphIds: [...shaped.glyphIds], - clusters: [...shaped.clusters], - xAdvances: [...shaped.xAdvances], - yAdvances: [...shaped.yAdvances], - xOffsets: [...shaped.xOffsets], - yOffsets: [...shaped.yOffsets], - glyphFlags: [...shaped.glyphFlags], - }, - { - glyphIds: expected.glyphs.map(({ glyphId }) => glyphId), - clusters: expected.glyphs.map(({ cluster }) => cluster), - xAdvances: expected.glyphs.map(({ xAdvance }) => xAdvance), - yAdvances: expected.glyphs.map(({ yAdvance }) => yAdvance), - xOffsets: expected.glyphs.map(({ xOffset }) => xOffset), - yOffsets: expected.glyphs.map(({ yOffset }) => yOffset), - glyphFlags: expected.glyphs.map(({ flags }) => flags), - }, - expected.id, - ); - if (expected.id.startsWith('locl-')) { - localized.set(expected.segment.language, [...shaped.glyphIds]); - } - } - return localized; -} - -function assertContextualRuns(caseId, expected, requests) { - assert.equal(requests.length, 1, `${caseId} uses one broad shape request`); - const request = requests[0]; - const boundaries = graphemeBoundaries(expected.text); - const contentRuns = request.runs.filter(({ textStart }) => textStart < expected.text.length); - assert.ok(contentRuns.length > 0, `${caseId} has content runs`); - for (const run of contentRuns) { - assert.equal(run.language, expected.style.language, `${caseId} language`); - assert.equal(boundaries.has(run.textStart), true, `${caseId} run start ${run.textStart}`); - assert.equal(boundaries.has(run.textEnd), true, `${caseId} run end ${run.textEnd}`); - assert.notEqual(run.script, 'Bopo', `${caseId} must not assign punctuation to Bopomofo`); - } - const scripts = new Set(contentRuns.map(({ script }) => script)); - const required = { - simplified: ['Hani'], - japanese: ['Hani', 'Hira'], - korean: ['Hani', 'Hang'], - mixed: ['Hani', 'Hang', 'Hira', 'Latn'], - }[caseId]; - assert.deepEqual(scripts, new Set(required), `${caseId} contextual scripts`); -} - -function assertGoldenLayout(layout, golden, label) { - assert.deepEqual(measurementOf(layout), golden.measurement, `${label} layout measurement`); - for (const field of layoutFields) { - assert.deepEqual([...layout[field]], golden[field], `${label}.${field}`); - } - assert.equal(hashParagraphLayout(layout), golden.hash, `${label} hash`); - for (const value of [ - layout.width, - layout.height, - layout.contentWidth, - layout.contentHeight, - layout.firstBaseline, - layout.lastBaseline, - ...layout.x, - ...layout.y, - ...layout.lineBaselines, - ...layout.lineAdvances, - ]) { - assert.equal(Number.isFinite(value), true, `${label} finite geometry`); - } -} - -function assertSafeBoundaries(text, layout, label) { - const boundaries = graphemeBoundaries(text); - for (const [kind, offsets] of [ - ['cluster', layout.clusters], - ['line start', layout.lineTextStarts], - ['line end', layout.lineTextEnds], - ]) { - for (const offset of offsets) { - assert.equal(boundaries.has(offset), true, `${label} ${kind} ${offset} is grapheme-safe`); - assert.equal(isUtf16Boundary(text, offset), true, `${label} ${kind} ${offset} is UTF-16-safe`); - } - } -} - -function observeShaper(shaper, calls, requests) { - return { - registry: shaper.registry, - registerFont: (font) => shaper.registerFont(font), - disposeFont: (font) => shaper.disposeFont(font), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => { - calls.shape += 1; - requests.push(request); - return shaper.shapeBatch(request); - }, - reshapeRanges: (request) => { - calls.reshape += 1; - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; -} - -function measurementOf(layout) { - return { - width: layout.width, - height: layout.height, - contentWidth: layout.contentWidth, - contentHeight: layout.contentHeight, - firstBaseline: layout.firstBaseline, - lastBaseline: layout.lastBaseline, - overflowed: layout.overflowed, - }; -} - -function assertEquivalentMeasurement(measurement, layout, label) { - assert.equal(measurement.overflowed, layout.overflowed, `${label}.overflowed`); - for (const field of ['width', 'height', 'contentWidth', 'contentHeight', 'firstBaseline', 'lastBaseline']) { - assert.ok(Math.abs(measurement[field] - layout[field]) < 0.0001, `${label}.${field}`); - } -} - -function graphemeBoundaries(text) { - const boundaries = new Set([0]); - for (const segment of graphemeSegments(text)) { - boundaries.add(segment.index + segment.segment.length); - } - return boundaries; -} - -function isUtf16Boundary(text, offset) { - if (offset === 0 || offset === text.length) return true; - const previous = text.charCodeAt(offset - 1); - const current = text.charCodeAt(offset); - return !(previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff); -} - -function utf16(text) { - return Uint16Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)); -} - -async function readJson(url) { - return JSON.parse(await readFile(url, 'utf8')); -} diff --git a/packages/text/tests/integration/empty-paragraph-features.test.mjs b/packages/text/tests/integration/empty-paragraph-features.test.mjs deleted file mode 100644 index 1cec1fe3..00000000 --- a/packages/text/tests/integration/empty-paragraph-features.test.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; - -const interUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); - -function dataUrl(bytes) { - return `data:application/octet-stream;base64,${Buffer.from(bytes).toString('base64')}`; -} - -test('a paragraph-wide font feature survives empty text and still rejects an empty explicit range', async () => { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const inter = await runtime.loadFont({ - input: { baked: dataUrl(await readFile(interUrl)) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - // An unbounded feature covers whatever it is applied to, so empty text makes it vacuous. A feature-styled - // input field is created before its first character is typed, and that must not fail preparation. - const field = batch.add({ - font: inter, - text: '', - style: { features: [{ tag: 'kern' }, { tag: 'liga' }] }, - }); - runtime.update(); - assert.equal(batch.preparationError, undefined, 'an unbounded feature must not fail an empty paragraph'); - assert.equal(batch.current.paragraphs[0].layout.glyphIds.length, 0); - - // The same paragraph shapes normally once it has text, proving the feature was carried rather than discarded. - field.text = 'Waffle'; - runtime.update(); - assert.equal(batch.preparationError, undefined); - assert.ok(batch.current.paragraphs[0].layout.glyphIds.length > 0); - - // An explicitly empty range is still a caller error rather than a vacuous no-op. Preparation reports it as a - // typed failure and keeps the prior revision current, rather than throwing out of the synchronization call. - const explicit = batch.add({ - font: inter, - text: 'Waffle', - style: { features: [{ tag: 'kern', start: 2, end: 2 }] }, - }); - const published = batch.current.revision; - assert.throws( - () => runtime.update(), - (error) => - error.kind === 'preparation-failed' && - /feature kern must be a non-empty UTF-16 range/.test(String(error.cause?.message)), - ); - assert.equal(batch.current.revision, published, 'a rejected feature range must preserve the prior revision'); - - explicit.dispose(); - field.dispose(); - batch.dispose(); - runtime.dispose(); - inter.dispose(); -}); diff --git a/packages/text/tests/integration/paragraph-bidi-policy.test.mjs b/packages/text/tests/integration/paragraph-bidi-policy.test.mjs deleted file mode 100644 index 174e3edd..00000000 --- a/packages/text/tests/integration/paragraph-bidi-policy.test.mjs +++ /dev/null @@ -1,372 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { hashParagraphLayout } from '../../../../apps/benchmarks/src/benchmark/paragraph-layout-digest.ts'; - -const contractUrl = new URL( - '../../../../apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json', - import.meta.url, -); -const contract = JSON.parse(await readFile(contractUrl, 'utf8')); - -test('lays out exact mixed-direction Amiri goldens through retained GLB shaping data', async () => { - const { font, shaper } = await runtime('amiri-1.002/Amiri-Regular.ttf'); - const calls = { shape: 0, reshape: 0 }; - const requests = []; - const observed = observeShaper(shaper, calls, requests); - const engine = createParagraphEngine({ shaper: observed }); - let retainedLayout; - - assert.equal(contract.generatedBy, 'apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts'); - assert.equal(font.shapingHash, contract.fonts.amiri.shapingHash); - for (const fixture of Object.values(contract.bidi)) { - const paragraph = engine.create({ - text: fixture.text, - font: font.handle, - style: fixture.style, - }); - const layout = paragraph.layout(fixture.constraints); - assertGoldenLayout(layout, fixture.layout, true); - assertLineTopology(layout, fixture.text); - if (retainedLayout === undefined) retainedLayout = layout; - } - - assert.deepEqual(calls, { shape: 2, reshape: 0 }); - assert.deepEqual( - requests - .filter(({ shape }) => shape !== undefined) - .map(({ shape }) => - shape.runs.slice(0, shape.runs.length / 2).map((run) => ({ - text: String.fromCharCode(...shape.textUtf16.slice(run.textStart, run.textEnd)), - direction: run.direction, - script: run.script, - })), - ), - [ - [ - { text: 'ABC ', direction: 'ltr', script: 'Latn' }, - { text: 'مرحبا ', direction: 'rtl', script: 'Arab' }, - { text: '123', direction: 'ltr', script: 'Arab' }, - { text: ' ', direction: 'ltr', script: 'Arab' }, - { text: 'DEF', direction: 'ltr', script: 'Latn' }, - ], - [ - { text: 'مرحبا ', direction: 'rtl', script: 'Arab' }, - { text: 'ABC 123', direction: 'ltr', script: 'Latn' }, - { text: ' ', direction: 'rtl', script: 'Latn' }, - { text: 'عالم', direction: 'rtl', script: 'Arab' }, - ], - ], - ); - assert.equal( - hashParagraphLayout(retainedLayout), - contract.bidi.ltr.layout.hash, - 'later borrowed Wasm results must not mutate an earlier paragraph layout', - ); - shaper.dispose(); - font.dispose(); -}); - -test('reuses broad Arabic shaping at safe line boundaries and narrows only unsafe context', async () => { - const { font, shaper } = await runtime('amiri-1.002/Amiri-Regular.ttf'); - shaper.registerFont(font); - const text = 'مرحبا بالعالم'; - const textUtf16 = utf16(text); - const request = { - textUtf16, - features: [], - runs: [ - { - font: font.handle, - textStart: 0, - textEnd: textUtf16.length, - direction: 'rtl', - script: 'Arab', - language: 'ar', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - const broad = ownShape(shaper.shapeBatch(request)); - const safeBoundary = 6; - const unsafeBoundary = 7; - - assert.equal(glyphFlagsAtCluster(broad, safeBoundary) & 1, 0, 'word boundary is safe to break'); - assert.equal(glyphFlagsAtCluster(broad, unsafeBoundary) & 1, 1, 'joining boundary is unsafe to break'); - assert.deepEqual( - shapeRangeSignature(reshapeLine(shaper, request, 0, safeBoundary), 0, safeBoundary), - shapeRangeSignature(broad, 0, safeBoundary), - 'safe first line is byte-identical to the retained broad shape', - ); - assert.deepEqual( - shapeRangeSignature(reshapeLine(shaper, request, safeBoundary, textUtf16.length), safeBoundary, textUtf16.length), - shapeRangeSignature(broad, safeBoundary, textUtf16.length), - 'safe next line is byte-identical to the retained broad shape', - ); - assert.notDeepEqual( - shapeRangeSignature(reshapeLine(shaper, request, 0, unsafeBoundary), 0, unsafeBoundary), - shapeRangeSignature(broad, 0, unsafeBoundary), - 'forcing an unsafe Arabic boundary changes the line shape', - ); - - shaper.dispose(); - font.dispose(); -}); - -test('applies exact alignment, clipping, max-lines, and ellipsis policies without hidden calls', async () => { - const { font, shaper } = await runtime('inter-v4.1/Inter-Regular.ttf'); - const calls = { shape: 0, reshape: 0 }; - const requests = []; - const observed = observeShaper(shaper, calls, requests); - const paragraph = createParagraphEngine({ shaper: observed }).create({ - text: contract.policies.text, - font: font.handle, - style: contract.policies.style, - }); - assert.equal(calls.shape, 1, 'text and every per-run ellipsis are prepared in one batch'); - - // Boundary reshaping requests the whole run as context, which is the context the retained paragraph shape already - // used, so it can only return the glyphs it already returned. These counts therefore assert that no policy reaches - // the shaper again: every layout below is produced from the one retained shape. A future narrowed context — a - // truncated line taking final forms, or a line shaped in isolation — is what should raise them again. - const expectedCrossings = { - start: 0, - center: 0, - end: 0, - justify: 0, - clip: 0, - maxLines: 0, - ellipsisOne: 0, - ellipsisHeightOne: 0, - ellipsisHeightTwo: 0, - }; - const layouts = {}; - for (const [id, fixture] of Object.entries(contract.policies.cases)) { - const measured = paragraph.measure(fixture.constraints); - assert.deepEqual(measured, fixture.layout.measurement); - const layout = paragraph.layout(fixture.constraints); - layouts[id] = layout; - assertGoldenLayout(layout, fixture.layout, false); - assertLineTopology(layout, contract.policies.text); - assert.equal(calls.reshape, expectedCrossings[id], `${id} reshape boundary count`); - } - - assert.equal(layouts.clip.glyphIds.length, layouts.start.glyphIds.length); - assert.equal(layouts.clip.height, 60); - assert.equal(layouts.clip.overflowed, true); - assert.deepEqual([...layouts.maxLines.lineTextEnds], [8, 19]); - assert.equal(layouts.maxLines.contentHeight, 160); - assert.equal(layouts.ellipsisOne.glyphIds.at(-1), 1503); - assert.equal(layouts.ellipsisOne.clusters.at(-1), 8); - assert.equal(layouts.ellipsisHeightOne.glyphIds, layouts.ellipsisOne.glyphIds); - assert.notEqual(layouts.ellipsisHeightTwo.glyphIds, layouts.ellipsisHeightOne.glyphIds); - assertAlignmentOffsets(layouts.start, layouts.center, layouts.end, 180); - assert.deepEqual( - [...layouts.justify.lineAdvances].slice(0, -1), - Array(layouts.justify.lineAdvances.length - 1).fill(180), - 'justification fills every non-final soft-wrapped line', - ); - assert.ok( - (layouts.justify.lineAdvances.at(-1) ?? 180) < 180, - 'justification leaves the final line at its natural advance', - ); - for (const layout of [layouts.ellipsisOne, layouts.ellipsisHeightOne, layouts.ellipsisHeightTwo]) { - const end = layout.lineTextEnds.at(-1) ?? contract.policies.text.length; - assert.ok(end < contract.policies.text.length, 'ellipsis truncates a source range'); - assert.equal(layout.clusters.at(-1), end, 'ellipsis glyph is anchored at the truncation boundary'); - } - assert.deepEqual( - requests.filter(({ ranges }) => ranges !== undefined).map(({ ranges }) => ranges.length), - [], - 'no policy issues a reshape request while the shaping context is the whole run', - ); - - shaper.dispose(); - font.dispose(); -}); - -async function runtime(relativeFontPath) { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL(`../../../../apps/benchmarks/fixtures/fonts/${relativeFontPath}`, import.meta.url)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} - -function observeShaper(shaper, calls, requests) { - return { - registry: shaper.registry, - registerFont: (font) => shaper.registerFont(font), - disposeFont: (font) => shaper.disposeFont(font), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (shape) => { - calls.shape += 1; - requests.push({ shape }); - return shaper.shapeBatch(shape); - }, - reshapeRanges: (request) => { - calls.reshape += 1; - requests.push({ ranges: request.ranges.map((range) => ({ ...range })) }); - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; -} - -function reshapeLine(shaper, request, start, end) { - return ownShape( - shaper.reshapeRanges({ - ...request, - ranges: [ - { - run: 0, - itemStart: start, - itemEnd: end, - contextStart: start, - contextEnd: end, - flags: 0x43, - }, - ], - }), - ); -} - -function ownShape(shape) { - return { - glyphIds: [...shape.glyphIds], - clusters: [...shape.clusters], - xAdvances: [...shape.xAdvances], - yAdvances: [...shape.yAdvances], - xOffsets: [...shape.xOffsets], - yOffsets: [...shape.yOffsets], - glyphFlags: [...shape.glyphFlags], - }; -} - -function glyphFlagsAtCluster(shape, cluster) { - const index = shape.clusters.indexOf(cluster); - assert.notEqual(index, -1, `expected a glyph at cluster ${cluster}`); - return shape.glyphFlags[index]; -} - -function shapeRangeSignature(shape, start, end) { - return shape.glyphIds.flatMap((glyphId, index) => - shape.clusters[index] >= start && shape.clusters[index] < end - ? [ - [ - glyphId, - shape.clusters[index], - shape.xAdvances[index], - shape.yAdvances[index], - shape.xOffsets[index], - shape.yOffsets[index], - ], - ] - : [], - ); -} - -function utf16(value) { - return Uint16Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); -} - -function assertGoldenLayout(layout, golden, full) { - assert.deepEqual( - { - width: layout.width, - height: layout.height, - contentWidth: layout.contentWidth, - contentHeight: layout.contentHeight, - firstBaseline: layout.firstBaseline, - lastBaseline: layout.lastBaseline, - overflowed: layout.overflowed, - }, - golden.measurement, - ); - const fields = full - ? [ - 'glyphFontSlots', - 'glyphIds', - 'clusters', - 'glyphFontSizes', - 'x', - 'y', - 'glyphFlags', - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', - ] - : [ - 'glyphIds', - 'clusters', - 'x', - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', - ]; - for (const field of fields) assert.deepEqual([...layout[field]], golden[field], field); - assert.equal(hashParagraphLayout(layout), golden.hash); -} - -function assertLineTopology(layout, text) { - assert.equal(layout.lineTextStarts.length, layout.lineTextEnds.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphStarts.length); - assert.equal(layout.lineTextStarts.length, layout.lineGlyphCounts.length); - let previousEnd = 0; - let previousGlyphEnd = 0; - let previousBaseline = -Infinity; - for (let index = 0; index < layout.lineTextStarts.length; index += 1) { - const start = layout.lineTextStarts[index]; - const end = layout.lineTextEnds[index]; - const glyphStart = layout.lineGlyphStarts[index]; - const glyphCount = layout.lineGlyphCounts[index]; - const baseline = layout.lineBaselines[index]; - const advance = layout.lineAdvances[index]; - assert.ok(start >= previousEnd && start <= end && end <= text.length, `line ${index} text range`); - assert.equal(glyphStart, previousGlyphEnd, `line ${index} glyph range is contiguous`); - assert.ok(glyphStart + glyphCount <= layout.glyphIds.length, `line ${index} glyph range is bounded`); - assert.ok(baseline > previousBaseline, `line ${index} baseline is strictly increasing`); - assert.ok(Number.isFinite(advance) && advance >= 0, `line ${index} advance is finite`); - previousEnd = end; - previousGlyphEnd = glyphStart + glyphCount; - previousBaseline = baseline; - } - assert.equal(previousGlyphEnd, layout.glyphIds.length, 'line glyph ranges cover the positioned output'); -} - -function assertAlignmentOffsets(start, center, end, width) { - for (let line = 0; line < start.lineGlyphStarts.length; line += 1) { - const glyph = start.lineGlyphStarts[line]; - if (glyph === undefined || start.lineGlyphCounts[line] === 0) continue; - const naturalX = start.x[glyph]; - const advance = start.lineAdvances[line]; - assertClose(center.x[glyph] - naturalX, (width - advance) / 2, `center line ${line}`); - assertClose(end.x[glyph] - naturalX, width - advance, `end line ${line}`); - } -} - -function assertClose(actual, expected, label) { - assert.ok(Math.abs(actual - expected) < 0.0001, `${label}: ${actual} != ${expected}`); -} diff --git a/packages/text/tests/integration/paragraph-measurement.test.mjs b/packages/text/tests/integration/paragraph-measurement.test.mjs deleted file mode 100644 index 37a75e74..00000000 --- a/packages/text/tests/integration/paragraph-measurement.test.mjs +++ /dev/null @@ -1,377 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; -import { hashParagraphLayout } from '../../../../apps/benchmarks/src/benchmark/paragraph-layout-digest.ts'; - -const fontDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); -const shapingDirectory = new URL('../../../../apps/benchmarks/fixtures/shaping/inter-regular/', import.meta.url); -const paragraphContract = new URL( - '../../../../apps/benchmarks/fixtures/contracts/paragraph-layout-v0.json', - import.meta.url, -); - -test('measures the exact GLB-extracted HarfRust paragraph without positioned arrays', async () => { - const [{ font, shaper }, oracle, contract] = await Promise.all([ - runtime(), - readJson(new URL('harfrust.json', shapingDirectory)), - readJson(paragraphContract), - ]); - const calls = { shape: 0, reshape: 0 }; - const reshapeRequests = []; - const observedShaper = observeShaper(shaper, calls, reshapeRequests); - const engine = createParagraphEngine({ shaper: observedShaper }); - const expected = oracle.cases.find(({ id }) => id === 'paragraph'); - const layoutGoldens = contract.goldens; - assert.ok(expected); - const paragraph = engine.create({ - text: expected.text, - font: font.handle, - style: { - fontSize: 32, - lineHeight: 1.3, - language: 'en', - direction: 'ltr', - features: [], - }, - }); - - assert.equal(calls.shape, 1, 'preparation must shape the paragraph once'); - assert.equal(calls.reshape, 0); - const interfering = engine.create({ text: 'AV', font: font.handle }); - assert.equal(calls.shape, 2, 'each prepared paragraph performs one broad shape'); - const expectedNaturalWidth = - (expected.glyphs.reduce((sum, glyph) => sum + glyph.xAdvance, 0) * 32) / font.metrics.unitsPerEm; - const natural = paragraph.measure(); - assert.equal(natural.width, expectedNaturalWidth); - assert.deepEqual(natural, layoutGoldens.natural.measurement); - assert.equal('glyphIds' in natural, false); - - const wideConstraints = { width: { mode: 'at-most', size: 720 } }; - const wide = paragraph.measure(wideConstraints); - assert.deepEqual(wide, layoutGoldens.wide.measurement); - assert.equal(paragraph.measure(wideConstraints), wide, 'equivalent measurements reuse one object'); - assert.deepEqual(paragraph.measure({ width: { mode: 'at-most', size: 360 } }), layoutGoldens.narrow.measurement); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'width-only reflow must not enter Wasm'); - - const naturalLayout = paragraph.layout(); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'unbroken layout reuses the broad shape'); - assert.deepEqual( - [...naturalLayout.glyphIds], - expected.glyphs.map(({ glyphId }) => glyphId), - ); - assert.deepEqual( - [...naturalLayout.clusters], - expected.glyphs.map(({ cluster }) => cluster), - ); - assert.deepEqual( - [...naturalLayout.glyphFlags], - expected.glyphs.map(({ flags }) => flags), - ); - assert.deepEqual([...naturalLayout.x], expectedNaturalX(expected.glyphs, 32 / font.metrics.unitsPerEm)); - assert.deepEqual( - [...naturalLayout.y], - expected.glyphs.map(({ yOffset }) => - Math.fround(naturalLayout.firstBaseline - (yOffset * 32) / font.metrics.unitsPerEm), - ), - ); - assertLayoutLines(naturalLayout, layoutGoldens.natural.layout); - assert.equal(hashParagraphLayout(naturalLayout), layoutGoldens.natural.layout.hash); - - const wideLayout = paragraph.layout(wideConstraints); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'wide boundaries lay out from the retained shape'); - assert.equal(paragraph.layout(wideConstraints), wideLayout, 'equivalent layout reuses one object'); - assertLayoutLines(wideLayout, layoutGoldens.wide.layout); - assert.deepEqual( - [...wideLayout.glyphIds], - expected.glyphs.map(({ glyphId }) => glyphId), - ); - assert.deepEqual( - [...wideLayout.clusters], - expected.glyphs.map(({ cluster }) => cluster), - ); - assert.deepEqual( - [...wideLayout.glyphFlags], - expected.glyphs.map(({ flags }) => flags), - ); - assert.equal(hashParagraphLayout(wideLayout), layoutGoldens.wide.layout.hash); - - const narrowConstraints = { width: { mode: 'at-most', size: 360 } }; - const narrowLayout = paragraph.layout(narrowConstraints); - // These requests used to carry `contextStart: 0, contextEnd: 56` — the whole run, which is the context the retained - // shape was produced with, so the shaper returned the glyphs it had already returned. The golden layout hashes below - // are unchanged by removing them, which is the proof. Reinstate them under a narrowed context. - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'narrow boundaries lay out from the retained shape'); - assert.deepEqual(reshapeRequests, []); - assertLayoutLines(narrowLayout, layoutGoldens.narrow.layout); - assert.equal(hashParagraphLayout(narrowLayout), layoutGoldens.narrow.layout.hash); - const exactHeight = paragraph.layout({ - ...narrowConstraints, - height: { mode: 'exactly', size: 200 }, - }); - assert.deepEqual(calls, { shape: 2, reshape: 0 }, 'height-only layout reuses positioned lines'); - assert.equal(exactHeight.height, 200); - assert.equal(exactHeight.glyphIds, narrowLayout.glyphIds); - const postLayoutInterfering = engine.create({ text: 'ffi', font: font.handle }); - assert.equal( - hashParagraphLayout(narrowLayout), - layoutGoldens.narrow.layout.hash, - 'positioned arrays own their Wasm results', - ); - - postLayoutInterfering.dispose(); - interfering.dispose(); - paragraph.dispose(); - assert.throws(() => paragraph.measure(), /disposed/); - shaper.dispose(); - font.dispose(); -}); - -test('resolves features and updates as one new broad-shape revision', async () => { - const { font, shaper } = await runtime(); - const calls = { shape: 0, reshape: 0 }; - const paragraph = createParagraphEngine({ shaper: observeShaper(shaper, calls) }).create({ - text: 'AVATAR', - font: font.handle, - style: { fontSize: 32, direction: 'ltr', language: 'en' }, - }); - assert.equal(paragraph.measure().width, 119.75); - assert.deepEqual(calls, { shape: 1, reshape: 0 }); - const defaultLayout = paragraph.layout(); - - paragraph.update({ - text: 'AVATAR', - font: font.handle, - style: { - fontSize: 32, - direction: 'ltr', - language: 'en', - features: [{ tag: 'kern', value: 0 }], - }, - }); - assert.equal(paragraph.measure().width, 129.5625); - assert.deepEqual(calls, { shape: 2, reshape: 0 }); - const unkernedLayout = paragraph.layout(); - assert.notEqual(unkernedLayout, defaultLayout); - assert.notDeepEqual([...unkernedLayout.x], [...defaultLayout.x]); - shaper.dispose(); - font.dispose(); -}); - -test('bounds retained paragraph layouts while keeping recent constraints hot', async () => { - const { font, shaper } = await runtime(); - const paragraph = createParagraphEngine({ shaper }).create({ - text: 'A short paragraph whose width changes repeatedly.', - font: font.handle, - }); - const constraints = Array.from({ length: 40 }, (_, index) => ({ - width: { mode: 'at-most', size: 120 + index }, - })); - const first = paragraph.layout(constraints[0]); - for (const constraint of constraints.slice(1)) paragraph.layout(constraint); - const mostRecent = paragraph.layout(constraints.at(-1)); - - assert.equal(paragraph.layout(constraints.at(-1)), mostRecent); - assert.notEqual(paragraph.layout(constraints[0]), first); - - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -test('validates spans, constraints, empty text, and lifecycle deterministically', async () => { - const { font, shaper } = await runtime(); - const engine = createParagraphEngine({ shaper }); - const empty = engine.create({ text: '', font: font.handle }); - assert.deepEqual(empty.measure(), { - width: 0, - height: 0, - contentWidth: 0, - contentHeight: 0, - firstBaseline: 0, - lastBaseline: 0, - overflowed: false, - }); - assert.deepEqual( - empty.measure({ - width: { mode: 'exactly', size: 20 }, - height: { mode: 'exactly', size: 10 }, - }), - { - width: 20, - height: 10, - contentWidth: 0, - contentHeight: 0, - firstBaseline: 0, - lastBaseline: 0, - overflowed: false, - }, - ); - assert.throws( - () => engine.create({ text: 'e\u0301', font: font.handle, spans: [{ start: 1, end: 2 }] }), - /extended-grapheme boundaries/, - ); - assert.throws(() => empty.measure({ width: { mode: 'at-most', size: Number.NaN } }), /finite/); - assert.throws(() => empty.measure({ width: { mode: 'invalid', size: 10 } }), /width mode/); - assert.throws(() => empty.measure({ maxLines: 0 }), /positive safe integer/); - assert.throws(() => empty.measure({ wrap: 'invalid' }), /wrap must/); - assert.throws(() => empty.measure({ align: 'invalid' }), /align must/); - assert.throws(() => empty.measure({ overflow: 'invalid' }), /overflow must/); - assert.throws(() => engine.create(null), /paragraph input must be an object/); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, style: null }), - /paragraph style must be an object/, - ); - assert.throws(() => engine.create({ text: 'a', font: font.handle, spans: null }), /paragraph spans must be an array/); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, spans: [null] }), - /paragraph span must be an object/, - ); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, style: { features: null } }), - /paragraph style features must be an array/, - ); - assert.throws(() => empty.measure(null), /paragraph constraints must be an object/); - assert.throws(() => empty.layout([]), /paragraph constraints must be an object/); - assert.throws(() => empty.measure({ width: null }), /width constraint must be an object/); - assert.throws( - () => engine.create({ text: 'a', font: font.handle, style: { language: null } }), - /language must be a string/, - ); - const emptyLayout = empty.layout(); - assert.deepEqual(emptyLayout, { - width: 0, - height: 0, - contentWidth: 0, - contentHeight: 0, - firstBaseline: 0, - lastBaseline: 0, - overflowed: false, - fontHandles: new Uint32Array(), - glyphFontSlots: new Uint16Array(), - glyphIds: new Uint16Array(), - clusters: new Uint32Array(), - glyphFontSizes: new Float32Array(), - x: new Float32Array(), - y: new Float32Array(), - glyphFlags: new Uint16Array(), - lineTextStarts: new Uint32Array(), - lineTextEnds: new Uint32Array(), - lineGlyphStarts: new Uint32Array(), - lineGlyphCounts: new Uint32Array(), - lineBaselines: new Float32Array(), - lineAdvances: new Float32Array(), - }); - const singleLine = engine.create({ text: 'a', font: font.handle }).measure(); - const trailingBreak = engine.create({ text: 'a\n', font: font.handle }).measure(); - assert.equal(trailingBreak.contentHeight, singleLine.contentHeight * 2); - assert.equal(trailingBreak.lastBaseline, singleLine.lastBaseline + singleLine.contentHeight); - const hardBreakEllipsis = engine.create({ text: 'a\nb', font: font.handle }).layout({ - maxLines: 1, - overflow: 'ellipsis', - }); - assert.deepEqual([...hardBreakEllipsis.lineTextEnds], [1]); - assert.equal(hardBreakEllipsis.clusters.at(-1), 1); - assert.ok([...hardBreakEllipsis.clusters].every((cluster) => cluster <= 1)); - shaper.dispose(); - font.dispose(); -}); - -test('sweeps nested spans without repeatedly resolving active styles', async () => { - const { font, shaper } = await runtime(); - let registrations = 0; - const observed = { - registry: shaper.registry, - registerFont: (registered) => { - registrations += 1; - return shaper.registerFont(registered); - }, - disposeFont: (registered) => shaper.disposeFont(registered), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => shaper.shapeBatch(request), - reshapeRanges: (request) => shaper.reshapeRanges(request), - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; - const text = 'a'.repeat(320); - const spans = Array.from({ length: 128 }, (_, index) => ({ - start: index, - end: text.length - index, - })); - const engine = createParagraphEngine({ shaper: observed }); - const nested = engine.create({ text, font: font.handle, spans }); - assert.equal(registrations, 1, 'the root style is registered once, not once per active span'); - const plain = engine.create({ text, font: font.handle }); - assert.deepEqual(nested.measure(), plain.measure()); - assert.deepEqual(nested.layout(), plain.layout()); - nested.dispose(); - plain.dispose(); - shaper.dispose(); - font.dispose(); -}); - -async function runtime() { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL('Inter-Regular.ttf', fontDirectory)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} - -function observeShaper(shaper, calls, reshapeRequests = []) { - return { - registry: shaper.registry, - registerFont: (font) => shaper.registerFont(font), - disposeFont: (font) => shaper.disposeFont(font), - analyzeBidi: (text, direction) => shaper.analyzeBidi(text, direction), - shapeBatch: (request) => { - calls.shape += 1; - return shaper.shapeBatch(request); - }, - reshapeRanges: (request) => { - calls.reshape += 1; - reshapeRequests.push({ ranges: request.ranges.map((range) => ({ ...range })) }); - return shaper.reshapeRanges(request); - }, - memoryReport: () => shaper.memoryReport(), - dispose: () => shaper.dispose(), - }; -} - -function expectedNaturalX(glyphs, scale) { - let cursor = 0; - return glyphs.map(({ xAdvance, xOffset }) => { - const positioned = Math.fround(cursor + xOffset * scale); - cursor += Math.abs(xAdvance) * scale; - return positioned; - }); -} - -function assertLayoutLines(layout, golden) { - assert.equal(layout.glyphIds.length, golden.glyphCount); - for (const key of [ - 'lineTextStarts', - 'lineTextEnds', - 'lineGlyphStarts', - 'lineGlyphCounts', - 'lineBaselines', - 'lineAdvances', - ]) { - assert.deepEqual([...layout[key]], golden[key]); - } -} - -async function readJson(url) { - return JSON.parse(await readFile(url, 'utf8')); -} diff --git a/packages/text/tests/integration/shaper-registration.test.mjs b/packages/text/tests/integration/shaper-registration.test.mjs index 9f70b2ef..93306a8f 100644 --- a/packages/text/tests/integration/shaper-registration.test.mjs +++ b/packages/text/tests/integration/shaper-registration.test.mjs @@ -2,7 +2,8 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { createRuntimeShaper, FontRegistry } from '@pmndrs/text'; +import { FontRegistry } from '@pmndrs/text'; +import { createRuntimeShaper } from '../../dist/shaper.js'; import { createFontBaker } from '@pmndrs/text-font-baker'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; import { fontBindingBytes, renderPolicyBytes, renderPolicyBytesFromPrograms } from '../support/engine-abi.mjs'; @@ -10,8 +11,6 @@ import { fontBindingBytes, renderPolicyBytes, renderPolicyBytesFromPrograms } fr const fixtureDirectory = new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/', import.meta.url); const shaperWasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); const shaperAbiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); -const shapingDirectory = new URL('../../../../apps/benchmarks/fixtures/shaping/inter-regular/', import.meta.url); - async function fixture() { const [source, bakerWasm, shaperWasm] = await Promise.all([ readFile(new URL('Inter-Regular.ttf', fixtureDirectory)), @@ -490,372 +489,3 @@ function engineStyleUpdateBytes( function align(value, alignment) { return Math.ceil(value / alignment) * alignment; } - -test('re-registering the same artifact creates a new lifecycle without reviving stale handles', async () => { - const { artifact, shaperWasm } = await fixture(); - const registry = new FontRegistry(); - const first = await registry.registerAsset(artifact); - const firstHandle = first.handle; - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - const request = { - textUtf16: utf16('A'), - features: [], - runs: [ - { - font: firstHandle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - language: 'en', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - const ranges = [{ run: 0, itemStart: 0, itemEnd: 1, contextStart: 0, contextEnd: 1, flags: 0x40 }]; - - shaper.registerFont(first); - assert.equal(shaper.shapeBatch(request).glyphIds.length, 1); - first.dispose(); - assert.throws(() => shaper.shapeBatch(request), /font handle .* is not registered/); - assert.throws(() => shaper.reshapeRanges({ ...request, ranges }), /font handle .* is not registered/); - - const second = await registry.registerAsset(artifact); - assert.notEqual(second.handle, firstHandle); - assert.equal(second.shapingHash, first.shapingHash); - shaper.registerFont(second); - const secondRequest = { - ...request, - runs: [{ ...request.runs[0], font: second.handle }], - }; - assert.equal(shaper.shapeBatch(secondRequest).glyphIds.length, 1); - assert.throws(() => shaper.shapeBatch(request), /font handle .* is not registered/); - - second.dispose(); - shaper.dispose(); -}); - -test('shapes every pinned HarfRust case exactly from GLB-extracted font data', async () => { - const [{ artifact, shaperWasm }, corpus, oracle] = await Promise.all([ - fixture(), - readJson(new URL('cases.json', shapingDirectory)), - readJson(new URL('harfrust.json', shapingDirectory)), - ]); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const corpusCases = new Map(corpus.cases.map((entry) => [entry.id, entry])); - - for (const expected of oracle.cases) { - const fixtureCase = corpusCases.get(expected.id); - assert.ok(fixtureCase, `missing corpus case ${expected.id}`); - const request = shapeRequest(font.handle, expected, fixtureCase); - assertShapedBatch(shaper.shapeBatch(request), font.handle, expected.glyphs); - assertShapedBatch( - shaper.reshapeRanges({ - ...request, - ranges: [ - { - run: 0, - itemStart: 0, - itemEnd: request.textUtf16.length, - contextStart: 0, - contextEnd: request.textUtf16.length, - flags: 0x40, - }, - ], - }), - font.handle, - expected.glyphs, - ); - } - - assert.equal(shaper.memoryReport().planCount, 3); - const first = oracle.cases[0]; - const firstCase = corpusCases.get(first.id); - shaper.shapeBatch(shapeRequest(font.handle, first, firstCase)); - assert.equal(shaper.memoryReport().planCount, 3, 'equivalent plans must be reused'); - font.dispose(); - assert.equal(shaper.memoryReport().planCount, 0, 'font disposal must release its plans'); - shaper.dispose(); -}); - -test('rejects malformed batch fields before or at the Wasm trust boundary', async () => { - const { artifact, shaperWasm } = await fixture(); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const base = { - textUtf16: utf16('A'), - features: [], - runs: [ - { - font: font.handle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - language: 'en', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - assert.throws( - () => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], script: 'Latin' }] }), - /exactly four bytes/, - ); - assert.throws(() => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], flags: 0x100 }] }), /unknown bits/); - assert.throws( - () => - shaper.reshapeRanges({ - ...base, - ranges: [{ run: 0, itemStart: 0, itemEnd: 2, contextStart: 0, contextEnd: 2, flags: 0 }], - }), - /outside its run context/, - ); - - for (const language of ['zh-hans', 'zh-hant', 'ja', 'ko']) { - assert.doesNotThrow(() => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language }] })); - } - assert.equal(shaper.memoryReport().planCount, 4, 'canonical CJK languages must remain distinct shape-plan inputs'); - for (const language of ['en\0us', 'en\nus', 'en--us']) { - assert.throws(() => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language }] }), /invalid batch request/); - } - assert.throws( - () => shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language: '' }] }), - /must encode to 1\.\.65535 UTF-8 bytes/, - ); - assert.throws( - () => - shaper.shapeBatch({ - ...base, - runs: [{ ...base.runs[0], language: 'a'.repeat(0x1_0000) }], - }), - /must encode to 1\.\.65535 UTF-8 bytes/, - ); - assert.equal(shaper.memoryReport().planCount, 4, 'invalid languages must not create plans'); - shaper.dispose(); - font.dispose(); -}); - -test("bounds each font's least-recently-used shape plans", async () => { - const { artifact, shaperWasm } = await fixture(); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const base = { - textUtf16: utf16('A'), - features: [], - runs: [ - { - font: font.handle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - - for (let index = 0; index < 80; index += 1) { - shaper.shapeBatch({ - ...base, - runs: [{ ...base.runs[0], language: `en-x-${index.toString(36)}` }], - }); - } - assert.equal(shaper.memoryReport().planCount, 64); - shaper.shapeBatch({ ...base, runs: [{ ...base.runs[0], language: 'en-x-0' }] }); - assert.equal(shaper.memoryReport().planCount, 64, 'revisiting an evicted plan must preserve the bound'); - - shaper.dispose(); - font.dispose(); -}); - -test('one coarse call shapes and reshapes multiple runs with absolute UTF-16 clusters', async () => { - const [{ artifact, shaperWasm }, oracle] = await Promise.all([ - fixture(), - readJson(new URL('harfrust.json', shapingDirectory)), - ]); - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - shaper.registerFont(font); - const expectedRuns = [ - oracle.cases.find(({ id }) => id === 'ligature-default'), - oracle.cases.find(({ id }) => id === 'combining-mark'), - ]; - assert.ok(expectedRuns.every(Boolean)); - const combinedText = expectedRuns.map(({ text }) => text).join(''); - const textUtf16 = utf16(combinedText); - let start = 0; - const runs = expectedRuns.map((expected) => { - const end = start + utf16(expected.text).length; - const run = { - font: font.handle, - textStart: start, - textEnd: end, - direction: expected.segment.direction, - script: expected.segment.script, - language: expected.segment.language, - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }; - start = end; - return run; - }); - const request = { textUtf16, runs, features: [] }; - const expectedGlyphs = expectedRuns.map((expected, run) => - expected.glyphs.map((glyph) => ({ ...glyph, cluster: glyph.cluster + runs[run].textStart })), - ); - assertMultiRun(shaper.shapeBatch(request), font.handle, expectedGlyphs); - assert.equal(shaper.memoryReport().planCount, 1); - assertMultiRun( - shaper.reshapeRanges({ - ...request, - ranges: runs.map((run, index) => ({ - run: index, - itemStart: run.textStart, - itemEnd: run.textEnd, - contextStart: run.textStart, - contextEnd: run.textEnd, - flags: run.flags, - })), - }), - font.handle, - expectedGlyphs, - ); - assert.equal(shaper.memoryReport().planCount, 1); - shaper.dispose(); - font.dispose(); -}); - -function shapeRequest(font, expected, fixtureCase) { - const textUtf16 = utf16(expected.text); - assert.equal(textUtf16.length, fixtureCase.utf16Length); - const features = expected.segment.features.map((source) => { - const match = /^(.{4})(?:=(\d+))?$/.exec(source); - assert.ok(match, `unsupported fixture feature ${source}`); - return { - tag: match[1], - value: match[2] === undefined ? 1 : Number(match[2]), - start: 0, - end: textUtf16.length, - }; - }); - return { - textUtf16, - features, - runs: [ - { - font, - textStart: 0, - textEnd: textUtf16.length, - direction: expected.segment.direction, - script: expected.segment.script, - language: expected.segment.language, - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: features.length, - }, - ], - }; -} - -function assertShapedBatch(actual, fontHandle, glyphs) { - assert.deepEqual([...actual.fontHandles], [fontHandle]); - assert.deepEqual([...actual.runFontSlots], [0]); - assert.deepEqual([...actual.runGlyphStarts], [0]); - assert.deepEqual([...actual.runGlyphCounts], [glyphs.length]); - assert.deepEqual( - { - glyphIds: [...actual.glyphIds], - clusters: [...actual.clusters], - xAdvances: [...actual.xAdvances], - yAdvances: [...actual.yAdvances], - xOffsets: [...actual.xOffsets], - yOffsets: [...actual.yOffsets], - glyphFlags: [...actual.glyphFlags], - }, - { - glyphIds: glyphs.map(({ glyphId }) => glyphId), - clusters: glyphs.map(({ cluster }) => cluster), - xAdvances: glyphs.map(({ xAdvance }) => xAdvance), - yAdvances: glyphs.map(({ yAdvance }) => yAdvance), - xOffsets: glyphs.map(({ xOffset }) => xOffset), - yOffsets: glyphs.map(({ yOffset }) => yOffset), - glyphFlags: glyphs.map(({ flags }) => flags), - }, - ); -} - -function assertMultiRun(actual, fontHandle, runs) { - const glyphs = runs.flat(); - const starts = []; - let start = 0; - for (const run of runs) { - starts.push(start); - start += run.length; - } - assert.deepEqual([...actual.fontHandles], [fontHandle]); - assert.deepEqual( - [...actual.runFontSlots], - runs.map(() => 0), - ); - assert.deepEqual([...actual.runGlyphStarts], starts); - assert.deepEqual( - [...actual.runGlyphCounts], - runs.map((run) => run.length), - ); - assert.deepEqual( - [...actual.glyphIds], - glyphs.map(({ glyphId }) => glyphId), - ); - assert.deepEqual( - [...actual.clusters], - glyphs.map(({ cluster }) => cluster), - ); - assert.deepEqual( - [...actual.xAdvances], - glyphs.map(({ xAdvance }) => xAdvance), - ); - assert.deepEqual( - [...actual.yAdvances], - glyphs.map(({ yAdvance }) => yAdvance), - ); - assert.deepEqual( - [...actual.xOffsets], - glyphs.map(({ xOffset }) => xOffset), - ); - assert.deepEqual( - [...actual.yOffsets], - glyphs.map(({ yOffset }) => yOffset), - ); - assert.deepEqual( - [...actual.glyphFlags], - glyphs.map(({ flags }) => flags), - ); -} - -function utf16(value) { - return Uint16Array.from({ length: value.length }, (_, index) => value.charCodeAt(index)); -} - -async function readJson(url) { - return JSON.parse(await readFile(url, 'utf8')); -} diff --git a/packages/text/tests/integration/text-runtime-v1.test.mjs b/packages/text/tests/integration/text-runtime-v1.test.mjs deleted file mode 100644 index 72a6396b..00000000 --- a/packages/text/tests/integration/text-runtime-v1.test.mjs +++ /dev/null @@ -1,385 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { - createFontStack, - createRuntimeShaper, - createTextRuntime, - FontLeaseError, - FontRegistry, - span, - txt, -} from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; -import { createTypeGpuTextEngine, defineTypeGpuRasterProgram } from '@pmndrs/text/typegpu'; - -const interUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); -const devanagariUrl = new URL( - '../../../../apps/benchmarks/fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb', - import.meta.url, -); - -test('target-v1 runtime loads, falls back, batches, recovers capacity, and retains ownership', async () => { - const [interBytes, devanagariBytes] = await Promise.all([readFile(interUrl), readFile(devanagariUrl)]); - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const [inter, devanagari] = await Promise.all([ - runtime.loadFont({ - input: { baked: dataUrl(interBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - runtime.loadFont({ - input: { baked: dataUrl(devanagariBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - ]); - const uiFont = createFontStack(inter, devanagari); - const warning = span(devanagari, { color: '#ff00ff', fontSize: 18 }); - const labels = runtime.createParagraphBatch({ - technique: bitmap, - capacity: { size: 32, policy: 'fixed' }, - }); - const label = labels.add({ - font: uiFont, - text: txt`Latin and fallback: देवनागरी; ${warning`styled`}`, - }); - - assert.throws(() => inter.dispose(), FontLeaseError); - assert.equal(runtime.hasPendingChanges, true); - const first = runtime.update(); - assert.equal(first.revision, 1); - assert.equal(first.paragraphBatches.length, 1); - assert.equal(labels.current.paragraphs.length, 1); - assert.ok(labels.current.glyphBatches.length >= 2, 'fallback fonts must become separate physical glyph batches'); - assert.ok(labels.current.glyphRuns.length >= 2, 'fallback ordering must remain explicit in ordered runs'); - const firstInterKey = labels.current.glyphBatches.find((batch) => batch.font === inter).key; - assert.equal( - [...labels.current.paragraphs[0].layout.glyphIds].includes(0), - false, - 'available fallback glyphs must replace primary .notdef glyphs', - ); - assert.equal(runtime.update(), first, 'a clean synchronous update must preserve revision identity'); - - const targetEvents = []; - const attachment = labels.attach({ - technique: bitmap, - stage(previous, next) { - targetEvents.push(['stage', previous?.sourceRevision, next.revision]); - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit() { - targetEvents.push(['commit', next.revision]); - return { - sourceRevision: next.revision, - dispose() { - targetEvents.push(['retire', next.revision]); - }, - }; - }, - abort() { - targetEvents.push(['abort', next.revision]); - }, - }, - }; - }, - dispose() { - targetEvents.push(['dispose-target']); - }, - }); - assert.deepEqual(targetEvents, [], 'attachment observation must not stage engine work'); - attachment.prepare(); - assert.equal(attachment.candidate.sourceRevision, 1); - attachment.commit(); - assert.equal(attachment.current.sourceRevision, 1); - - label.text = 'This replacement is intentionally longer than the fixed capacity.'; - label.text = 'The final desired replacement still exceeds thirty-two glyphs.'; - let overflow; - try { - runtime.update(); - assert.fail('fixed capacity should reject the prepared generation'); - } catch (error) { - overflow = error; - } - assert.equal(overflow.kind, 'capacity-exceeded'); - assert.equal(overflow.batch, labels); - assert.ok(overflow.required > 32); - assert.equal(labels.preparationError, overflow); - assert.equal(labels.hasPendingChanges, false, 'an unchanged failed generation must be latched'); - assert.equal(labels.current.revision, 1, 'failed preparation must preserve the complete prior revision'); - - labels.setCapacity({ size: overflow.required, policy: 'fixed' }); - const recovered = runtime.update(); - assert.equal(recovered.revision, 2); - assert.equal(labels.current.revision, 2); - assert.equal(labels.has(label), true); - assert.equal(labels.preparationError, undefined); - const resizedInterKey = labels.current.glyphBatches.find((batch) => batch.font === inter).key; - assert.notEqual(resizedInterKey, firstInterKey, 'explicit capacity replacement must retire physical keys'); - assert.equal(attachment.source.revision, 2); - assert.equal(attachment.current.sourceRevision, 1, 'core publication must not mutate live target state'); - attachment.prepare(); - attachment.commit(); - assert.equal(attachment.current.sourceRevision, 2); - assert.ok(targetEvents.some((event) => event[0] === 'retire' && event[1] === 1)); - - const beforePaintStorage = labels.current.glyphBatches.find((batch) => batch.font === inter).storage; - label.paint = { color: '#00ff00' }; - runtime.update(); - assert.equal( - labels.current.glyphBatches.find((batch) => batch.font === inter).key, - resizedInterKey, - 'compatible content updates must preserve physical batch-key identity', - ); - - label.order = 10; - runtime.update(); - assert.ok( - labels.current.glyphBatches.every((batch) => batch.dirtyRanges.length === 0), - 'order-only publication must not report canonical instance uploads', - ); - assert.equal( - labels.current.glyphBatches.find((batch) => batch.font === inter).storage, - beforePaintStorage, - 'compatible batches must alternate retained staging storage instead of allocating every revision', - ); - - const snapshot = label.snapshotGlyphs(); - const movedX = snapshot.displayedX.slice(); - movedX[0] += 4; - label.setGlyphOrigins({ topology: snapshot.topology, x: movedX, y: snapshot.displayedY }); - runtime.update(); - assert.equal(label.snapshotGlyphs().displayedX[0], movedX[0]); - const movedBatch = labels.current.glyphBatches.find((batch) => batch.font === inter); - assert.deepEqual(movedBatch.dirtyRanges, [{ start: 0, count: 1 }]); - - label.set({ text: 'Snapshot A', spans: [] }); - const preparingA = runtime.updateAsync(); - label.text = 'Desired state B remains pending'; - const outcomeA = await preparingA; - assert.equal(outcomeA.status, 'published'); - assert.equal(label.committed.layout.glyphIds.length, 10, 'async preparation must use its call-time snapshot'); - assert.equal(labels.hasPendingChanges, true, 'later desired state must remain dirty after publishing the snapshot'); - runtime.update(); - assert.equal(label.committed.layout.glyphIds.length, 31); - - label.text = 'Superseded A'; - const supersededA = runtime.updateAsync(); - label.text = 'Synchronous B'; - runtime.update(); - const supersededOutcome = await supersededA; - assert.equal(supersededOutcome.status, 'superseded'); - assert.equal(supersededOutcome.byRevision, supersededOutcome.revision + 1); - assert.equal(label.committed.layout.glyphIds.length, 13); - - label.text = 'Callback publication'; - const progress = []; - let callbackWasSynchronous = true; - const callbackResult = await new Promise((resolve) => { - const returned = runtime.updateAsync({ onProgress: (value) => progress.push(value) }, (result) => { - assert.equal(callbackWasSynchronous, false); - resolve(result); - }); - assert.equal(returned, undefined, 'callback form must expose no Promise'); - callbackWasSynchronous = false; - }); - assert.equal(callbackResult.ok, true); - assert.equal(callbackResult.value.status, 'published'); - assert.deepEqual( - progress.map(({ preparedParagraphs, totalParagraphs }) => [preparedParagraphs, totalParagraphs]), - [ - [0, 1], - [1, 1], - ], - ); - - const aborted = new AbortController(); - aborted.abort('not needed'); - const abortedRevision = runtime.current; - const abortedOutcome = await runtime.updateAsync({ signal: aborted.signal }); - assert.equal(abortedOutcome.status, 'aborted'); - assert.equal(abortedOutcome.reason, 'not needed'); - assert.equal(runtime.current, abortedRevision, 'aborted preparation must not publish'); - - labels.setCapacity({ size: 1, policy: 'fixed' }); - label.text = 'Capacity failure'; - await assert.rejects(runtime.updateAsync(), (error) => { - assert.equal(error.kind, 'capacity-exceeded'); - assert.equal(error.batch, labels); - return true; - }); - labels.setCapacity({ size: 64, policy: 'fixed' }); - runtime.update(); - - labels.dispose(); - assert.equal(label.disposed, true); - assert.deepEqual(targetEvents.at(-1), ['dispose-target']); - inter.dispose(); - devanagari.dispose(); - runtime.dispose(); -}); - -test('the maintained TypeGPU engine retains core handles and delegates exact target ownership', async () => { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const root = { device: {} }; - const events = []; - const states = new Map(); - const program = defineTypeGpuRasterProgram({ - technique: bitmap, - createTarget(options) { - assert.equal(options.root, root); - return { - root, - technique: bitmap, - setParagraphState(paragraph, state) { - events.push(['state', paragraph, state?.visible]); - if (state === undefined) states.delete(paragraph); - else states.set(paragraph, state); - }, - stage(previous, next) { - events.push(['stage', previous?.sourceRevision, next.revision]); - return { - status: 'ready', - stage: { - sourceRevision: next.revision, - commit() { - return { - sourceRevision: next.revision, - draws: Object.freeze([{ glyphs: next.glyphRuns.reduce((sum, run) => sum + run.count, 0) }]), - dispose() { - events.push(['retire', next.revision]); - }, - }; - }, - abort() {}, - }, - }; - }, - encode(_pass, revision, frame) { - events.push(['encode', revision.sourceRevision, revision.draws[0]?.glyphs, frame.pixelRatio]); - }, - dispose() { - events.push(['dispose-target']); - }, - }; - }, - dispose() {}, - }); - const engine = await createTypeGpuTextEngine({ - root, - colorFormat: 'rgba8unorm', - runtime: { registry, shaper }, - }); - const inter = await engine.loadFont({ - input: { baked: dataUrl(await readFile(interUrl)) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); - const labels = engine.createParagraphBatch({ technique: bitmap, program }); - const label = labels.add({ font: inter, text: 'TypeGPU' }); - const identity = label; - - engine.update(); - labels.encode( - {}, - { - viewProjection: Float32Array.from([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]), - viewport: [320, 200], - pixelRatio: 2, - }, - ); - assert.deepEqual(events.at(-1), ['encode', 1, 7, 2]); - assert.equal(labels.current.sourceRevision, 1); - - label.setTransform([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 0, 1]); - label.visible = false; - assert.equal(label, identity, 'renderer-owned transform changes must preserve the core paragraph handle'); - assert.equal(states.get(label.id).transform[12], 10); - assert.equal(states.get(label.id).visible, false); - assert.equal(engine.current.revision, 1, 'renderer-owned state must not schedule shaping'); - - label.text = 'TypeGPU retained'; - engine.update(); - labels.encode( - {}, - { - viewProjection: Float32Array.from([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]), - viewport: [320, 200], - pixelRatio: 1, - }, - ); - assert.equal(labels.current.sourceRevision, 2); - assert.ok(events.some((event) => event[0] === 'retire' && event[1] === 1)); - - labels.dispose(); - assert.equal(label.disposed, true); - assert.equal(states.size, 0); - inter.dispose(); - engine.dispose(); - assert.deepEqual(events.at(-1), ['dispose-target']); -}); - -function dataUrl(bytes) { - return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; -} - -const fontAwesomeUrl = new URL( - '../../../../apps/benchmarks/fixtures/rendering/font-awesome-free-6.7.2-bitmap-16.font.glb', - import.meta.url, -); - -/** - * Font fallback inspects shaped glyph identity, and the shaping request appends one ellipsis run per source run - * clustered past the end of the text so overflow can be measured. Those runs are not glyphs of the paragraph, and a - * primary font without U+2026 shapes them to .notdef. Substituting a font for one authored a span outside the text, - * which failed preparation for the whole batch rather than for one paragraph. - * - * The icon-font-first stack is the ordinary configuration that reaches this: Font Awesome carries neither the Latin - * text nor the ellipsis. The fallback must land in the middle of the text, because a stack whose substitution happens - * to end at the final cluster is the one arrangement where the stray entry is dropped harmlessly. - */ -test('font fallback ignores the ellipsis runs shaped past the end of the text', async () => { - const [awesomeBytes, interBytes] = await Promise.all([readFile(fontAwesomeUrl), readFile(interUrl)]); - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - const runtime = await createTextRuntime({ registry, shaper }); - const [awesome, inter] = await Promise.all([ - runtime.loadFont({ - input: { baked: dataUrl(awesomeBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - runtime.loadFont({ - input: { baked: dataUrl(interBytes) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }), - ]); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - const paragraph = batch.add({ font: createFontStack(awesome, inter), text: 'hello\nworld' }); - - const first = runtime.update(); - assert.equal(first.preparationError, undefined, 'a primary font without U+2026 must still prepare'); - assert.equal(batch.current.paragraphs[0].layout.glyphIds.length, 10); - - // The retained paragraph re-enters preparation here, so the stray selection would reappear on the second pass. - paragraph.style = { fontSize: 18 }; - assert.equal(runtime.update().preparationError, undefined, 'reparation must not accumulate a stray fallback span'); - assert.equal(batch.current.paragraphs[0].layout.glyphIds.length, 10); - - batch.dispose(); - runtime.dispose(); - awesome.dispose(); - inter.dispose(); -}); diff --git a/packages/text/tests/integration/text-spans.test.mjs b/packages/text/tests/integration/text-spans.test.mjs deleted file mode 100644 index a3bd223d..00000000 --- a/packages/text/tests/integration/text-spans.test.mjs +++ /dev/null @@ -1,567 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; -import { gunzipSync } from 'node:zlib'; - -import { - createFontStack, - createRuntimeShaper, - createTextRuntime, - FontRegistry, - span, - SpanNestingError, - txt, -} from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/three/bitmap'; -import { msdf } from '@pmndrs/text/three/msdf'; -import { defineTextMaterial, Text, TextGroup } from '@pmndrs/text/three'; -import * as THREE from 'three/webgpu'; - -const interUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); -const interMsdfUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-mtsdf.font.glb.gz', import.meta.url); -const devanagariUrl = new URL( - '../../../../apps/benchmarks/fixtures/rendering/noto-sans-devanagari-bitmap-16.font.glb', - import.meta.url, -); - -const BLUE = [0, 0, 1, 1]; -const RED = [1, 0, 0, 1]; -const GREEN = [0, 1, 0, 1]; -const WHITE = [1, 1, 1, 1]; - -test('a style-only span inherits the surrounding font while overriding its own shaping style', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const emphasis = span({ fontSize: 24 }); - const literal = txt`Score ${emphasis`99`} pts`; - assert.equal(literal.text, 'Score 99 pts'); - assert.deepEqual(literal.spans, [{ start: 6, end: 8, style: { fontSize: 24 } }]); - - const formatted = batch.add({ font: inter, text: literal, paint: { color: '#0000ff' } }); - const unformatted = batch.add({ font: inter, text: 'Score 99 pts' }); - runtime.update(); - - const layout = formatted.committed.layout; - assert.deepEqual([...layout.fontHandles], [inter.font.handle], 'a style-only span must select no further font'); - assert.deepEqual( - [...layout.glyphFontSlots], - Array.from({ length: 12 }, () => 0), - ); - assert.deepEqual( - [...layout.glyphIds], - [...unformatted.committed.layout.glyphIds], - 'the span must shape from the surrounding font, so its glyph ids must match the unformatted paragraph', - ); - assert.equal(layout.glyphIds.includes(0), false, 'the inherited font must resolve every glyph'); - assert.deepEqual([...layout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 24, 24, 16, 16, 16, 16]); - - const run = runFor(batch, formatted); - assert.equal(run.count, 10, 'the ten visible glyphs of "Score 99 pts" must become one contiguous run'); - assert.deepEqual( - glyphColors(batch, run), - Array.from({ length: 10 }, () => BLUE), - 'a span that states no paint must inherit the paragraph paint instead of resetting it', - ); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('nested spans compose inner formatting over the enclosing span across exact UTF-16 ranges', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const outer = span({ fontSize: 24, color: '#ff0000' }); - const inner = span({ fontSize: 12, color: '#00ff00' }); - const literal = txt`a${outer`b${inner`c`}d`}e`; - assert.equal(literal.text, 'abcde'); - assert.deepEqual(literal.spans, [ - { start: 1, end: 4, style: { fontSize: 24 }, paint: { color: '#ff0000' } }, - { start: 2, end: 3, style: { fontSize: 12 }, paint: { color: '#00ff00' } }, - ]); - - const nested = batch.add({ font: inter, text: literal }); - runtime.update(); - assert.deepEqual( - [...nested.committed.layout.glyphFontSizes], - [16, 24, 12, 24, 16], - 'the inner span must override the enclosing span on the range they share', - ); - assert.deepEqual(glyphColors(batch, runFor(batch, nested)), [WHITE, RED, GREEN, RED, WHITE]); - - // An astral character occupies two UTF-16 code units, so composed ranges are - // only correct when they count code units rather than code points. - const astral = txt`🎯${inner`hit`}`; - assert.equal(astral.text, '🎯hit'); - assert.equal(astral.text.length, 5); - assert.deepEqual(astral.spans, [{ start: 2, end: 5, style: { fontSize: 12 }, paint: { color: '#00ff00' } }]); - - const surrogate = batch.add({ font: inter, text: astral }); - runtime.update(); - assert.deepEqual([...surrogate.committed.layout.clusters], [0, 2, 3, 4]); - assert.deepEqual([...surrogate.committed.layout.glyphFontSizes], [16, 12, 12, 12]); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a tuple-extended format binds the same span as the direct call', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const alertStyle = { color: '#ff0000', fontSize: 24 }; - const alertFormat = [inter, alertStyle]; - const spread = span(...alertFormat); - const direct = span(inter, alertStyle); - - const spreadLiteral = txt`Alert ${spread`now`}`; - const directLiteral = txt`Alert ${direct`now`}`; - assert.deepEqual(spreadLiteral, directLiteral); - assert.deepEqual(spreadLiteral.spans, [ - { start: 6, end: 9, font: inter, style: { fontSize: 24 }, paint: { color: '#ff0000' } }, - ]); - - const spreadParagraph = batch.add({ font: inter, text: spreadLiteral }); - const directParagraph = batch.add({ font: inter, text: directLiteral }); - runtime.update(); - - const spreadLayout = spreadParagraph.committed.layout; - const directLayout = directParagraph.committed.layout; - assert.deepEqual([...spreadLayout.glyphIds], [...directLayout.glyphIds]); - assert.deepEqual([...spreadLayout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 24, 24, 24]); - assert.deepEqual([...spreadLayout.glyphFontSizes], [...directLayout.glyphFontSizes]); - assert.equal(spreadLayout.width, directLayout.width); - assert.deepEqual( - glyphColors(batch, runFor(batch, spreadParagraph)), - glyphColors(batch, runFor(batch, directParagraph)), - ); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a plain string stays valid wherever a formatted literal is accepted', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const emphasis = span({ fontSize: 24 }); - const interpolated = txt`before ${'middle'} after`; - assert.equal(interpolated.text, 'before middle after'); - assert.deepEqual(interpolated.spans, [], 'an interpolated string carries no formatting of its own'); - - const insideSpan = txt`x${emphasis`plain ${'value'}`}y`; - assert.equal(insideSpan.text, 'xplain valuey'); - assert.deepEqual(insideSpan.spans, [{ start: 1, end: 12, style: { fontSize: 24 } }]); - - const literalParagraph = batch.add({ font: inter, text: txt`Score ${emphasis`99`} pts` }); - const stringParagraph = batch.add({ font: inter, text: 'Score 99 pts' }); - runtime.update(); - assert.deepEqual( - [...stringParagraph.committed.layout.glyphFontSizes], - Array.from({ length: 12 }, () => 16), - ); - assert.deepEqual([...literalParagraph.committed.layout.glyphIds], [...stringParagraph.committed.layout.glyphIds]); - - // Replacement content owns its formatting, so a plain string must clear the - // spans of the literal it replaces rather than reinterpret them. - literalParagraph.text = 'Tally'; - runtime.update(); - assert.deepEqual(literalParagraph.spans, []); - assert.equal(literalParagraph.committed.layout.glyphIds.length, 5); - assert.deepEqual([...literalParagraph.committed.layout.glyphFontSizes], [16, 16, 16, 16, 16]); - assert.equal(batch.preparationError, undefined); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('Three Text shapes and draws a formatted literal through the real render lifecycle', async () => { - const runtime = await createBitmapRuntime(); - const [inter, devanagari] = await Promise.all([loadInter(runtime), loadDevanagari(runtime)]); - - const scene = new THREE.Scene(); - const group = new TextGroup(); - const warning = span(devanagari, { color: '#ff00ff', fontSize: 18 }); - const label = new Text({ font: createFontStack(inter, devanagari), text: txt`Alert ${warning`देव`}!` }); - group.add(label); - scene.add(group); - - assert.equal(label.bound, false, 'constructing a formatted Text must not shape eagerly'); - scene.updateMatrixWorld(); - assert.equal(label.bound, true); - assert.equal(label.text, 'Alert देव!'); - assert.deepEqual( - label.spans.map(({ start, end, font, style, paint }) => ({ start, end, font, style, paint })), - [{ start: 6, end: 9, font: devanagari, style: { fontSize: 18 }, paint: { color: '#ff00ff' } }], - ); - - assert.equal(label.layout, undefined, 'the render lifecycle must not request layout readback'); - assert.equal(group.error, undefined); - const draws = group.children.filter((child) => child.isMesh); - assert.deepEqual( - draws.map((mesh) => [mesh.userData.pmndrsTextRunStart, mesh.geometry.instanceCount]), - [ - [0, 5], - [0, 3], - [5, 1], - ], - 'the span font must split the drawn glyph ranges around the surrounding font', - ); - assert.equal(group.error, undefined); - - // A plain string replaces the literal and its spans through the same setter. - label.text = 'Alert'; - scene.updateMatrixWorld(); - assert.deepEqual(label.spans, []); - assert.equal(label.layout, undefined); - assert.equal(group.error, undefined); - assert.deepEqual( - group.children.filter((child) => child.isMesh).map((mesh) => mesh.geometry.instanceCount), - [5], - ); - - group.dispose(); - label.removeFromParent(); - label.dispose(); - inter.dispose(); - devanagari.dispose(); - runtime.dispose(); -}); - -test('Three realizes mixed raster techniques from one Rust-planned paragraph', async () => { - const runtime = await createBitmapRuntime(); - const [bitmapInter, msdfInter] = await Promise.all([loadInter(runtime), loadMsdfInter(runtime)]); - const techniques = []; - const material = defineTextMaterial((context) => { - techniques.push(context.technique); - return context.createDefaultMaterial(); - }); - const label = new Text({ - font: createFontStack(bitmapInter, msdfInter), - text: 'AB', - spans: [{ start: 1, end: 2, font: msdfInter }], - material, - }); - const group = new TextGroup(); - group.add(label); - const scene = new THREE.Scene(); - scene.add(group); - scene.updateMatrixWorld(); - - assert.equal(group.error, undefined); - assert.deepEqual(new Set(techniques), new Set([bitmap.id, msdf.id])); - assert.equal( - group.children.filter((child) => child.isMesh).length, - 2, - 'the Rust plan must split one paragraph into renderer draws for both selected techniques', - ); - - group.dispose(); - label.dispose(); - bitmapInter.dispose(); - msdfInter.dispose(); - runtime.dispose(); -}); - -test('a span keeps every surrounding paint property it does not state', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadMsdfInter(runtime); - const batch = runtime.createParagraphBatch({ technique: msdf }); - - // Each span states exactly one paint property, so the three it omits must - // survive from the paragraph. "d" carries no span and fixes the inherited - // values the other three are measured against. - const paragraph = batch.add({ - font: inter, - text: 'abcd', - paint: { - color: '#ff0000', - opacity: 0.5, - outline: { color: '#00ff00', width: 1 }, - shadow: { color: '#0000ff', offset: [1, 2] }, - }, - spans: [ - { start: 0, end: 1, paint: { color: '#ffffff' } }, - { start: 1, end: 2, paint: { opacity: 1 } }, - { start: 2, end: 3, paint: { outline: { color: '#ffffff', width: 1 } } }, - ], - }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - - const painted = msdfGlyphPaint(batch, runFor(batch, paragraph)); - assert.equal(painted.length, 4); - assert.deepEqual(painted[3], { fill: [1, 0, 0, 0.5], outline: [0, 1, 0, 0.5], shadow: [0, 0, 1, 0.5] }); - assert.deepEqual( - painted[0], - { fill: [1, 1, 1, 0.5], outline: [0, 1, 0, 0.5], shadow: [0, 0, 1, 0.5] }, - 'a colour-only span must keep the surrounding opacity, outline, and shadow', - ); - assert.deepEqual( - painted[1], - { fill: [1, 0, 0, 1], outline: [0, 1, 0, 1], shadow: [0, 0, 1, 1] }, - 'an opacity-only span must keep the surrounding colour, outline, and shadow and re-apply its own opacity to each', - ); - assert.deepEqual( - painted[2], - { fill: [1, 0, 0, 0.5], outline: [1, 1, 1, 0.5], shadow: [0, 0, 1, 0.5] }, - 'an outline-only span must keep the surrounding colour, opacity, and shadow', - ); - - // A dropped outline or shadow would leave a zero width and a zero offset, so - // the geometric parts are asserted as well as the colours. - const physical = batchFor(batch, runFor(batch, paragraph)); - const first = runFor(batch, paragraph).start; - const widths = [...physical.storage.outlineWidths.slice(first, first + 4)]; - assert.equal( - widths.every((value) => value === widths[0] && value > 0), - true, - `outline widths were ${widths}`, - ); - const offsets = [...physical.storage.shadowOffsets.slice(first * 2, (first + 4) * 2)]; - assert.deepEqual(offsets, [ - offsets[0], - offsets[1], - offsets[0], - offsets[1], - offsets[0], - offsets[1], - offsets[0], - offsets[1], - ]); - assert.equal(offsets[0] > 0 && offsets[1] > 0, true, `shadow offsets were ${offsets}`); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('nested spans merge paint per property from the outermost inward', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const outer = span({ color: '#00ff00' }); - const inner = span({ opacity: 0.25 }); - const literal = txt`a${outer`b${inner`c`}d`}e`; - assert.deepEqual(literal.spans, [ - { start: 1, end: 4, paint: { color: '#00ff00' } }, - { start: 2, end: 3, paint: { opacity: 0.25 } }, - ]); - - const paragraph = batch.add({ font: inter, text: literal, paint: { color: '#ff0000', opacity: 0.5 } }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - assert.deepEqual( - glyphColors(batch, runFor(batch, paragraph)), - [ - [1, 0, 0, 0.5], - [0, 1, 0, 0.5], - [0, 1, 0, 0.25], - [0, 1, 0, 0.5], - [1, 0, 0, 0.5], - ], - 'the innermost span states only opacity, so it must inherit the colour of the span enclosing it', - ); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('spans that overlap without nesting are rejected, and nesting order is not load-bearing', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - assert.throws( - () => - batch.add({ - font: inter, - text: 'abcdef', - spans: [ - { start: 0, end: 4, paint: { color: '#ff0000' } }, - { start: 2, end: 6, paint: { color: '#00ff00' } }, - ], - }), - (error) => { - assert.equal(error instanceof SpanNestingError, true, `expected a SpanNestingError, received ${error}`); - assert.equal(error.name, 'SpanNestingError'); - assert.deepEqual(error.enclosing, { index: 0, start: 0, end: 4 }); - assert.deepEqual(error.overlapping, { index: 1, start: 2, end: 6 }); - assert.equal( - error.message, - 'paragraph spans must be disjoint or nested: span 0 [0, 4) partially overlaps span 1 [2, 6)', - ); - return true; - }, - ); - - // Precedence follows containment, so a producer that emits a contained span - // before the span enclosing it still resolves innermost-first. - const reversed = batch.add({ - font: inter, - text: 'abcdef', - spans: [ - { start: 2, end: 4, paint: { color: '#00ff00' } }, - { start: 0, end: 6, paint: { color: '#ff0000' } }, - ], - }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - assert.deepEqual(glyphColors(batch, runFor(batch, reversed)), [RED, RED, GREEN, GREEN, RED, RED]); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a span font size re-shapes advances and moves where a line wraps', async () => { - const runtime = await createBitmapRuntime(); - const inter = await loadInter(runtime); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const contentBox = { width: { mode: 'at-most', size: 120 }, wrap: 'word' }; - const plain = batch.add({ font: inter, text: 'wide wide wide', contentBox }); - const emphasis = span({ fontSize: 32 }); - const styled = batch.add({ font: inter, text: txt`${emphasis`wide`} wide wide`, contentBox }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - - const plainLayout = plain.committed.layout; - const styledLayout = styled.committed.layout; - assert.deepEqual([...plainLayout.lineGlyphCounts], [14], 'the unformatted paragraph must fit on one line'); - assert.deepEqual( - [...styledLayout.lineGlyphCounts], - [10, 4], - 'a span font size must change line breaking, not only glyph scale', - ); - assert.deepEqual([...plainLayout.lineAdvances], [117.28125]); - assert.deepEqual([...styledLayout.lineAdvances], [117.28125, 36.09375]); - - // Shaped advances inside the span double with its font size, and the text - // after the span moves by the difference rather than staying put. - assert.deepEqual([...plainLayout.x.slice(0, 4)], [0, 13.09375, 16.96875, 26.765625]); - assert.deepEqual([...styledLayout.x.slice(0, 4)], [0, 26.1875, 33.9375, 53.53125]); - assert.equal(plainLayout.x[4], 36.09375); - assert.equal(styledLayout.x[4], 72.1875, 'the glyph after the span must shift by the re-shaped advance'); - assert.deepEqual([...styledLayout.glyphFontSizes.slice(0, 5)], [32, 32, 32, 32, 16]); - - batch.dispose(); - inter.dispose(); - runtime.dispose(); -}); - -test('a nested style-only span shapes with the font its enclosing span selected', async () => { - const runtime = await createBitmapRuntime(); - const [inter, devanagari] = await Promise.all([loadInter(runtime), loadDevanagari(runtime)]); - const batch = runtime.createParagraphBatch({ technique: bitmap }); - - const script = span(devanagari); - const emphasis = span({ fontSize: 24 }); - const literal = txt`Alert ${script`दे${emphasis`व`}`}!`; - assert.equal(literal.text, 'Alert देव!'); - assert.deepEqual(literal.spans, [ - { start: 6, end: 9, font: devanagari }, - { start: 8, end: 9, style: { fontSize: 24 } }, - ]); - - const paragraph = batch.add({ font: inter, text: literal }); - runtime.update(); - assert.equal(batch.preparationError, undefined); - - const layout = paragraph.committed.layout; - assert.deepEqual([...layout.fontHandles], [inter.font.handle, devanagari.font.handle]); - assert.deepEqual( - [...layout.glyphFontSlots], - [0, 0, 0, 0, 0, 0, 1, 1, 1, 0], - 'the inner span states no font, so it must shape from the font its enclosing span selected', - ); - assert.deepEqual([...layout.glyphFontSizes], [16, 16, 16, 16, 16, 16, 16, 16, 24, 16]); - assert.deepEqual([...layout.clusters], [0, 1, 2, 3, 4, 5, 6, 6, 8, 9]); - assert.equal(layout.glyphIds.includes(0), false, 'the inherited span font must resolve every glyph'); - - batch.dispose(); - inter.dispose(); - devanagari.dispose(); - runtime.dispose(); -}); - -async function createBitmapRuntime() { - const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); - return createTextRuntime({ registry, shaper }); -} - -function loadInter(runtime) { - return loadBitmapFont(runtime, interUrl); -} - -function loadDevanagari(runtime) { - return loadBitmapFont(runtime, devanagariUrl); -} - -async function loadBitmapFont(runtime, url) { - return runtime.loadFont({ - input: { baked: dataUrl(await readFile(url)) }, - raster: { technique: bitmap, options: { strikes: [16] } }, - }); -} - -async function loadMsdfInter(runtime) { - return runtime.loadFont({ - input: { baked: dataUrl(gunzipSync(await readFile(interMsdfUrl))) }, - raster: { technique: msdf }, - }); -} - -function runFor(batch, paragraph) { - const run = batch.current.glyphRuns.find((entry) => entry.paragraph === paragraph.id); - if (run === undefined) throw new Error('the published revision has no run for the paragraph'); - return run; -} - -function batchFor(batch, run) { - const physical = batch.current.glyphBatches.find((entry) => entry.key === run.batch); - if (physical === undefined) throw new Error('the published revision has no physical batch for the run'); - return physical; -} - -function glyphColors(batch, run) { - const physical = batchFor(batch, run); - const colors = []; - for (let index = 0; index < run.count; index += 1) { - colors.push([...physical.storage.colors.slice((run.start + index) * 4, (run.start + index + 1) * 4)]); - } - return colors; -} - -function msdfGlyphPaint(batch, run) { - const physical = batchFor(batch, run); - const painted = []; - for (let index = 0; index < run.count; index += 1) { - const start = (run.start + index) * 4; - painted.push({ - fill: [...physical.storage.fillColors.slice(start, start + 4)], - outline: [...physical.storage.outlineColors.slice(start, start + 4)], - shadow: [...physical.storage.shadowColors.slice(start, start + 4)], - }); - } - return painted; -} - -function dataUrl(bytes) { - return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; -} diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index 1f88aceb..a7996a4b 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -134,7 +134,7 @@ test('Three coordinator shares shaping data across technique bindings and refere }, disposed: false, }; - const coordinator = new ThreeTextEngineCoordinator({ shaper }); + const coordinator = new ThreeTextEngineCoordinator(shaper); const materialCalls = []; const primaryMaterial = coordinator.acquireMaterial( defineTextMaterial((context) => { diff --git a/packages/text/tests/integration/three-shader.test.mjs b/packages/text/tests/integration/three-shader.test.mjs index d6c5176d..f0f131f4 100644 --- a/packages/text/tests/integration/three-shader.test.mjs +++ b/packages/text/tests/integration/three-shader.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '@pmndrs/text'; +import { createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; import { bitmapShader, defineTextMaterial, msdfShader, slugShader, Text } from '@pmndrs/text/three'; import * as TSL from 'three/tsl'; @@ -18,11 +18,10 @@ test('the canonical technique shaders are exported as callable node builders', ( test('a custom Three material composes over the Bitmap shader in the Rust command-buffer draw path', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index fdf485be..65d0d8c6 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { createRuntimeShaper, createTextRuntime, FontRegistry } from '@pmndrs/text'; +import { createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; import { setThreeTextProfiler, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; @@ -19,11 +19,10 @@ const amiriFontUrl = new URL( test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose through the scene graph', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, @@ -137,11 +136,10 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr test('TextGroup realizes two public Text objects as one indexed Rust draw', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, @@ -254,11 +252,10 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn test('Bitmap strike changes fully initialize a replacement indexed batch', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(densityFontUrl)) }, raster: { technique: bitmap, options: { strikes: [16, 32] } }, @@ -298,56 +295,15 @@ test('Bitmap strike changes fully initialize a replacement indexed batch', async test('Rust ellipsis reshapes only the narrowed unsafe line boundary', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(amiriFontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, }); const text = 'مرحبا بالعالم'; - const textUtf16 = Uint16Array.from({ length: text.length }, (_, index) => text.charCodeAt(index)); - const shapingRequest = { - textUtf16, - features: [], - runs: [ - { - font: font.font.handle, - textStart: 0, - textEnd: textUtf16.length, - direction: 'rtl', - script: 'Arab', - language: 'ar', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], - }; - const broad = ownedShape(shaper.shapeBatch(shapingRequest)); - const narrowed = ownedShape( - shaper.reshapeRanges({ - ...shapingRequest, - ranges: [ - { - run: 0, - itemStart: 0, - itemEnd: 3, - contextStart: 0, - contextEnd: 3, - flags: 0x43, - }, - ], - }), - ); - assert.notDeepEqual( - shapeSignature(broad, 0, 3), - shapeSignature(narrowed, 0, 3), - 'the fixture must fail if the retained whole-run shape is reused at the unsafe boundary', - ); const scene = new THREE.Scene(); const label = new Text({ @@ -368,11 +324,9 @@ test('Rust ellipsis reshapes only the narrowed unsafe line boundary', async () = assert.ok(inspection); assert.equal(inspection.lineTextEnds[0], 3, 'the fixed width must preserve the unsafe-boundary fixture'); assert.equal(inspection.clusters.at(-1), 3, 'the ellipsis is anchored at the truncation boundary'); - assert.deepEqual( - shapeSignature(inspection, 0, 3), - shapeSignature(narrowed, 0, 3), - 'Rust positioning must consume the narrowed boundary shape, not the retained whole-run glyphs', - ); + assert.deepEqual([...inspection.glyphIds], [61, 2613, 2598, 6597]); + assert.deepEqual([...inspection.clusters], [2, 1, 0, 3]); + assert.deepEqual([...inspection.x], [0.23199999332427979, 10.807999610900879, 18.375999450683594, 23.91200065612793]); label.dispose(); font.dispose(); @@ -381,11 +335,10 @@ test('Rust ellipsis reshapes only the narrowed unsafe line boundary', async () = test('TextGroup atomically replaces child paragraphs without multiplying retained text capacity', async () => { const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, @@ -422,14 +375,3 @@ test('TextGroup atomically replaces child paragraphs without multiplying retaine function dataUrl(bytes) { return `data:model/gltf-binary;base64,${bytes.toString('base64')}`; } - -function ownedShape(shape) { - return { glyphIds: [...shape.glyphIds], clusters: [...shape.clusters] }; -} - -function shapeSignature(shape, start, end) { - return [...shape.glyphIds].flatMap((glyphId, index) => { - const cluster = shape.clusters[index]; - return cluster >= start && cluster < end ? [[glyphId, cluster]] : []; - }); -} diff --git a/packages/text/tests/integration/uikit-layout-fixture.test.mjs b/packages/text/tests/integration/uikit-layout-fixture.test.mjs deleted file mode 100644 index eb6efa2c..00000000 --- a/packages/text/tests/integration/uikit-layout-fixture.test.mjs +++ /dev/null @@ -1,107 +0,0 @@ -import assert from 'node:assert/strict'; -import { readFile } from 'node:fs/promises'; -import test from 'node:test'; - -import { createParagraphEngine, createRuntimeShaper, FontRegistry } from '@pmndrs/text'; -import { createFontBaker } from '@pmndrs/text-font-baker'; - -import { - createUikitLayoutFixture, - YogaMeasureMode, -} from '../../../../apps/benchmarks/src/benchmark/uikit-layout-fixture.ts'; - -const text = 'office AVATAR café — ffi, kerning, marks, and wrapping.'; - -test('mirrors the current uikit CustomLayouting and resolved content-box flow', async () => { - const { font, shaper } = await runtime(); - const input = { - text, - font: font.handle, - style: { fontSize: 31, lineHeight: 1.23, direction: 'ltr', language: 'en' }, - }; - const paragraph = createParagraphEngine({ shaper }).create(input); - const fixture = createUikitLayoutFixture(paragraph, { wrap: 'word', overflow: 'clip' }); - const custom = fixture.customLayouting(); - - assert.deepEqual( - { - minWidth: custom.minWidth, - minHeight: custom.minHeight, - firstBaseline: custom.firstBaseline, - }, - { - minWidth: 96.0576171875, - minHeight: 38.13, - firstBaseline: 30.34185546875, - }, - ); - assert.equal(fixture.calls.layout, 0, 'intrinsic sizing must not materialize glyph arrays'); - - const natural = custom.measure(Number.NaN, YogaMeasureMode.Undefined, Number.NaN, YogaMeasureMode.Undefined); - assert.deepEqual(natural, { width: 821.14, height: 38.14 }); - const atMost = custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost); - assert.deepEqual(atMost, { width: 345.41, height: 90 }); - const exactWidth = custom.measure(420.001, YogaMeasureMode.Exactly, NaN, YogaMeasureMode.Undefined); - assert.deepEqual(exactWidth, { width: 420.01, height: 114.4 }); - for (let index = 0; index < 20; index += 1) { - assert.deepEqual(custom.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost), atMost); - } - assert.equal(fixture.calls.layout, 0); - - const beforeDefinite = fixture.calls.measure; - assert.deepEqual(fixture.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly), { - width: 401.24, - height: 150.12, - measured: false, - }); - assert.equal(fixture.calls.measure, beforeDefinite, 'Yoga skips leaf measurement for two exact axes'); - assert.throws( - () => custom.measure(Number.NaN, YogaMeasureMode.AtMost, 10, YogaMeasureMode.Exactly), - /Yoga width must be finite/, - ); - assert.throws(() => custom.measure(10, 99, 10, YogaMeasureMode.Exactly), /measure mode/); - - const resolved = fixture.layoutResolvedBox([401.24, 150.12], [7, 11, 13, 17], [1, 2, 3, 4]); - assert.deepEqual(resolved.contentBox, { width: 367.24, height: 126.12 }); - assert.equal(fixture.calls.layout, 1); - assert.equal(resolved.layout.width, 367.24); - assert.equal(resolved.layout.height, 126.12); - assert.equal(resolved.centeredX[0], Math.fround(resolved.layout.x[0] - 179.62)); - assert.equal(resolved.centeredY[0], Math.fround(67.06 - resolved.layout.y[0])); - - const dirtyBeforePaint = fixture.dirtyCount; - fixture.updatePaint(); - fixture.updateRaster(); - assert.equal(fixture.dirtyCount, dirtyBeforePaint); - assert.deepEqual([fixture.paintRevision, fixture.rasterRevision], [1, 1]); - - fixture.updateShapingPolicy({ maxLines: 2 }); - assert.equal(fixture.dirtyCount, dirtyBeforePaint + 1); - fixture.updateParagraph({ ...input, text: `${text} Updated.` }); - assert.equal(fixture.dirtyCount, dirtyBeforePaint + 2); - assert.notDeepEqual( - fixture.customLayouting().measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost), - atMost, - ); - - paragraph.dispose(); - shaper.dispose(); - font.dispose(); -}); - -async function runtime() { - const [source, bakerWasm, shaperWasm] = await Promise.all([ - readFile(new URL('../../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url)), - readFile(new URL('../../../font-baker/dist/font_baker.wasm', import.meta.url)), - readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - ]); - const baker = await createFontBaker(bakerWasm); - const artifact = baker.bake({ - source, - descriptor: { formatVersion: 0, fontFaceIndex: 0 }, - }).artifacts[0].bytes; - const registry = new FontRegistry(); - const font = await registry.registerAsset(artifact); - const shaper = await createRuntimeShaper({ registry, wasm: shaperWasm }); - return { font, shaper }; -} diff --git a/packages/text/tests/types/public-api.test.ts b/packages/text/tests/types/public-api.test.ts index 05dccd4b..56776447 100644 --- a/packages/text/tests/types/public-api.test.ts +++ b/packages/text/tests/types/public-api.test.ts @@ -3,13 +3,10 @@ import { defineRasterBatchStage, defineRasterBaker, defineFont, - createParagraphEngine, - createRuntimeShaper, FontLoader, FontRegistry, rasterBake, type AnyRasterModule, - type BidiAnalysisViews, type FontInputOf, type FontRasterModuleOf, type GlyphPaint, @@ -27,13 +24,7 @@ import { type RasterSource, type RegisteredFont, type RegisteredRaster, - type RuntimeShaper, type Sha256Hex, - type ShapeBatchRequest, - type ShapedBatchViews, - type LayoutParagraph, - type ParagraphConstraints, - type ParagraphMeasurement, } from '../../src/index.js'; import type { Object3D } from 'three/webgpu'; @@ -63,38 +54,7 @@ const fontLoader = new FontLoader({ development: false, }); const registeredPromise: Promise = fontLoader.load('/fonts/Inter.ttf'); -const shaperPromise: Promise = createRuntimeShaper({ registry: fontRegistry }); -declare const fontHandle: RegisteredFont['handle']; -const shapeRequest: ShapeBatchRequest = { - textUtf16: Uint16Array.of(0x41), - features: [], - runs: [ - { - font: fontHandle, - textStart: 0, - textEnd: 1, - direction: 'ltr', - script: 'Latn', - language: 'en', - clusterLevel: 0, - flags: 0x40, - featureStart: 0, - featureCount: 0, - }, - ], -}; -const shapedPromise: Promise = shaperPromise.then((shaper) => shaper.shapeBatch(shapeRequest)); -const bidiPromise: Promise = shaperPromise.then((shaper) => - shaper.analyzeBidi(Uint16Array.of(0x05d0), 'auto'), -); -const preparedParagraph: Promise = shaperPromise.then((shaper) => - createParagraphEngine({ shaper }).create({ text: 'Hello', font: fontHandle }), -); void registeredPromise; -void shaperPromise; -void shapedPromise; -void bidiPromise; -void preparedParagraph; interface MsdfResource { readonly texture: unknown; @@ -221,43 +181,6 @@ runtime.load(font, { module: configurable }); // @ts-expect-error An MSDF decoder cannot consume a Slug artifact. msdf.decode(font, slugArtifact); -declare const paragraph: LayoutParagraph; - -const naturalMeasurement: ParagraphMeasurement = paragraph.measure(); -const constrainedMeasurement = paragraph.measure({ - width: { mode: 'at-most', size: 320 }, -}); -const intrinsicParagraph = paragraph.layout(); -const constrainedParagraph = paragraph.layout({ - width: { mode: 'at-most', size: 320 }, - height: { mode: 'unconstrained' }, - wrap: 'word', -}); -const committedParagraph = paragraph.layout({ - width: { mode: 'exactly', size: 280 }, -}); -const hostLayoutConstraints: ParagraphConstraints = { - width: { mode: 'at-most', size: 320 }, -}; -void intrinsicParagraph; -void naturalMeasurement; -void constrainedMeasurement; -void constrainedParagraph; -void committedParagraph; -void hostLayoutConstraints; - -// @ts-expect-error Measurement deliberately omits positioned glyph arrays. -void naturalMeasurement.glyphIds; - -// @ts-expect-error At-most constraints require an available size. -paragraph.layout({ width: { mode: 'at-most' } }); - -// @ts-expect-error Measurement and layout use the same constraint contract. -paragraph.measure({ height: { mode: 'exactly' } }); - -// @ts-expect-error Unconstrained axes do not carry a meaningless size. -paragraph.layout({ width: { mode: 'unconstrained', size: 320 } }); - const titleFont = defineFont('/fonts/Inter-Regular.ttf', msdf); type _TitleInput = Expect, '/fonts/Inter-Regular.ttf'>>; type _TitleRaster = Expect, typeof msdf>>; diff --git a/packages/text/tests/types/text-runtime-api.test.ts b/packages/text/tests/types/text-runtime-api.test.ts index 819f9b90..b907a00a 100644 --- a/packages/text/tests/types/text-runtime-api.test.ts +++ b/packages/text/tests/types/text-runtime-api.test.ts @@ -1,59 +1,10 @@ -import { - createFontStack, - createTextRuntime, - span, - txt, - type LoadedFont, - type Paragraph, - type TextRuntime, -} from '../../src/index.js'; +import { createTextRuntime, type TextRuntime } from '../../src/index.js'; import { bitmap } from '../../src/raster/bitmap-technique.js'; import { msdf } from '../../src/raster/msdf.js'; import { slug } from '../../src/raster/slug-technique.js'; declare const runtime: TextRuntime; -declare const bitmapFont: LoadedFont; -declare const bitmapFallback: LoadedFont; -declare const mtsdfFont: LoadedFont; - -const uiFont = createFontStack(bitmapFont, bitmapFallback); -const mixedRasterFont = createFontStack(bitmapFont, mtsdfFont); - -const labels = runtime.createParagraphBatch({ - technique: bitmap, - capacity: { size: 128, policy: 'fixed' }, - renderVariant: 'plain' as 'plain' | 'warning', -}); - -// @ts-expect-error The legacy renderer-neutral batch still owns one raster technique. -labels.add({ font: mixedRasterFont, text: 'Mixed techniques require a render-plan integration' }); - -const warning = span(bitmapFallback, { color: '#ff00ff', fontSize: 18 }); -const label: Paragraph = labels.add({ - font: uiFont, - text: txt`A ${warning`fallback`} label`, - renderVariant: 'warning', -}); - -label.text = 'Plain text'; -label.set({ order: 4, contentBox: { width: { mode: 'at-most', size: 320 }, wrap: 'word' } }); -labels.setCapacity({ size: 256, policy: 'fixed' }); - -runtime.updateAsync({ - priority: 'urgent', - onProgress(progress) { - progress.preparedParagraphs satisfies number; - progress.totalParagraphs satisfies number; - progress.stagedGlyphs satisfies number; - }, -}); -runtime.updateAsync((result) => { - if (result.ok && result.value.status === 'published') - result.value.value satisfies import('../../src/index.js').TextRuntimeRevision; -}); - -// @ts-expect-error A paragraph batch cannot accept a font from another technique. -labels.add({ font: mtsdfFont, text: 'wrong technique' }); +runtime.registry satisfies TextRuntime['registry']; async function loadTargetV1Fonts(): Promise { const created = await createTextRuntime(); @@ -71,5 +22,4 @@ async function loadTargetV1Fonts(): Promise { }); } -void label; void loadTargetV1Fonts; diff --git a/packages/text/tests/types/typegpu-v1-api.test.ts b/packages/text/tests/types/typegpu-v1-api.test.ts deleted file mode 100644 index 816f24eb..00000000 --- a/packages/text/tests/types/typegpu-v1-api.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { TgpuRoot } from 'typegpu'; - -import type { PreparedParagraphBatchRevision } from '../../src/paragraph-batch.js'; -import { bitmap } from '../../src/raster/bitmap-technique.js'; -import { - createTypeGpuTextEngine, - defineTypeGpuRasterProgram, - defineTypeGpuRasterShader, - type TypeGpuParagraphBatchTargetRevision, - type TypeGpuRasterProgram, -} from '../../src/typegpu.js'; - -interface TintVariant { - readonly tint: readonly [number, number, number, number]; -} - -interface BitmapDraw { - readonly instanceCount: number; -} - -interface BitmapRevision extends TypeGpuParagraphBatchTargetRevision { - readonly sourceRevision: number; -} - -const shader = defineTypeGpuRasterShader({ - technique: bitmap, - vertex: { input: 'bitmap-instance', output: 'clip-position' }, - fragment: { input: 'bitmap-sample', output: 'color' }, - resources: { atlas: 'texture-2d-array' }, -}); - -const bitmapProgram = defineTypeGpuRasterProgram({ - technique: bitmap, - createTarget(options) { - return { - root: options.root, - technique: bitmap, - setParagraphState() {}, - stage(_previous: BitmapRevision | undefined, next: PreparedParagraphBatchRevision) { - return { - status: 'ready' as const, - stage: { - sourceRevision: next.revision, - commit: () => ({ sourceRevision: next.revision, draws: [], dispose() {} }), - abort() {}, - }, - }; - }, - encode() {}, - dispose() {}, - }; - }, - dispose() {}, -} satisfies TypeGpuRasterProgram); - -declare const root: TgpuRoot; - -async function useTypeGpuApi(): Promise { - const engine = await createTypeGpuTextEngine({ root, colorFormat: 'rgba8unorm' }); - const batch = engine.createParagraphBatch({ technique: bitmap, program: bitmapProgram }); - - // The concrete program carries its exact variant into paragraphs. - batch.renderVariant = { tint: [1, 0, 0, 1] }; - - // @ts-expect-error The retained variant is not an untyped effect bag. - batch.renderVariant = { opacity: 0.5 }; - - batch.dispose(); - engine.dispose(); -} - -void shader; -void useTypeGpuApi; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8dfb1ff..eff28ad0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -233,9 +233,6 @@ importers: three: specifier: 0.185.1 version: 0.185.1 - typegpu: - specifier: 0.11.9 - version: 0.11.9 unicode-property-value-aliases: specifier: 3.9.0 version: 3.9.0 @@ -2796,10 +2793,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyest@0.3.2: - resolution: {integrity: sha512-1wqSt97RLezWzeogLSVXHr+E3jpYO8jxyaK2AIdJeGoZZTCubwNxQ9IqU5gbQpJVcxlbPugPAsII+m9/gqpPSA==} - engines: {node: '>=12.20.0'} - tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -2834,9 +2827,6 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} - tsover-runtime@0.0.7: - resolution: {integrity: sha512-FHHwMJzZbnP23+fU1PxXljy7j0RRRosL2r1/CRUNTqfW+l7m35JsUkM615x/cAzw4GqRdXPIl3axKMj9qzpZtQ==} - tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -2848,14 +2838,6 @@ packages: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} - typed-binary@4.3.3: - resolution: {integrity: sha512-W2hLsSzt3k/tg38gDE4Fn/QiwcoqGuUHBc2cb3mXuH7KcYxe/GM57vzW14s2/bawB4R5knGgGq8Xb57vsaJ4Sg==} - - typegpu@0.11.9: - resolution: {integrity: sha512-AqBV6P9lW2jA7pEVDeiMD7Qoza9doPgueQbwyUyf09u4ndb9jOttHwpZ18c+BEzwLHaPlV50jw0hx5QsmVJGNg==} - engines: {node: '>=12.20.0'} - hasBin: true - typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -5371,8 +5353,6 @@ snapshots: tinybench@2.9.0: {} - tinyest@0.3.2: {} - tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -5403,8 +5383,6 @@ snapshots: tslib@2.8.1: {} - tsover-runtime@0.0.7: {} - tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -5417,14 +5395,6 @@ snapshots: media-typer: 1.1.1 mime-types: 3.0.2 - typed-binary@4.3.3: {} - - typegpu@0.11.9: - dependencies: - tinyest: 0.3.2 - tsover-runtime: 0.0.7 - typed-binary: 4.3.3 - typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 From d447bd3547238c050b20b5048c9b8ce89e9c5b8d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 13:52:39 -0400 Subject: [PATCH 093/128] bench(text): separate external frame timing --- docs/log.md | 6 ++++++ docs/packages/text.md | 21 ++++++++++++------- .../scripts/benchmark-paragraph-layout.mts | 9 +++++--- 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/docs/log.md b/docs/log.md index 3f33e1d5..a7d4f87c 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Separated final customer timing from temporary phase instrumentation** — The public Three workload can now retain a + single outside timer while disabling its internal phase collector. A 25,515-glyph, 31-sample release-artifact run + measures complete frame preparation, Rust update/render-plan publication, and Three application without internal clock + calls. The packaged shaper is Cargo release + LTO + SIMD followed by Binaryen `-Oz`; adjacent `-O3`/`-O4` artifacts cost + more bytes without a demonstrated speed gain. Production profiling hooks remain an explicit removal gate. + - **Specified paragraph-scoped synchronous preparation without triple buffering** — Current `measureLayout()` either returns committed cache or drives a complete session update and plan. The reviewed follow-up design retains one speculative session transaction with paragraph-keyed pending states, linear identity reservation, explicit diff --git a/docs/packages/text.md b/docs/packages/text.md index 38b8f51b..bc101481 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:7543198f3107315061bd1615a4cfb50356c9e96441e3a5e701d28bab6b682515' +source_digest: 'sha256:cadece5952b3f3f57222acdf576b45cf1d27c76d1d082e3d338be2cb78f16082' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -55,7 +55,7 @@ sources: title: Three.js text API reference generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T18:30:00Z' + at: '2026-08-09T17:49:53Z' --- # Package reference: `@pmndrs/text` @@ -196,11 +196,18 @@ The latest checked package-size record before final cleanup reports: Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. -A five-sample smoke run of the newly ported end-to-end Node layout benchmark realized 29,160 glyphs and measured 6.93 ms -for font-size updates and 6.48 ms for width updates, while cold creation measured 24.11 ms and text replacement 18.65 ms. -This run proves the benchmark now drives `Text`/`TextGroup`, Rust, render-plan packing, and Three application; its small -sample count is not release evidence. The `<4 ms` target and same-work comparison against the retained TypeScript baseline -remain open. +The public Three benchmark now supports an outside-only mode that leaves the internal phase collector disabled and wraps +one `updateMatrixWorld()` call with a host timer. An eight-warmup/31-sample run over 25,515 positioned glyphs measured +17.68/6.13/5.66/16.37 ms median and 18.11/6.32/6.53/16.57 ms p95 for cold/font-size/width/text updates. Those values cover +frame preparation, the complete Rust transaction and render-plan publication, and Three plan application; they exclude +GPU submission. An adjacent phase-instrumented run was indistinguishable within process noise, but the current published +Three graph still contains inactive profiler calls and branches. The production publishing build must remove those hooks, +while benchmark/development instrumentation remains separate. + +The canonical direct benchmark loads the packaged `dist/text_shaper.wasm`: Cargo release optimization, LTO, one codegen +unit, default-on `simd128`, stripping, and `wasm-opt -Oz --enable-simd` have already run. On the identical Rust artifact, +Binaryen `-O3` and `-O4` added 11,976 and 13,661 raw bytes without a demonstrated latency improvement, so `-Oz` remains +the evidence-backed setting. The `<4 ms` warm-path target and stable p95 closure remain open. ## Merge gates still open diff --git a/packages/text/scripts/benchmark-paragraph-layout.mts b/packages/text/scripts/benchmark-paragraph-layout.mts index 3efeadec..07554dd1 100644 --- a/packages/text/scripts/benchmark-paragraph-layout.mts +++ b/packages/text/scripts/benchmark-paragraph-layout.mts @@ -164,9 +164,11 @@ async function measureCase(name: CaseName, text: string): Promise { function profileUpdate(update: () => void): UpdateProfile { const durations = new Map(); - setThreeTextProfiler((phase, startedMs, endedMs) => { - durations.set(phase, (durations.get(phase) ?? 0) + endedMs - startedMs); - }); + if (options.profilePhases) { + setThreeTextProfiler((phase, startedMs, endedMs) => { + durations.set(phase, (durations.get(phase) ?? 0) + endedMs - startedMs); + }); + } const started = performance.now(); try { update(); @@ -232,6 +234,7 @@ function parseArguments(argv: readonly string[]) { cases: (cases === undefined ? ['cold', 'font-size', 'layout-width', 'text'] : cases.split(',')) as CaseName[], warmup: Number.parseInt(read('--warmup') ?? String(DEFAULT_WARMUP), 10), repetitions: Number.parseInt(read('--reps') ?? String(DEFAULT_REPETITIONS), 10), + profilePhases: read('--profile-phases') !== '0', jsonPath: read('--json'), }; } From 48f075c80fac84ee995e05ed6834a86ea39e67a1 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 13:59:55 -0400 Subject: [PATCH 094/128] test(text): align layout contract with f32 ABI --- .../fixtures/contracts/paragraph-bidi-layout-v0.json | 12 ++++++------ docs/log.md | 6 ++++++ docs/packages/benchmarks.md | 12 +++++++++--- docs/packages/text.md | 9 +++++---- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json b/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json index 9859f3ca..c7dbe3d8 100644 --- a/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json +++ b/apps/benchmarks/fixtures/contracts/paragraph-bidi-layout-v0.json @@ -570,9 +570,9 @@ -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, -1.4118552207946777, - -1.4118552207946777, -1.4118552207946777, -39.54185104370117, -39.54185104370117, - -39.54185104370117, -39.54185104370117, -39.54185104370117, -39.54185104370117, - -39.54185104370117, -39.54185104370117, -39.54185104370117 + -1.4118552207946777, -1.4118552207946777, -39.5418586730957, -39.5418586730957, + -39.5418586730957, -39.5418586730957, -39.5418586730957, -39.5418586730957, + -39.5418586730957, -39.5418586730957, -39.5418586730957 ], "layout": { "measurement": { @@ -581,10 +581,10 @@ "contentWidth": 345.40478515625, "contentHeight": 114.39000000000001, "firstBaseline": 30.341856002807617, - "lastBaseline": 106.60185241699219, + "lastBaseline": 106.60186004638672, "overflowed": false }, - "hash": "c1a7730c", + "hash": "64cd57ec", "glyphIds": [ 790, 647, 647, 689, 586, 614, 1777, 2, 456, 2, 411, 2, 384, 1777, 586, 507, 647, 618, 1777, 1462, 1777, 647, 647, 689, 1501, 1777, 727, 614, 852, 773, 689, 773, 658, 1501, @@ -612,7 +612,7 @@ "lineTextEnds": [22, 47, 56], "lineGlyphStarts": [0, 21, 46], "lineGlyphCounts": [21, 25, 9], - "lineBaselines": [30.341856002807617, 68.47185516357422, 106.60185241699219], + "lineBaselines": [30.341856002807617, 68.47185516357422, 106.60186004638672], "lineAdvances": [329.556640625, 345.40478515625, 146.17529296875] } } diff --git a/docs/log.md b/docs/log.md index a7d4f87c..78cbf5ef 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Closed the retained paragraph browser matrix under the f32 frame contract** — The sole UIKit mismatch was a stale + JavaScript-double style-input expectation, not a Rust or Yoga precision defect. Independent f32 line-box arithmetic + reproduces the retained engine's final baseline, content height, centered final row, and exact layout hash. The public + browser target now passes two bidi, nine policy, twelve CJK, and one UIKit-shaped contract without runtime widening or + comparison tolerances. + - **Separated final customer timing from temporary phase instrumentation** — The public Three workload can now retain a single outside timer while disabling its internal phase collector. A 25,515-glyph, 31-sample release-artifact run measures complete frame preparation, Rust update/render-plan publication, and Three application without internal clock diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 9eaf584e..51b01ca5 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:cedbf0466d5682351c98761d2e874f9dcf8955b5eec88a548f9144efedf0d338' +source_digest: 'sha256:56aa4878fd9c8f00d50483bddd9759e97a808c89b3c0cfd5128d84306487df7f' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -204,8 +204,8 @@ sources: resource: ../../apps/benchmarks/vitexec/raster-technique-compare.probe.ts title: Realtime comparison product probe generated: - by: openai-codex/gpt-5 - at: '2026-08-09T12:24:43Z' + by: openai-codex/gpt-5.6 + at: '2026-08-09T17:57:28Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -220,6 +220,12 @@ when a visibly populated draw claims no GPU residency, so the accessor is proven a unit fixture. The Worker proof distinguishes call-time snapshots, later desired state, supersession, abort, progress, and one reusable module Worker. +The public paragraph-contract target now passes its complete exact matrix through the retained Rust path: two bidi +layouts, nine line-policy layouts, twelve CJK layouts, and one UIKit-shaped measurement/layout seam. The UIKit fixture +models the frame ABI's f32 style input rather than the deleted TypeScript engine's JavaScript-double input. Its final-line +baseline and centered glyph row are derived independently from the f32 line box followed by f64 accumulation and one final +f32 publication; the exact full-layout hash remains a deterministic byte contract rather than a tolerance assertion. + A fifth proof covers material customization over the canonical Bitmap command-buffer path. It renders one paragraph with the default Bitmap material, then renders the same Rust-produced draw with a `defineTextMaterial` factory that starts from `createDefaultMaterial()` and changes only its final colour. The verification compares the two passes on the same page diff --git a/docs/packages/text.md b/docs/packages/text.md index bc101481..1e6cb37a 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -180,9 +180,11 @@ The foundation currently has: the browser behavior check; - byte-identical Bitmap, MSDF, and Slug packing/consumer gates retained elsewhere in the benchmark suite. -The browser paragraph target is not yet fully green: the UIKit fixture exposes one-ULP baseline differences caused by -layout-sensitive style values narrowing to `f32` before Rust performs `f64` accumulation. That issue must be resolved at -the ABI contract rather than hidden with fixture regeneration or benchmark-only tolerance. +The browser paragraph target is fully green under the explicit f32 frame contract. The former UIKit mismatch came from a +fixture generated by the deleted TypeScript path, where authored JavaScript-double line height survived until final array +publication. The retained engine deliberately receives that style scalar as f32, accumulates line positions in f64, and +narrows published values once. An independent calculation from the f32 line box reproduces the corrected final baseline, +centered glyph row, content height, and complete layout hash exactly; no runtime precision or tolerance changed. ## Current size and performance evidence @@ -213,7 +215,6 @@ the evidence-backed setting. The `<4 ms` warm-path target and stable p95 closure Before the foundation stack is publishable: -- resolve the layout-style precision contract and make the public paragraph browser target exact; - finish the stale-code and stale-documentation audit; - regenerate affected ABI, optimized Wasm, package-size records, and package digests from source; - run package checks, strict Rust checks, benchmark conformance, packed consumers, WebGPU and forced-WebGL2 live rendering, From a11016f78669feaa7b62449d1a04cb759011ad46 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 14:36:54 -0400 Subject: [PATCH 095/128] refactor(text): harden published runtime boundary --- .../size-entries/mtsdf-generator.ts | 2 +- .../benchmark/scenes/comparison-workload.ts | 3 +- docs/log.md | 12 ++ docs/packages/benchmarks.md | 2 +- docs/packages/font-baker.md | 2 +- docs/packages/text.md | 55 +++++---- packages/font-baker/src/index.ts | 11 +- .../tests/integration/wasm-package.test.mjs | 8 +- .../scripts/benchmark-paragraph-layout.mts | 49 +------- packages/text/scripts/build.mjs | 71 +++++++++--- .../text/scripts/test-mtsdf-core-wasm.mjs | 8 +- packages/text/src/bakers/bitmap.ts | 10 +- packages/text/src/bakers/msdf.ts | 9 +- packages/text/src/bakers/slug.ts | 9 +- packages/text/src/internal/mtsdf-generator.ts | 20 ++-- packages/text/src/three.ts | 2 - packages/text/src/three/engine-plan-target.ts | 3 - packages/text/src/three/profiler.ts | 34 ------ packages/text/src/three/text.ts | 16 --- .../tests/integration/bitmap-baker.test.mjs | 9 +- .../tests/integration/mtsdf-baker.test.mjs | 9 +- .../integration/mtsdf-generator.test.mjs | 6 +- .../tests/integration/slug-baker.test.mjs | 13 +-- .../text/tests/integration/three-v1.test.mjs | 107 ++++++++++-------- .../text/tests/types/three-v1-api.test.ts | 12 +- 25 files changed, 211 insertions(+), 271 deletions(-) delete mode 100644 packages/text/src/three/profiler.ts diff --git a/apps/benchmarks/size-entries/mtsdf-generator.ts b/apps/benchmarks/size-entries/mtsdf-generator.ts index 13fa4aed..5ae84fae 100644 --- a/apps/benchmarks/size-entries/mtsdf-generator.ts +++ b/apps/benchmarks/size-entries/mtsdf-generator.ts @@ -2,5 +2,5 @@ export { MtsdfGenerationError, createMtsdfGenerator, createMtsdfGeneratorFromInstance, - readMtsdfGeneratorAbi, + mtsdfGeneratorAbi, } from '../../../packages/text/dist/internal/mtsdf-generator.js'; diff --git a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts index bbe1bae5..2980fcf6 100644 --- a/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts +++ b/apps/benchmarks/src/surfaces/benchmark/scenes/comparison-workload.ts @@ -1,5 +1,5 @@ import { FontRegistry, type ParagraphLayoutSummary, type RegisteredFont } from '@pmndrs/text'; -import { setThreeTextProfiler, TextGroup, threeTextUserTimingProfiler } from '@pmndrs/text/three'; +import { TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; import { selectBitmapStrikePpem } from '@pmndrs/text/three/bitmap'; @@ -69,7 +69,6 @@ type WorkloadEntry = ComparisonWorkloadEntry; const textTimingsEnabled = typeof location !== 'undefined' && new URLSearchParams(location.search).get('textTimings') === '1'; -if (textTimingsEnabled) setThreeTextProfiler(threeTextUserTimingProfiler()); function timingBegin(): number { return textTimingsEnabled ? performance.now() : 0; diff --git a/docs/log.md b/docs/log.md index 78cbf5ef..ce8011a2 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,18 @@ ## 2026-08-09 +- **Prevented test-only Wasm variants from entering published baker artifacts** — Distributable MTSDF and Slug + artifact-baker builds and the optional SIMD compatibility switch now use feature-specific Cargo target directories; + the MTSDF kernel test uses a separate target. The package build rejects any optimized baker missing an export declared + by its Rust-generated TypeScript ABI. The full MTSDF artifact remains 552,025 bytes with SHA-256 `ec6eb164…7de8` before + and after the 60,993-byte kernel-only test. Generated ABI constants replace instance-ignoring or duplicate reader + functions across the font, Bitmap, MTSDF, and Slug baker hosts. + +- **Removed timing instrumentation and stale-output risk from the published Three graph** — The package no longer exports + or calls its temporary phase profiler. One-crossing integration evidence now wraps the Wasm export solely in the test + harness, while benchmark workload markers and outside frame timing remain application-owned. Package builds recreate + `dist` before TypeScript emission so deleted profiler and legacy modules cannot survive in a published tarball. + - **Closed the retained paragraph browser matrix under the f32 frame contract** — The sole UIKit mismatch was a stale JavaScript-double style-input expectation, not a Rust or Yoga precision defect. Independent f32 line-box arithmetic reproduces the retained engine's final baseline, content height, centered final row, and exact layout hash. The public diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 51b01ca5..17f4b35a 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:56aa4878fd9c8f00d50483bddd9759e97a808c89b3c0cfd5128d84306487df7f' +source_digest: 'sha256:c86eb603cccf0cc45344709437c37a6dde1636261ae69d32d0c75fe8fa1e1dec' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/font-baker.md b/docs/packages/font-baker.md index d8f6a14c..f61b26c3 100644 --- a/docs/packages/font-baker.md +++ b/docs/packages/font-baker.md @@ -42,7 +42,7 @@ The separate `@pmndrs/text-font-baker/validate` ESM entry treats every baked ass The integration suite also compiles the canonical MTSDF and Slug Draft-04 schemas directly from the knowledge bundle with their shared resource references. Positive V0 specimens and one-field mutations keep required members, 20/40-byte record strides, MTSDF encoding, linear color space, lossless RGBA8 MTSDF pages, and lossless RGBA16F Slug curve pages executable before either generator lands. These schema tests do not claim an implemented raster; they prevent Milestone 8 and 9 code from beginning against an internally inconsistent draft. -The build applies pinned Binaryen 129.0.0 `-Oz` after Rust release linking. Canonical path remapping removes host workspace and Cargo registry prefixes before compilation. The current hardened zero-import module is 422,538 raw bytes while preserving the canonical font artifact hash. Pinned dynamic Talc 5.0.4 owns the ABI-private Wasm heap; it saves 9,801 raw, 3,352 gzip, and 2,342 Brotli bytes relative to the measured `dlmalloc` build without imposing a fixed arena reservation. Its ABI JSON remains a published tool artifact, but production Wasm embeds neither that JSON nor ABI pointer/length exports; production TypeScript imports the generated constant directly. Native Rust/Binaryen hosts may permute equivalent internal Wasm function indices across CPU architectures, so source/product goldens and the optimized length are portable checks while the exact module hash is canonical release-builder provenance. This package is the sole owner of those optimized bytes and exposes one browser-safe canonical URL; the offline Node host reads that URL and the runtime Worker fetches it instead of `@pmndrs/text` shipping a second copy. Reports keep raw and transport costs distinct. +The build applies pinned Binaryen 129.0.0 `-Oz` after Rust release linking. Canonical path remapping removes host workspace and Cargo registry prefixes before compilation. The current hardened zero-import module is 422,538 raw bytes while preserving the canonical font artifact hash. Pinned dynamic Talc 5.0.4 owns the ABI-private Wasm heap; it saves 9,801 raw, 3,352 gzip, and 2,342 Brotli bytes relative to the measured `dlmalloc` build without imposing a fixed arena reservation. Its ABI JSON remains a published tool artifact, but production Wasm embeds neither that JSON nor ABI pointer/length exports; production TypeScript imports and publicly re-exports the generated constant directly, while construction validates the contract-declared Wasm exports once. Native Rust/Binaryen hosts may permute equivalent internal Wasm function indices across CPU architectures, so source/product goldens and the optimized length are portable checks while the exact module hash is canonical release-builder provenance. This package is the sole owner of those optimized bytes and exposes one browser-safe canonical URL; the offline Node host reads that URL and the runtime Worker fetches it instead of `@pmndrs/text` shipping a second copy. Reports keep raw and transport costs distinct. The direct-memory boundary owns every request and response allocation in a module registry. Its fixed-width `#[repr(C)]` response header publishes compiler-derived size, alignment, and offsets from `size_of`, `align_of`, and `offset_of!`. Rust serialization consumes those same facts; build-only generation makes them an exact TypeScript type and value, and CI fails when checked-in output is stale. There is no numeric layout mirror to maintain and no runtime JSON parse, QuickType, JSON Schema, or binding-generator dependency in the baker. Caller-controlled requests are capped at 64 MiB and use fallible reservation; use and release require the exact active pointer/length pair, forged or repeated releases are harmless, checked response arithmetic prevents truncation, and response metadata cannot outlive its owned bytes. The TypeScript wrapper enters cleanup before its first copy, releases each successful allocation after any later failure, and validates every promised Wasm export and response/error field before constructing a public result. It decodes the response while the Wasm allocation is live and copies only the artifact ranges that must survive release, avoiding a redundant full-response copy. The fixed, tiny `WasmState` allocation still uses stable Rust's infallible `Box::new` once per Wasm instance; replacing that theoretical OOM trap would require unstable allocator APIs or a disproportionate static-state design. diff --git a/docs/packages/text.md b/docs/packages/text.md index 1e6cb37a..532817b0 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:cadece5952b3f3f57222acdf576b45cf1d27c76d1d082e3d338be2cb78f16082' +source_digest: 'sha256:13d1410aa9a8d392875c94b0d5360e7ab1c7769e6cfbee8d45a998410b6b154f' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -66,13 +66,13 @@ Status: foundation cutover in progress; publishing-feature stacks follow after m The package owns five runtime layers: -| Layer | Owner | Responsibility | -| --- | --- | --- | -| Font and raster loading | TypeScript core | Validate portable GLB assets, register shaping payloads, decode selected raster resources, and retain font identity. | -| Shaping and layout | Rust/Wasm | Unicode analysis, bidi, font fallback, shaping, line composition, positioning, ellipsis, and semantic query state. | -| Policy and render plan | Rust/Wasm | Interpret a validated renderer policy, pack canonical technique records, coalesce dirty ranges, and emit a compact command buffer. | -| Three.js integration | `@pmndrs/text/three` | Compile policy programs, resolve font/material resources, apply command-buffer deltas, upload dirty ranges, and maintain draw proxies. | -| React integration | `@pmndrs/text/r3f` | Reconcile React values into the same imperative `Text` and `TextGroup` objects. | +| Layer | Owner | Responsibility | +| ----------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| Font and raster loading | TypeScript core | Validate portable GLB assets, register shaping payloads, decode selected raster resources, and retain font identity. | +| Shaping and layout | Rust/Wasm | Unicode analysis, bidi, font fallback, shaping, line composition, positioning, ellipsis, and semantic query state. | +| Policy and render plan | Rust/Wasm | Interpret a validated renderer policy, pack canonical technique records, coalesce dirty ranges, and emit a compact command buffer. | +| Three.js integration | `@pmndrs/text/three` | Compile policy programs, resolve font/material resources, apply command-buffer deltas, upload dirty ranges, and maintain draw proxies. | +| React integration | `@pmndrs/text/r3f` | Reconcile React values into the same imperative `Text` and `TextGroup` objects. | Rust remains `no_std + alloc` with the package allocator contract. It uses the existing compile-time direct-memory mapping for font registrations and the single `text_update(requestOffset, requestLength)` export for retained engine sessions. @@ -80,16 +80,16 @@ TypeScript does not independently shape, lay out, or pack paragraphs. ## Public package surfaces -| Subpath | Purpose | -| --- | --- | -| `@pmndrs/text` | Font/raster contracts, loading, fallback stacks, formatting helpers, paragraph inputs, layout-query values, and portable bakers. | -| `@pmndrs/text/three` | Three `FontLoader`, `Text`, `TextGroup`, material factories, profiling, and policy registration. | -| `@pmndrs/text/three/bitmap` | Bitmap technique, policy program, and canonical TSL shader. | -| `@pmndrs/text/three/msdf` | MSDF technique, policy program, and canonical TSL shader. | -| `@pmndrs/text/three/slug` | Slug technique, policy program, and canonical TSL shader. | -| `@pmndrs/text/r3f` | React Three Fiber ``, ``, and `useFont`. | -| `@pmndrs/text/raster/*` | Renderer-neutral Bitmap, MSDF, and Slug decoding and raster-technique contracts. | -| `@pmndrs/text/bakers/*` | Optional portable raster bakers and validators. | +| Subpath | Purpose | +| --------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `@pmndrs/text` | Font/raster contracts, loading, fallback stacks, formatting helpers, paragraph inputs, layout-query values, and portable bakers. | +| `@pmndrs/text/three` | Three `FontLoader`, `Text`, `TextGroup`, material factories, and policy registration. | +| `@pmndrs/text/three/bitmap` | Bitmap technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/three/msdf` | MSDF technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/three/slug` | Slug technique, policy program, and canonical TSL shader. | +| `@pmndrs/text/r3f` | React Three Fiber ``, ``, and `useFont`. | +| `@pmndrs/text/raster/*` | Renderer-neutral Bitmap, MSDF, and Slug decoding and raster-technique contracts. | +| `@pmndrs/text/bakers/*` | Optional portable raster bakers and validators. | `@pmndrs/text/typegpu`, the TypeScript paragraph engine, paragraph batches/attachments, direct shaping exports, and the text-preparation Worker are removed. TypeGPU is a later adapter stack built against the Rust render plan; it is not a @@ -165,6 +165,13 @@ partial revision. WebGPU may alias compatible Wasm-backed typed arrays. Three's WebGL2 PBO path owns a padded array and therefore requires one retained copy. The architecture does not add complexity to pretend WebGL2 can preserve a Wasm alias it replaces. +Each raster baker's Rust contract generator emits both published JSON and an exact typed TypeScript constant. Bitmap, +MTSDF, and Slug may own different internal ABI shapes—MTSDF exposes both its glyph generator and artifact baker—but their +TypeScript hosts consume those generated constants directly and validate the declared exports once during construction. +There are no instance-ignoring runtime ABI readers. Package builds isolate the distributable MTSDF and Slug +`artifact-baker` feature sets from kernel-only test targets and reject an optimized module missing any contract-declared +artifact export, preventing Cargo's shared top-level artifact path from silently publishing a smaller test variant. + Asynchronous Worker execution is a follow-on host concern. Transfer buffers must return to the Worker when retired so their final collection occurs in the owning realm. It does not restore the deleted TypeScript shaping Worker. @@ -190,9 +197,9 @@ centered glyph row, content height, and complete layout hash exactly; no runtime The latest checked package-size record before final cleanup reports: -| Graph | Raw | gzip | Brotli | -| --- | ---: | ---: | ---: | -| Core JavaScript plus shaper Wasm | 1,211,173 B | 440,875 B | 349,703 B | +| Graph | Raw | gzip | Brotli | +| --------------------------------------- | ----------: | --------: | --------: | +| Core JavaScript plus shaper Wasm | 1,211,173 B | 440,875 B | 349,703 B | | Three adapter plus core and shaper Wasm | 1,454,561 B | 479,863 B | 381,897 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are @@ -202,9 +209,9 @@ The public Three benchmark now supports an outside-only mode that leaves the int one `updateMatrixWorld()` call with a host timer. An eight-warmup/31-sample run over 25,515 positioned glyphs measured 17.68/6.13/5.66/16.37 ms median and 18.11/6.32/6.53/16.57 ms p95 for cold/font-size/width/text updates. Those values cover frame preparation, the complete Rust transaction and render-plan publication, and Three plan application; they exclude -GPU submission. An adjacent phase-instrumented run was indistinguishable within process noise, but the current published -Three graph still contains inactive profiler calls and branches. The production publishing build must remove those hooks, -while benchmark/development instrumentation remains separate. +GPU submission. An adjacent phase-instrumented run was indistinguishable within process noise. Those temporary profiler +exports, calls, branches, and clock reads are now absent from the package source and clean publishing output; benchmark +workload markers and the direct Wasm timer remain outside the shipped library. The canonical direct benchmark loads the packaged `dist/text_shaper.wasm`: Cargo release optimization, LTO, one codegen unit, default-on `simd128`, stripping, and `wasm-opt -Oz --enable-simd` have already run. On the identical Rust artifact, diff --git a/packages/font-baker/src/index.ts b/packages/font-baker/src/index.ts index 55a3f588..80f2bc68 100644 --- a/packages/font-baker/src/index.ts +++ b/packages/font-baker/src/index.ts @@ -2,6 +2,7 @@ import { FONT_BAKER_VERSION, FONT_FORMAT_VERSION } from './contract.js'; import { fontBakerAbi, type FontBakerAbi } from './generated/font-baker-abi.js'; export { FONT_BAKER_VERSION, FONT_FORMAT_VERSION } from './contract.js'; +export { fontBakerAbi } from './generated/font-baker-abi.js'; export interface FontBakeDescriptorV0 { readonly formatVersion: 0; @@ -113,8 +114,7 @@ export async function createFontBaker(source: FontBakerWasmSource): Promise number; diff --git a/packages/font-baker/tests/integration/wasm-package.test.mjs b/packages/font-baker/tests/integration/wasm-package.test.mjs index 0f024e67..11910992 100644 --- a/packages/font-baker/tests/integration/wasm-package.test.mjs +++ b/packages/font-baker/tests/integration/wasm-package.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { FontBakeError, createFontBaker, createFontBakerFromInstance, readFontBakerAbi } from '../../dist/index.js'; +import { FontBakeError, createFontBaker, createFontBakerFromInstance, fontBakerAbi } from '../../dist/index.js'; const [wasm, rustReleaseWasm] = await Promise.all([ readFile(new URL('../../dist/font_baker.wasm', import.meta.url)), @@ -23,10 +23,8 @@ test('the distributed module is the pinned size-optimized zero-import release mo test('the published and generated TypeScript ABI contracts are identical', async () => { const module = await WebAssembly.compile(wasm); assert.deepEqual(WebAssembly.Module.imports(module), []); - const instance = await WebAssembly.instantiate(module, {}); - const generated = readFontBakerAbi(instance); - assert.equal(generated.versions.binaryen, '129.0.0'); - assert.deepEqual(generated, publishedAbi); + assert.equal(fontBakerAbi.versions.binaryen, '129.0.0'); + assert.deepEqual(fontBakerAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, diff --git a/packages/text/scripts/benchmark-paragraph-layout.mts b/packages/text/scripts/benchmark-paragraph-layout.mts index 07554dd1..3f97e798 100644 --- a/packages/text/scripts/benchmark-paragraph-layout.mts +++ b/packages/text/scripts/benchmark-paragraph-layout.mts @@ -1,6 +1,6 @@ /* @workflow { "name": "text:layout-benchmark", - "summary": "Profiles public TextGroup updates from frame preparation through Rust text_update and Three render-plan application.", + "summary": "Measures public TextGroup updates externally through Rust text_update and Three render-plan application.", "requirements": "Built package: pnpm --filter @pmndrs/text build. Accepts --glyphs, --reps, --warmup, --case, --json.", "writes": "stdout only, or the JSON report path passed to --json" } */ @@ -8,8 +8,6 @@ import { writeFile } from 'node:fs/promises'; import { setFlagsFromString } from 'node:v8'; import { runInNewContext } from 'node:vm'; -import { setThreeTextProfiler, type ThreeTextProfilePhase } from '../dist/three.js'; - import { createBenchmarkParagraph, disposeBenchmarkParagraph, @@ -45,11 +43,6 @@ type CaseName = 'cold' | 'font-size' | 'layout-width' | 'text'; interface UpdateProfile { readonly wallMs: number; - readonly frameMs: number; - readonly prepareMs: number; - readonly engineMs: number; - readonly applyMs: number; - readonly transformsMs: number; } interface Sample extends UpdateProfile { @@ -67,11 +60,6 @@ interface CaseReport { readonly rsdPercent: number; readonly perGlyphUs: number; readonly bytesPerUpdate: number; - readonly frameMedianMs: number; - readonly prepareMedianMs: number; - readonly engineMedianMs: number; - readonly applyMedianMs: number; - readonly transformsMedianMs: number; } const options = parseArguments(process.argv.slice(2)); @@ -154,45 +142,19 @@ async function measureCase(name: CaseName, text: string): Promise { rsdPercent: mean === 0 ? 0 : (Math.sqrt(variance) / mean) * 100, perGlyphUs: glyphs === 0 ? 0 : (median * 1000) / glyphs, bytesPerUpdate: bytes, - frameMedianMs: medianOf(sorted(samples, 'frameMs')), - prepareMedianMs: medianOf(sorted(samples, 'prepareMs')), - engineMedianMs: medianOf(sorted(samples, 'engineMs')), - applyMedianMs: medianOf(sorted(samples, 'applyMs')), - transformsMedianMs: medianOf(sorted(samples, 'transformsMs')), }; } function profileUpdate(update: () => void): UpdateProfile { - const durations = new Map(); - if (options.profilePhases) { - setThreeTextProfiler((phase, startedMs, endedMs) => { - durations.set(phase, (durations.get(phase) ?? 0) + endedMs - startedMs); - }); - } const started = performance.now(); - try { - update(); - } finally { - setThreeTextProfiler(undefined); - } - return { - wallMs: performance.now() - started, - frameMs: durations.get('frame.total') ?? 0, - prepareMs: durations.get('frame.prepare') ?? 0, - engineMs: durations.get('engine.update') ?? 0, - applyMs: durations.get('plan.apply') ?? 0, - transformsMs: durations.get('transforms.sync') ?? 0, - }; + update(); + return { wallMs: performance.now() - started }; } function sorted(samples: readonly Sample[], key: Key): number[] { return samples.map((sample) => sample[key]).sort((left, right) => left - right); } -function medianOf(values: readonly number[]): number { - return values[Math.floor(values.length / 2)] ?? 0; -} - function applyChange(name: CaseName, paragraph: ParagraphHandle, repetition: number, text: string): void { // Every repetition applies a value no earlier repetition used, so a retained per-constraint cache can never answer a // measured update. A repeating cycle would let the cache serve part of the run and report a median that no drag, @@ -210,12 +172,12 @@ function printReport(rows: readonly CaseReport[]): void { `\nwarmup ${options.warmup} discarded · ${options.repetitions} recorded repetitions · budget ${budget60.toFixed(2)}ms @60Hz · ${budget120.toFixed(2)}ms @120Hz\n`, ); console.log( - `${'case'.padEnd(13)}${'glyphs'.padStart(8)}${'prepare'.padStart(10)}${'engine'.padStart(10)}${'apply'.padStart(10)}${'frame'.padStart(10)}${'wall'.padStart(10)}${'wall p95'.padStart(11)}${'rsd'.padStart(7)} budget`, + `${'case'.padEnd(13)}${'glyphs'.padStart(8)}${'outside'.padStart(10)}${'p95'.padStart(11)}${'min'.padStart(10)}${'rsd'.padStart(7)} budget`, ); for (const row of rows) { const over = row.medianMs / budget120; console.log( - `${row.name.padEnd(13)}${String(row.glyphs).padStart(8)}${`${row.prepareMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.engineMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.applyMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.frameMedianMs.toFixed(2)}ms`.padStart(10)}${`${row.medianMs.toFixed(2)}ms`.padStart(10)}${`${row.p95Ms.toFixed(2)}ms`.padStart(11)}${`${row.rsdPercent.toFixed(1)}%`.padStart(7)} ${over <= 1 ? 'within 120Hz' : `${over.toFixed(1)}x over 120Hz`}`, + `${row.name.padEnd(13)}${String(row.glyphs).padStart(8)}${`${row.medianMs.toFixed(2)}ms`.padStart(10)}${`${row.p95Ms.toFixed(2)}ms`.padStart(11)}${`${row.minMs.toFixed(2)}ms`.padStart(10)}${`${row.rsdPercent.toFixed(1)}%`.padStart(7)} ${over <= 1 ? 'within 120Hz' : `${over.toFixed(1)}x over 120Hz`}`, ); } console.log('\nThis public Three diagnostic is not the canonical Rust-vs-TypeScript comparison.'); @@ -234,7 +196,6 @@ function parseArguments(argv: readonly string[]) { cases: (cases === undefined ? ['cold', 'font-size', 'layout-width', 'text'] : cases.split(',')) as CaseName[], warmup: Number.parseInt(read('--warmup') ?? String(DEFAULT_WARMUP), 10), repetitions: Number.parseInt(read('--reps') ?? String(DEFAULT_REPETITIONS), 10), - profilePhases: read('--profile-phases') !== '0', jsonPath: read('--json'), }; } diff --git a/packages/text/scripts/build.mjs b/packages/text/scripts/build.mjs index eb94b815..0100638d 100644 --- a/packages/text/scripts/build.mjs +++ b/packages/text/scripts/build.mjs @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process'; -import { chmod, mkdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { captureCommand } from '../../font-baker/scripts/capture-command.mjs'; @@ -17,27 +18,39 @@ if (shaperSimdSetting !== undefined && shaperSimdSetting !== '0' && shaperSimdSe throw new Error('PMNDRS_TEXT_SHAPER_SIMD must be 0 or 1'); } const shaperSimd = shaperSimdSetting !== '0'; +const shaperTargetDirectory = fileURLToPath( + new URL(`../rust/shaper/target/wasm-${shaperSimd ? 'simd128' : 'scalar'}/`, import.meta.url), +); +const mtsdfArtifactTargetDirectory = fileURLToPath( + new URL('../rust/mtsdf-baker/target/artifact-baker-wasm/', import.meta.url), +); +const slugArtifactTargetDirectory = fileURLToPath( + new URL('../rust/slug-baker/target/artifact-baker-wasm/', import.meta.url), +); const shaperRustEnvironment = { ...rustEnvironment, + CARGO_TARGET_DIR: shaperTargetDirectory, CARGO_ENCODED_RUSTFLAGS: `${rustEnvironment.CARGO_ENCODED_RUSTFLAGS}\u001f-C\u001ftarget-feature=${shaperSimd ? '+simd128' : '-simd128'}`, }; +const mtsdfArtifactRustEnvironment = { + ...rustEnvironment, + CARGO_TARGET_DIR: mtsdfArtifactTargetDirectory, +}; +const slugArtifactRustEnvironment = { + ...rustEnvironment, + CARGO_TARGET_DIR: slugArtifactTargetDirectory, +}; const executable = process.platform === 'win32' ? 'wasm-opt.CMD' : 'wasm-opt'; const wasmOpt = fileURLToPath(new URL(`../node_modules/.bin/${executable}`, import.meta.url)); const rustWasm = fileURLToPath( new URL('../rust/bitmap-baker/target/wasm32-unknown-unknown/release/pmndrs_text_bitmap_baker.wasm', import.meta.url), ); const distributedWasm = fileURLToPath(new URL('../dist/bitmap_baker.wasm', import.meta.url)); -const shaperWasm = fileURLToPath( - new URL('../rust/shaper/target/wasm32-unknown-unknown/release/pmndrs_text_shaper.wasm', import.meta.url), -); +const shaperWasm = join(shaperTargetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_shaper.wasm'); const distributedShaperWasm = fileURLToPath(new URL('../dist/text_shaper.wasm', import.meta.url)); -const mtsdfWasm = fileURLToPath( - new URL('../rust/mtsdf-baker/target/wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm', import.meta.url), -); +const mtsdfWasm = join(mtsdfArtifactTargetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm'); const distributedMtsdfWasm = fileURLToPath(new URL('../dist/mtsdf_baker.wasm', import.meta.url)); -const slugWasm = fileURLToPath( - new URL('../rust/slug-baker/target/wasm32-unknown-unknown/release/pmndrs_text_slug_baker.wasm', import.meta.url), -); +const slugWasm = join(slugArtifactTargetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_slug_baker.wasm'); const distributedSlugWasm = fileURLToPath(new URL('../dist/slug_baker.wasm', import.meta.url)); const [bitmapAbiJson, shaperAbiJson, mtsdfAbiJson, slugAbiJson] = await Promise.all([ @@ -133,7 +146,7 @@ await run( '--features', 'artifact-baker', ], - rustEnvironment, + slugArtifactRustEnvironment, ); await run( 'cargo', @@ -149,7 +162,7 @@ await run( '--features', 'artifact-baker', ], - rustEnvironment, + mtsdfArtifactRustEnvironment, ); await run( 'cargo', @@ -166,11 +179,9 @@ await run( ], shaperRustEnvironment, ); -await run(tsc, ['-p', 'tsconfig.build.json']); +await rm(new URL('../dist/', import.meta.url), { recursive: true, force: true }); await mkdir(new URL('../dist/', import.meta.url), { recursive: true }); -await rm(new URL('../dist/font_baker.wasm', import.meta.url), { force: true }); -await rm(new URL('../dist/mtsdf-baker-abi-v0.json', import.meta.url), { force: true }); -await rm(new URL('../dist/slug-baker-abi-v1.json', import.meta.url), { force: true }); +await run(tsc, ['-p', 'tsconfig.build.json']); await run(wasmOpt, [ '--enable-bulk-memory', '--enable-nontrapping-float-to-int', @@ -204,6 +215,10 @@ await run(wasmOpt, [ '-o', distributedSlugWasm, ]); +await Promise.all([ + assertMtsdfArtifactBakerExports(distributedMtsdfWasm, mtsdfAbiJson), + assertSlugArtifactBakerExports(distributedSlugWasm, slugAbiJson), +]); await writeFile(new URL('../dist/bitmap-baker-abi-v0.json', import.meta.url), bitmapAbiJson); await writeFile(new URL('../dist/text-shaper-abi-v0.json', import.meta.url), shaperAbiJson); await writeFile(new URL('../dist/mtsdf-baker-abi-v1.json', import.meta.url), mtsdfAbiJson); @@ -226,3 +241,27 @@ function run(command, args, environment = process.env) { function runCapture(command, args) { return captureCommand(command, args, { cwd: packageRoot }); } + +async function assertMtsdfArtifactBakerExports(wasmPath, abiJson) { + const abi = JSON.parse(abiJson); + await assertWasmExports(wasmPath, Object.values(abi.artifactBaker.functions), 'MTSDF'); +} + +async function assertSlugArtifactBakerExports(wasmPath, abiJson) { + const abi = JSON.parse(abiJson); + await assertWasmExports( + wasmPath, + [...Object.values(abi.functions), ...Object.values(abi.segmented.functions)], + 'Slug', + ); +} + +async function assertWasmExports(wasmPath, functions, label) { + const module = await WebAssembly.compile(await readFile(wasmPath)); + const exports = new Set(WebAssembly.Module.exports(module).map(({ name }) => name)); + for (const definition of functions) { + if (!exports.has(definition.export)) { + throw new Error(`${label} distributable Wasm is missing artifact-baker export ${definition.export}`); + } + } +} diff --git a/packages/text/scripts/test-mtsdf-core-wasm.mjs b/packages/text/scripts/test-mtsdf-core-wasm.mjs index e71f5d6a..7c1ac26e 100644 --- a/packages/text/scripts/test-mtsdf-core-wasm.mjs +++ b/packages/text/scripts/test-mtsdf-core-wasm.mjs @@ -1,10 +1,12 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; const packageRoot = fileURLToPath(new URL('../', import.meta.url)); const manifest = 'rust/mtsdf-baker/Cargo.toml'; +const targetDirectory = fileURLToPath(new URL('../rust/mtsdf-baker/target/kernel-only-wasm/', import.meta.url)); execFileSync( 'cargo', [ @@ -17,12 +19,10 @@ execFileSync( '--locked', '--no-default-features', ], - { cwd: packageRoot, stdio: 'inherit' }, + { cwd: packageRoot, env: { ...process.env, CARGO_TARGET_DIR: targetDirectory }, stdio: 'inherit' }, ); -const bytes = await readFile( - new URL('../rust/mtsdf-baker/target/wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm', import.meta.url), -); +const bytes = await readFile(join(targetDirectory, 'wasm32-unknown-unknown/release/pmndrs_text_mtsdf_baker.wasm')); const module = await WebAssembly.compile(bytes); assert.deepEqual(WebAssembly.Module.imports(module), []); const { exports } = await WebAssembly.instantiate(module, {}); diff --git a/packages/text/src/bakers/bitmap.ts b/packages/text/src/bakers/bitmap.ts index c5fc7263..e1c2c75d 100644 --- a/packages/text/src/bakers/bitmap.ts +++ b/packages/text/src/bakers/bitmap.ts @@ -17,6 +17,8 @@ import { } from '../internal/bitmap-contract.js'; import type { RasterCoverage } from '../raster-coverage.js'; +export { bitmapBakerAbi } from '../generated/bitmap-baker-abi.js'; + export interface BitmapBakerOptions { readonly strikes: readonly [number, ...number[]]; readonly coverage?: RasterCoverage; @@ -83,8 +85,7 @@ export async function createBitmapBaker(source: BitmapBakerWasmSource): Promise< } export function createBitmapBakerFromInstance(instance: WebAssembly.Instance): BitmapBakerCore { - const abi = readBitmapBakerAbi(instance); - return createDirectRasterBakerFromInstance(instance, abi, { + return createDirectRasterBakerFromInstance(instance, bitmapBakerAbi, { label: 'bitmap baker', kind: BITMAP_KIND, extension: BITMAP_EXTENSION, @@ -94,11 +95,6 @@ export function createBitmapBakerFromInstance(instance: WebAssembly.Instance): B }); } -export function readBitmapBakerAbi(instance: WebAssembly.Instance): BitmapBakerAbiV0 { - void instance; - return bitmapBakerAbi; -} - export function bitmapBakerFromCore( core: BitmapBakerCore, ): RasterBakerModule<'bitmap', BitmapBakerOptions, BitmapDescriptorV0> { diff --git a/packages/text/src/bakers/msdf.ts b/packages/text/src/bakers/msdf.ts index e328530f..fdf53df8 100644 --- a/packages/text/src/bakers/msdf.ts +++ b/packages/text/src/bakers/msdf.ts @@ -23,6 +23,8 @@ import { type MsdfDescriptorV0, } from '../internal/msdf-contract.js'; +export { mtsdfBakerAbi as msdfBakerAbi } from '../generated/mtsdf-baker-abi.js'; + export type MsdfBakerOptions = MsdfOptions | undefined; export interface MsdfBakerRequestV0 { @@ -86,7 +88,7 @@ export async function createMsdfBaker(source: MsdfBakerWasmSource): Promise { return { kind: MSDF_KIND, diff --git a/packages/text/src/bakers/slug.ts b/packages/text/src/bakers/slug.ts index ab789790..52610383 100644 --- a/packages/text/src/bakers/slug.ts +++ b/packages/text/src/bakers/slug.ts @@ -20,6 +20,8 @@ import { } from '../internal/slug-contract.js'; import { cacheSuccessfulPromise } from '../internal/successful-promise-cache.js'; +export { slugBakerAbi } from '../generated/slug-baker-abi.js'; + export type SlugBakerOptions = undefined; export interface SlugBakerRequestV0 { @@ -84,7 +86,7 @@ export async function createSlugBaker(source: SlugBakerWasmSource): Promise( instance, - readSlugBakerAbi(instance) satisfies DirectRasterBakerAbi, + slugBakerAbi satisfies DirectRasterBakerAbi, { label: 'Slug baker', kind: SLUG_KIND, @@ -96,11 +98,6 @@ export function createSlugBakerFromInstance(instance: WebAssembly.Instance): Slu ); } -export function readSlugBakerAbi(instance: WebAssembly.Instance): SlugBakerAbiV0 { - void instance; - return slugBakerAbi; -} - export function slugBakerFromCore( core: SlugBakerCore, ): RasterBakerModule { diff --git a/packages/text/src/internal/mtsdf-generator.ts b/packages/text/src/internal/mtsdf-generator.ts index 427e7060..5ca178e8 100644 --- a/packages/text/src/internal/mtsdf-generator.ts +++ b/packages/text/src/internal/mtsdf-generator.ts @@ -1,3 +1,7 @@ +import { mtsdfBakerAbi, type MtsdfBakerAbi } from '../generated/mtsdf-baker-abi.js'; + +export const mtsdfGeneratorAbi: MtsdfBakerAbi = mtsdfBakerAbi; + export type MtsdfGeneratorWasmSource = BufferSource | WebAssembly.Module; export type MtsdfOutlineCommand = @@ -83,17 +87,18 @@ export async function createMtsdfGenerator(source: MtsdfGeneratorWasmSource): Pr } export function createMtsdfGeneratorFromInstance(instance: WebAssembly.Instance): MtsdfGenerator { - const abi = readMtsdfGeneratorAbi(instance); - const exports = readExports(instance.exports, abi); + const exports = readExports(instance.exports, mtsdfGeneratorAbi); return { generate(request) { - const encoded = encodeRequest(request, abi); + const encoded = encodeRequest(request, mtsdfGeneratorAbi); const pointer = exports.allocate(encoded.byteLength); if (pointer === 0) throw new RangeError('MTSDF generator Wasm allocation failed'); try { copyToMemory(exports.memory, pointer, encoded); const status = exports.generate(pointer, encoded.byteLength); - if (status !== abi.status.ok) throw new MtsdfGenerationError(statusCode(status, abi)); + if (status !== mtsdfGeneratorAbi.status.ok) { + throw new MtsdfGenerationError(statusCode(status, mtsdfGeneratorAbi)); + } const width = checkedDimension(request.region.innerWidth, request.region.paddingX, 'width'); const height = checkedDimension(request.region.innerHeight, request.region.paddingY, 'height'); @@ -115,11 +120,6 @@ export function createMtsdfGeneratorFromInstance(instance: WebAssembly.Instance) }; } -export function readMtsdfGeneratorAbi(instance: WebAssembly.Instance): MtsdfGeneratorAbiV1 { - readExports(instance.exports, mtsdfBakerAbi); - return mtsdfBakerAbi; -} - function encodeRequest(request: MtsdfGlyphRequest, abi: MtsdfGeneratorAbiV1): Uint8Array { validateRequest(request); const requestLayout = abi.layouts.request; @@ -282,5 +282,3 @@ function checkedSum(...values: number[]): number { } return sum; } - -import { mtsdfBakerAbi, type MtsdfBakerAbi } from '../generated/mtsdf-baker-abi.js'; diff --git a/packages/text/src/three.ts b/packages/text/src/three.ts index 397404ef..ebb17b24 100644 --- a/packages/text/src/three.ts +++ b/packages/text/src/three.ts @@ -26,8 +26,6 @@ export type { ThreePlanProgramMaterialContext, ThreeRasterPlanProgram, } from './three/plan-program-registry.js'; -export { setThreeTextProfiler, threeTextUserTimingProfiler } from './three/profiler.js'; -export type { ThreeTextProfiler, ThreeTextProfilePhase } from './three/profiler.js'; export { msdfShader } from './three/msdf-shader.js'; export type { ThreeMsdfInstanceNodes, ThreeMsdfShaderOutput, ThreeMsdfShaderResources } from './three/msdf-shader.js'; export type { diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 2a140815..bc267036 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -14,7 +14,6 @@ import { msdfShader } from './msdf-shader.js'; import { slugShader, type ThreeSlugPageResources } from './slug-shader.js'; import type { ThreeTextMaterialContext } from './material.js'; import type { ThreePlanProgramBuffer } from './plan-program-registry.js'; -import { textProfileBegin, textProfileEnd } from './profiler.js'; type ScalarArray = Float32Array | Uint32Array | Uint16Array; @@ -515,7 +514,6 @@ export class ThreeTextRenderPlanExecutor { #ensureOriginRecords(): void { if (this.#originRecords.size !== 0) return; - const started = textProfileBegin(); for (const segment of this.#originSegments) { if (!(segment.origins.array instanceof Float32Array) || !(segment.stableIds.array instanceof Uint32Array)) continue; @@ -533,7 +531,6 @@ export class ThreeTextRenderPlanExecutor { }); } } - textProfileEnd('origins.index', started); } #transformRealization(buffers: ReadonlyMap, transformId: number): TransformRealization { diff --git a/packages/text/src/three/profiler.ts b/packages/text/src/three/profiler.ts deleted file mode 100644 index cf866e01..00000000 --- a/packages/text/src/three/profiler.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** Synchronous host phases surrounding one retained Rust text update. */ -export type ThreeTextProfilePhase = - | 'frame.total' - | 'frame.prepare' - | 'engine.update' - | 'plan.apply' - | 'origins.index' - | 'semantic.read' - | 'transforms.sync'; - -/** Receives one completed phase whose timestamps share the `performance.now()` origin. */ -export type ThreeTextProfiler = (phase: ThreeTextProfilePhase, startedMs: number, endedMs: number) => void; - -let activeProfiler: ThreeTextProfiler | undefined; - -/** Installs optional process-wide diagnostics. Passing `undefined` restores the allocation-free inactive path. */ -export function setThreeTextProfiler(profiler: ThreeTextProfiler | undefined): void { - activeProfiler = profiler; -} - -/** Creates Chrome/Node User Timing entries without requiring paired named marks. */ -export function threeTextUserTimingProfiler(prefix = '@pmndrs/text'): ThreeTextProfiler { - return (phase, startedMs, endedMs) => { - performance.measure(`${prefix} ${phase}`, { start: startedMs, duration: endedMs - startedMs }); - }; -} - -export function textProfileBegin(): number { - return activeProfiler === undefined ? 0 : performance.now(); -} - -export function textProfileEnd(phase: ThreeTextProfilePhase, startedMs: number): void { - if (activeProfiler !== undefined) activeProfiler(phase, startedMs, performance.now()); -} diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index a88a0ce0..c4061a1d 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -43,7 +43,6 @@ import { type ThreeTextEngineStackLease, } from './engine-runtime.js'; import type { ThreeTextMaterial } from './material.js'; -import { textProfileBegin, textProfileEnd } from './profiler.js'; const MAX_TEXT_ENGINE_OUTPUT_BYTES = 64 * 1024 * 1024; const TEXT_CHANGE = 1 << 0; @@ -561,7 +560,6 @@ class ThreeTextBatchBinding { } synchronize(semanticViewMask = 0): void { if (this.#disposed) return; - const frameStarted = textProfileBegin(); const ordered = [...this.#paragraphs.entries()].sort( ([leftText, left], [rightText, right]) => leftText.renderOrder - rightText.renderOrder || left.id - right.id, ); @@ -574,16 +572,12 @@ class ThreeTextBatchBinding { : []; }); if (changed.length === 0 && this.#removed.length === 0) { - const transformsStarted = textProfileBegin(); this.#target.syncTransforms(); - textProfileEnd('transforms.sync', transformsStarted); if (semanticViewMask !== 0 && !this.#hasSemanticViews(semanticViewMask)) { this.#retainSemanticViews(this.#querySemanticViews(semanticViewMask), semanticViewMask); } - textProfileEnd('frame.total', frameStarted); return; } - const preparingStarted = textProfileBegin(); const paragraphMutations = [ ...this.#removed.map((paragraph) => ({ opcode: 'remove' as const, paragraphId: paragraph.id })), ...changed.map(({ paragraph, order }) => ({ @@ -661,12 +655,9 @@ class ThreeTextBatchBinding { constraints, regions, }); - textProfileEnd('frame.prepare', preparingStarted); let publication: TextEnginePublication; try { - const updateStarted = textProfileBegin(); publication = this.#session.update(frame); - textProfileEnd('engine.update', updateStarted); } catch (error) { if (error instanceof TextEngineStatusError) { error.message += @@ -704,9 +695,7 @@ class ThreeTextBatchBinding { this.#layoutInspections.clear(); committed = true; try { - const applyStarted = textProfileBegin(); this.#target.apply(publication); - textProfileEnd('plan.apply', applyStarted); this.#lastPublication = undefined; } catch (error) { this.#lastPublication = ownPublication(publication); @@ -714,7 +703,6 @@ class ThreeTextBatchBinding { } this.#acknowledgedPublicationGeneration = publication.publicationGeneration; this.#retainSemanticViews(publication, semanticViewMask); - textProfileEnd('frame.total', frameStarted); } catch (error) { if (!committed) { for (const leases of pendingLeases.values()) releaseStackLeases(leases); @@ -798,7 +786,6 @@ class ThreeTextBatchBinding { #querySemanticViews(semanticViewMask: number): TextEnginePublication { const totalTextLength = [...this.#paragraphs.keys()].reduce((total, entry) => total + entry.text.length, 0); - const updateStarted = textProfileBegin(); const publication = this.#session.update( compileTextEngineFrameUpdate({ sessionId: this.#session.handle, @@ -817,7 +804,6 @@ class ThreeTextBatchBinding { ), }), ); - textProfileEnd('engine.update', updateStarted); this.#engineRevision = publication.engineRevision; this.#planRevision = publication.planRevision; return publication; @@ -835,7 +821,6 @@ class ThreeTextBatchBinding { #retainSemanticViews(publication: TextEnginePublication, semanticViewMask: number): void { if (semanticViewMask === 0) return; - const readingStarted = textProfileBegin(); if (semanticViewMask === textShaperAbi.engine.semanticViewMasks.measurement) { for (const [paragraphId, measurement] of readTextEngineMeasurements(publication)) { const measuredText = this.#textsByParagraph.get(paragraphId); @@ -853,7 +838,6 @@ class ThreeTextBatchBinding { throw new RangeError(`unsupported semantic view mask ${semanticViewMask}`); } this.#acknowledgedPublicationGeneration = publication.publicationGeneration; - textProfileEnd('semantic.read', readingStarted); } } diff --git a/packages/text/tests/integration/bitmap-baker.test.mjs b/packages/text/tests/integration/bitmap-baker.test.mjs index 8185d175..8ddbb2bc 100644 --- a/packages/text/tests/integration/bitmap-baker.test.mjs +++ b/packages/text/tests/integration/bitmap-baker.test.mjs @@ -4,10 +4,10 @@ import test from 'node:test'; import { RasterCoverageError } from '@pmndrs/text'; import { + bitmapBakerAbi, bitmapBakerFromCore, createBitmapBaker, createBitmapBakerFromInstance, - readBitmapBakerAbi, } from '@pmndrs/text/bakers/bitmap'; import { bitmap, bitmapDescriptor, bitmapRasterKey } from '@pmndrs/text/raster/bitmap'; import { validateBitmapArtifact } from '@pmndrs/text/bakers/bitmap/validate'; @@ -45,17 +45,16 @@ async function bake(core, source, pages) { } test('ships one generated progress import and bundles its generated ABI in TypeScript', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - const generated = readBitmapBakerAbi(instance); - assert.deepEqual(generated, publishedAbi); + assert.deepEqual(bitmapBakerAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, ); - assert.deepEqual(generated.versions, { + assert.deepEqual(bitmapBakerAbi.versions, { bitmapFormat: 0, generator: '0.0.0', ktx2: '0.5.0', diff --git a/packages/text/tests/integration/mtsdf-baker.test.mjs b/packages/text/tests/integration/mtsdf-baker.test.mjs index 7df05887..60e8252c 100644 --- a/packages/text/tests/integration/mtsdf-baker.test.mjs +++ b/packages/text/tests/integration/mtsdf-baker.test.mjs @@ -7,8 +7,8 @@ import { RasterCoverageError } from '@pmndrs/text'; import { createMsdfBaker, createMsdfBakerFromInstance, + msdfBakerAbi, msdfBakerFromCore, - readMsdfBakerAbi, } from '@pmndrs/text/bakers/msdf'; import { MsdfArtifactValidationError, validateMsdfArtifact } from '@pmndrs/text/bakers/msdf/validate'; import { @@ -57,17 +57,16 @@ async function setup() { } test('ships one generated progress import and bundles its artifact contract in TypeScript', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - const generated = readMsdfBakerAbi(instance); - assert.deepEqual(generated, publishedAbi); + assert.deepEqual(msdfBakerAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, ); - assert.deepEqual(generated.artifactBaker.versions, { + assert.deepEqual(msdfBakerAbi.artifactBaker.versions, { generator: '0.0.0', ktx2: '0.5.0', msdfFormat: 0, diff --git a/packages/text/tests/integration/mtsdf-generator.test.mjs b/packages/text/tests/integration/mtsdf-generator.test.mjs index cbc6e12f..dfb208ec 100644 --- a/packages/text/tests/integration/mtsdf-generator.test.mjs +++ b/packages/text/tests/integration/mtsdf-generator.test.mjs @@ -7,7 +7,7 @@ import { MtsdfGenerationError, createMtsdfGenerator, createMtsdfGeneratorFromInstance, - readMtsdfGeneratorAbi, + mtsdfGeneratorAbi, } from '../../dist/internal/mtsdf-generator.js'; import { mtsdfOracleCases } from '../fixtures/mtsdf-oracle-cases.mjs'; @@ -24,11 +24,11 @@ async function setup() { } test('ships an optimized module with the exact progress import and TypeScript ABI', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - assert.deepEqual(readMtsdfGeneratorAbi(instance), publishedAbi); + assert.deepEqual(mtsdfGeneratorAbi, publishedAbi); assert.equal( WebAssembly.Module.exports(module).some(({ name }) => name.includes('abi_')), false, diff --git a/packages/text/tests/integration/slug-baker.test.mjs b/packages/text/tests/integration/slug-baker.test.mjs index 9b35b6ba..4257ae1e 100644 --- a/packages/text/tests/integration/slug-baker.test.mjs +++ b/packages/text/tests/integration/slug-baker.test.mjs @@ -5,8 +5,8 @@ import test from 'node:test'; import { createSlugBaker, createSlugBakerFromInstance, - readSlugBakerAbi, SlugBakeError, + slugBakerAbi, slugBakerFromCore, } from '../../dist/bakers/slug.js'; import { validateSlugArtifact } from '../../dist/bakers/slug-validator.js'; @@ -31,15 +31,14 @@ async function setup() { } test('ships the generated generic direct/segmented Slug ABI', async () => { - const { module, instance } = await setup(); + const { module } = await setup(); assert.deepEqual(WebAssembly.Module.imports(module), [ { module: 'env', name: 'pmndrs_text_bake_progress', kind: 'function' }, ]); - const generated = readSlugBakerAbi(instance); - assert.deepEqual(generated, JSON.parse(await readFile(abiUrl, 'utf8'))); - assert.equal(generated.response.magic, 'PMSL'); - assert.equal(generated.segmented.chunkByteLength, 8 * 1024 * 1024); - assert.deepEqual(generated.versions, { + assert.deepEqual(slugBakerAbi, JSON.parse(await readFile(abiUrl, 'utf8'))); + assert.equal(slugBakerAbi.response.magic, 'PMSL'); + assert.equal(slugBakerAbi.segmented.chunkByteLength, 8 * 1024 * 1024); + assert.deepEqual(slugBakerAbi.versions, { generator: '0.0.0', ktx2: '0.5.0', readFonts: '0.42.1', diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 65d0d8c6..dd066239 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import { createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; -import { setThreeTextProfiler, Text, TextGroup } from '@pmndrs/text/three'; +import { Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; const fontUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); @@ -32,10 +32,6 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr const group = new TextGroup({ renderOrder: 12 }); const container = new THREE.Object3D(); const label = new Text({ font, text: 'First frame' }); - let originIndexBuilds = 0; - setThreeTextProfiler((phase) => { - if (phase === 'origins.index') originIndexBuilds += 1; - }); container.add(label); group.add(container); scene.add(group); @@ -47,7 +43,6 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(group.textCount, 1); assert.equal(label.layout, undefined, 'rendering must not materialize layout readback'); assert.equal(group.error, undefined); - assert.equal(originIndexBuilds, 0, 'rendering must not index glyph origins until the presentation API needs them'); const firstDraws = group.children.filter((child) => child.isMesh); assert.ok(firstDraws.length > 0); assert.equal(firstDraws[0].geometry.instanceCount, 10, 'the GPU plan omits the non-rendering space glyph'); @@ -73,7 +68,6 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(group.children.filter((child) => child.isMesh)[0], firstDraws[0]); const origins = label.snapshotGlyphOrigins(); - assert.equal(originIndexBuilds, 1, 'the first presentation query builds the retained origin index once'); assert.equal(origins.layout, inspection); assert.deepEqual(origins.displayedX, origins.shapedX); assert.deepEqual(origins.displayedY, origins.shapedY); @@ -85,7 +79,6 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr assert.equal(presented.displayedX[0], origins.shapedX[0] + 3); label.clearGlyphOriginOverrides(); assert.deepEqual(label.snapshotGlyphOrigins().displayedX, origins.shapedX); - setThreeTextProfiler(undefined); group.renderOrder = 20; scene.updateMatrixWorld(); @@ -136,10 +129,8 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr test('TextGroup realizes two public Text objects as one indexed Rust draw', async () => { const registry = new FontRegistry(); - const runtime = await createTextRuntime({ - registry, - wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), - }); + const instrumented = await createInstrumentedRuntime(registry); + const runtime = instrumented.runtime; const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(fontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, @@ -169,30 +160,23 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn const initialRightMeasurement = right.measureLayout(); assert.ok(initialLeftMeasurement); assert.ok(initialRightMeasurement); - let updateCrossings = 0; - setThreeTextProfiler((phase) => { - if (phase === 'engine.update') updateCrossings += 1; - }); - try { - left.set({}); - assert.equal(left.measureLayout(), initialLeftMeasurement, 'an empty update must preserve the cached measurement'); - scene.updateMatrixWorld(); - assert.equal(updateCrossings, 0, 'an empty update and cached measurement must not cross into Rust'); - - left.contentBox = { width: { mode: 'exact', size: 100 }, wrap: 'word' }; - const resizedMeasurement = left.measureLayout(); - assert.ok(resizedMeasurement, 'a pending mutation must produce its requested measurement'); - assert.notEqual(resizedMeasurement, initialLeftMeasurement); - assert.deepEqual( - right.measureLayout(), - initialRightMeasurement, - 'one requested semantic publication must populate every retained paragraph', - ); - scene.updateMatrixWorld(); - } finally { - setThreeTextProfiler(undefined); - } - assert.equal(updateCrossings, 1, 'mutation, render plan, and demanded measurement must share one text_update'); + instrumented.reset(); + left.set({}); + assert.equal(left.measureLayout(), initialLeftMeasurement, 'an empty update must preserve the cached measurement'); + scene.updateMatrixWorld(); + assert.equal(instrumented.crossings, 0, 'an empty update and cached measurement must not cross into Rust'); + + left.contentBox = { width: { mode: 'exact', size: 100 }, wrap: 'word' }; + const resizedMeasurement = left.measureLayout(); + assert.ok(resizedMeasurement, 'a pending mutation must produce its requested measurement'); + assert.notEqual(resizedMeasurement, initialLeftMeasurement); + assert.deepEqual( + right.measureLayout(), + initialRightMeasurement, + 'one requested semantic publication must populate every retained paragraph', + ); + scene.updateMatrixWorld(); + assert.equal(instrumented.crossings, 1, 'mutation, render plan, and demanded measurement must share one text_update'); const leftOrigins = left.snapshotGlyphOrigins(); const rightOrigins = right.snapshotGlyphOrigins(); @@ -226,19 +210,12 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn 'an authoritative command-buffer update must retire the previous presentation override', ); - updateCrossings = 0; - setThreeTextProfiler((phase) => { - if (phase === 'engine.update') updateCrossings += 1; - }); - try { - left.text = 'ABC'; - const replacedMeasurement = left.measureLayout(); - assert.equal(replacedMeasurement?.glyphCount, 3); - scene.updateMatrixWorld(); - } finally { - setThreeTextProfiler(undefined); - } - assert.equal(updateCrossings, 1, 'text replacement and demanded measurement must share one text_update'); + instrumented.reset(); + left.text = 'ABC'; + const replacedMeasurement = left.measureLayout(); + assert.equal(replacedMeasurement?.glyphCount, 3); + scene.updateMatrixWorld(); + assert.equal(instrumented.crossings, 1, 'text replacement and demanded measurement must share one text_update'); const replacedDraws = group.children.filter((child) => child.isMesh); assert.equal(replacedDraws.length, 1); assert.equal(replacedDraws[0].geometry.instanceCount, 5, 'the published command buffer must include the new glyph'); @@ -250,6 +227,38 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn runtime.dispose(); }); +async function createInstrumentedRuntime(registry) { + const wasm = await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)); + const abi = JSON.parse(await readFile(new URL('../../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8')); + const originalInstantiate = WebAssembly.instantiate; + let crossings = 0; + WebAssembly.instantiate = async (source, imports) => { + const instance = await originalInstantiate(source, imports); + const exports = { ...instance.exports }; + const update = exports[abi.functions.textUpdate]; + assert.equal(typeof update, 'function', 'instrumented shaper must export text_update'); + exports[abi.functions.textUpdate] = (...arguments_) => { + crossings += 1; + return update(...arguments_); + }; + return { exports }; + }; + try { + const runtime = await createTextRuntime({ registry, wasm }); + return { + runtime, + get crossings() { + return crossings; + }, + reset() { + crossings = 0; + }, + }; + } finally { + WebAssembly.instantiate = originalInstantiate; + } +} + test('Bitmap strike changes fully initialize a replacement indexed batch', async () => { const registry = new FontRegistry(); const runtime = await createTextRuntime({ diff --git a/packages/text/tests/types/three-v1-api.test.ts b/packages/text/tests/types/three-v1-api.test.ts index ec145a7b..cd361f57 100644 --- a/packages/text/tests/types/three-v1-api.test.ts +++ b/packages/text/tests/types/three-v1-api.test.ts @@ -1,15 +1,7 @@ import type { LoadedFont } from '../../src/index.js'; import { bitmap } from '../../src/raster/bitmap-technique.js'; import { msdf } from '../../src/raster/msdf.js'; -import { - FontLoader, - setThreeTextProfiler, - span, - Text, - TextGroup, - threeTextUserTimingProfiler, - txt, -} from '../../src/three.js'; +import { FontLoader, span, Text, TextGroup, txt } from '../../src/three.js'; declare const bitmapFont: LoadedFont; declare const mtsdfFont: LoadedFont; @@ -18,8 +10,6 @@ const emphasis = span(bitmapFont, { color: '#ff00ff' }); const label = new Text({ font: bitmapFont, text: txt`Typed ${emphasis`span`}` }); const labels = new TextGroup({ compositing: 'independent' }); const compositing: 'ordered' | 'independent' = labels.compositing; -setThreeTextProfiler(threeTextUserTimingProfiler('test')); -setThreeTextProfiler(undefined); labels.add(label); label.text = 'Updated'; label.setCapacity({ size: 64, policy: 'grow' }); From 73b89f2139113f961be1c074b0a4b1c86df85200 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 14:42:27 -0400 Subject: [PATCH 096/128] feat(text): add r3f hello world example --- apps/r3f-hello-world/.gitignore | 1 + apps/r3f-hello-world/.oxlintrc.json | 21 +++ apps/r3f-hello-world/README.md | 19 +++ .../assets/font-awesome-world.font.glb | Bin 0 -> 184948 bytes .../assets/inter-latin.font.glb | Bin 0 -> 2356064 bytes apps/r3f-hello-world/assets/manifest.json | 34 ++++ apps/r3f-hello-world/index.html | 13 ++ apps/r3f-hello-world/package.json | 37 +++++ .../scripts/generate-fonts.mts | 104 ++++++++++++ apps/r3f-hello-world/src/app.tsx | 22 +++ apps/r3f-hello-world/src/main.tsx | 23 +++ apps/r3f-hello-world/src/styles.css | 29 ++++ apps/r3f-hello-world/src/technique-scene.tsx | 152 ++++++++++++++++++ apps/r3f-hello-world/tsconfig.json | 13 ++ apps/r3f-hello-world/vite.config.ts | 40 +++++ docs/packages/benchmarks.md | 2 +- docs/packages/font-baker.md | 2 +- docs/packages/index.md | 1 + docs/packages/r3f-hello-world.md | 55 +++++++ docs/packages/text.md | 2 +- pnpm-lock.yaml | 55 +++++++ 21 files changed, 622 insertions(+), 3 deletions(-) create mode 100644 apps/r3f-hello-world/.gitignore create mode 100644 apps/r3f-hello-world/.oxlintrc.json create mode 100644 apps/r3f-hello-world/README.md create mode 100644 apps/r3f-hello-world/assets/font-awesome-world.font.glb create mode 100644 apps/r3f-hello-world/assets/inter-latin.font.glb create mode 100644 apps/r3f-hello-world/assets/manifest.json create mode 100644 apps/r3f-hello-world/index.html create mode 100644 apps/r3f-hello-world/package.json create mode 100644 apps/r3f-hello-world/scripts/generate-fonts.mts create mode 100644 apps/r3f-hello-world/src/app.tsx create mode 100644 apps/r3f-hello-world/src/main.tsx create mode 100644 apps/r3f-hello-world/src/styles.css create mode 100644 apps/r3f-hello-world/src/technique-scene.tsx create mode 100644 apps/r3f-hello-world/tsconfig.json create mode 100644 apps/r3f-hello-world/vite.config.ts create mode 100644 docs/packages/r3f-hello-world.md diff --git a/apps/r3f-hello-world/.gitignore b/apps/r3f-hello-world/.gitignore new file mode 100644 index 00000000..849ddff3 --- /dev/null +++ b/apps/r3f-hello-world/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/apps/r3f-hello-world/.oxlintrc.json b/apps/r3f-hello-world/.oxlintrc.json new file mode 100644 index 00000000..a516e221 --- /dev/null +++ b/apps/r3f-hello-world/.oxlintrc.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json", + "plugins": ["react", "jsx-a11y"], + "categories": { "correctness": "error", "perf": "warn", "suspicious": "warn" }, + "env": { "browser": true, "es2025": true }, + "settings": { "react": { "version": "19.2.8" } }, + "rules": { + "react/react-in-jsx-scope": "off", + "react/react-compiler": "error", + "react/rules-of-hooks": "error", + "eslint/no-await-in-loop": "off", + "eslint/no-restricted-imports": [ + "error", + { + "paths": ["three", "@react-three/fiber"], + "patterns": ["three/src/*", "three/addons/*", "three/examples/*"] + } + ] + }, + "ignorePatterns": ["dist/**"] +} diff --git a/apps/r3f-hello-world/README.md b/apps/r3f-hello-world/README.md new file mode 100644 index 00000000..08d5d444 --- /dev/null +++ b/apps/r3f-hello-world/README.md @@ -0,0 +1,19 @@ +# React Three Fiber hello world + +This is the smallest product-shaped `@pmndrs/text` example in the workspace. It renders `Hello world` through the public React Three Fiber API, resolves the globe from a Font Awesome fallback font, and switches between Bitmap, MSDF, and Slug using controls rendered inside the canvas. + +```sh +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world dev +``` + +The app uses React 19, the React Compiler, the WebGPU R3F entry point, and Three's automatic WebGL fallback. Its two checked-in GLBs share shaping data across the three embedded raster techniques: + +- `inter-latin.font.glb` is a true Basic Latin source subset (`U+0020–U+007E`). +- `font-awesome-world.font.glb` contains only six globe/earth variants. + +Regeneration requires exactly HarfBuzz 14.2.0. The check performs fresh source subsets and complete Bitmap/MSDF/Slug bakes, then requires byte-identical GLBs and manifest hashes. + +```sh +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world assets:check +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world check +``` diff --git a/apps/r3f-hello-world/assets/font-awesome-world.font.glb b/apps/r3f-hello-world/assets/font-awesome-world.font.glb new file mode 100644 index 0000000000000000000000000000000000000000..006f08d67fe47cb623fb4a0d77965ed9b5990cba GIT binary patch literal 184948 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 HF{1wk?WzQq literal 0 HcmV?d00001 diff --git a/apps/r3f-hello-world/assets/inter-latin.font.glb b/apps/r3f-hello-world/assets/inter-latin.font.glb new file mode 100644 index 0000000000000000000000000000000000000000..ec5e03a684994c14f05ed5efc283b75e6e92d5f4 GIT binary patch literal 2356064 zcmeFa2Y6LQ^FO}lmJ|qt-kYHbQql`Wia<~h5D*X$NCl)P!61lWL+l7D78Dc&6$Dgl zpokS4qF7!8R0Qe0LrCuaKeOeOdoLlNzxVt8pNGBAJ-a(QJ6mUVwwyh~NB8aGVvG$t zQJ=MmWbB4MJ$p=vO3Tg7%8P0>C2Dxq*sPqiym2{Et)kkGA2T*HC%0u@)}*}V+2h9M zHBV0)m6a3KEb8{GoZOM)#sVj{MRe5EW>M)Av$L~u`i;!W&y8v|Xo~VjwTed29eG*Z zv&Ih38xhqiCO#!5QG$AAXNx4Gr#34FJvQEnZ6nYpFK1+CR#dCxVhF^>`9)8Ri!Uz0 zxP(NNW1YR2#JI%xVp52SO-fEl_DdxtCAPRYK~jlNN-j1^Y;xY(pbzeI zZ55M}j5^HBG6BuwQvet~`i}7ZGr=+P6$ zj?ByLm6g+ZOjN7bXsXSq@j2se&l;OHHUk|J-I>^=pUET9E${=^P`WuI$Ah`LrsaYg zqOxN$qO%e+VzbgRW8yNh($eE&vSYL2Qqt0rljE{t;!?6RvNE#M6H+pwlhd=Zl9FT4 zOJWma5@M2~nb|SPX_-lJaVhEP8PVx6=?T$E32D(8F$oEA z@#*Q=>1hd>DOs5b>9KLKX>sux8Ht&h8S$|hnM4njl`$^Uo{1!p2~=86T5cXHNeB$- z?v`~2swE>WB_k#_CMG>LDJ3Z_EjA`42Cbfw6b}|a4J2j9q-4g#rYEE&CT69@#U(|@ zCMU%w#>QqR#-%4D#Zs!HMvlz{rSy?`W6)@}t^%N~qYO0>Oo;}DkyStqnRmx{G-y=T znDng7%&g3)sU!*PS&Eo0(lqMl(m|=27<=nmsaWG&THXW;iJ=Ix8kQ zJ`L$7W@aa(fwi+^5|fkAuM*O-!G7s!5Ye%5NoiSeNf~KLvA{{m$b!s>O^QiNPq#9h zJ9^^qVlu3POPWovbMA<=@gv6$7Yt+$C!$M>PCjvLMxN$%!JnOp{qu4=+@3aabXxkz z(IfNj=xB=vA!bLj2gNvpY$FNyDQ8R?#6Z-z@gR=oZ=Rctflo6`^V_L+jgagM!5^29 z5uc8FiI0!T%E-)2%uEDJr$whEpn)^eqoWhkW7Csj;?gtYQ{rMWz~8a)iJ9md39%Vj z@icH+^#f0ko{3Ia0_kM$lK%qtsFqMd;2#$gO$%8(Dob+u__X25l`(GgxST%YA-z!F zBw*7>w-}K%a`*@m#IXs@qQ;CI-#zR0tkGIHqr%d1My8?Jh1AG^^h-lT(pE<0O^QYI z;o~QE(QTNMJaH`CfKHg0M*V}*8JC+oIx9C9K#R{U^(Cqm#skInHt19uZ}Qb}5FHy! z-tnW;#%A4Y8w+UM96vs5%z&s?+Ifp~=454z%gMBNK`15?s5@V5G|{|ZckttXbc?Jp zeMU}3&5>3BF+VO-@J`g2JSj*@&}&NC5(zO#;vcrea7f9^Xbg)Q;RD-OUtN)*!UzHSSL>%P@`Tv2QkQguwvVY*m4n69Fb}YbfIKP z#p+)e`~y3^C@YCKEy?0Zg_2|W|C3ISwzf3?-dPhSj?95-)@qP__>6)qwUBPk4MYe! z+B$)LGS?X>nC;f7^}_zH&pKY;gE0e>>Toulxmmf?PMy2B!`)Q?nTLOu)LuRN{QaMA z=feFrV+|Xm_PM!Z+dpvrv}7r>tke}fTY@+Qsl40#Ox!Hl`S8k06@yzBkQ#~EwY0O9pS z-Xh>4<3cVCRv-VyEE&&MtTmpgtSg>9SWi52@H1E*n~3K<_!;azc0Zo7jHOr`HiUUP zb?M%hHOjanXEduZJSS@ut1&t)Z!D|9Jd_CM0^f2Q%!N4-!o{;JP&_6@$`es{l^V}N z;0|N1@EWWXbA@apYGGH0_GYzN9in@2!}lTeXEYw)L@vHY`!8zd^P?<_z;0L-3m2b zc_O3>KP@0vYq+#2q=&G?u_wDV~Rc{jrAgLjp#l zBpDiE9mZddI07%xL;1dZ!;$Y|jKNYcm?z^&<8K?*jJ0huAFk z1e?pAVb8MX*dq1{Tg;YVlubsw2v!c`X+=h(a0EtdU5lmR4>$kbfZW#tq%vsGSRIOA z2!0;%BUBp0Da3MxVI+48NOe|*m4~Ym!;S;oHCa^wmyc2y>lv$HgD_Uj;S1)W)Sy*} zbdyj6l~F1;D3?`~s=-xGM2rOe(&j%jfLIO03sF$Yvll=g zQ7WpJio#zVPb#N05vW}%v)$;rCRiE#{Ebe-A5N*0u10yPX53)3H)0L4LuxKCSdGFO zh3qtb2id!98GehQcg|*yuo>tTWAQUkpUJE(yB7Vd1M7&M)z9ov>1>qfMHA3_?h%^a zgQD+1_k)D>unD{^o59~_v-nE(1pWpfJLw6H%mwHUKzG2E4@o!$e}mAvt&&>9(BqHEgP4x~bI_QIideEtY>{7>~_ zQCFgV6lte!1AX-~ou)m+5#xf|r~}K)Dd^OZngQQTa{@jREwYA{)}_|5WDW9(D5t5d zL1!LGTC>zz2~%N7O<9b2mX0H&A7fYZgM2$mg0|O2wP+=UTgQsSzt=cq%hA*dwTJz8 z-1c0TEJa;^#bf_7eWiHXj?jA0OX{H}SI|=!VWU-7m=YWik!uQ}zmW9Q#>gf4gBz>w zmQwv`B6^mbF(C$8e2w5H+4F)*7cqyMez6&_tSg?uSAyb5P#!1wj(RV8*AV_CZ!NPZ z;mSRoY{~^aB}_j>OzpARd>HEGGw?%8ew#>XI-cm&!mVfsdJ8EMpAPENVSgrIU2KFX zUBCIZo^RT*v>RN7as?X5a$)@Q@5IztPN;vWiFY-I#nHeLa+howdLe2$uG7}B5T7I` zjfBcrA%IVmC7{~+=f>8@%=y$mr|P8e@A4;&Nr9&LRt>~29%7|otDlX*FgWTXtvuF8-UD$u2IHY$-9<{#tP_*JYO=mrKJxp0juHO*Z|?tOt}< z)u(@Kr$0EBRWlZL@%0ich85S}#XlzqxNSAxF2j%y%*Cu$4amRH@TsR`#1d-{G9LN0 zOWSrTfc@KZ1DyPcJ&ANL$E1M;gxXi~E9?noqxY%nM=ouh63=WT`I{;WFT8 z^?-R4$k1Ao!&vhuJ2*tfCCKJ!NAHceo10&Ia%ysw8kDr zD%P8?$M0IKHg`u@5Bxf?UifvyzQzD{9af-+;MBn|{BFdaMi#Y0eoo}%G1S^kK=Wwif*TP;v1a<~O0i&G)7rP37 zL+nD(>OIAB(X6?uw??oVn*Z8x)wTT@ zi$M)tgB=LkV~N7A4t_Pn4*={-$PmlbfHi=2S|7ibgm6iw#YM3xHQEg+CrZ2obyxwl z<3YVNK^>6^LoLzn1?_q?z`v5H!!YD03iT9${UW`;W1u88;R_eo;bQkl?!p*=mqI!r zsLj%->3Vop#437>s2g3bc$6?k#EoW@bN!cfO!cgF)&#_BjJApd>>B(U;iv1IYEFh& zuI6Z+8fdd-g8HRB;x33zsYD{RK%+zS?ty{5JKCSABdX)Han#svY(uqe!v90~mK%$V z7mX*4$BdcAJ;t5J?Z#*$-5892qS3%8CH8A(poQw7{yeBf+O>)hX_rTPR7Oo#MXOau z8S9~5TA(+^pmyU}0ywH4qkWul;2ZYdR_ozZ(bgEu;(#ycZFomnh! z6Yvi~>>-Fbl2ygO8YoqVRILHof}SO4&I9f&GJ_|qEZ zc>JkUYfz>%JV${xxmLin1|eyfjnR{^(lDo|-5uJYN&`*WhnfyoOW}V+_@9A)B$ zQYRPD^9uTX0T~K9X@HDEuH-Hj?H{>We}rWrY>Y~WPDhOb%%%31C?}&t7(>f7Ws{jp zoRKOk&PA0KJ%i3gQ4gUWgEr1c8=c2GWsV&;hFzPTla|5ijh#3q2koo(8p{BWdIHI$ z2*JS>&=abl7f2ifm>#rFZS;t%SpCQfWxtA85HUL)!3&we%DDxYh=DdlsX;C?7O7 zNjj8|UX%~dL)K5^W3`Dx`53K#A|Eu*wer!+BYq+u(`_LhPa^$9KJ4}6_SEsz5&7t0 zVpBf4BOeANi{a{L!d>Vk-O;NU`qco8MnmxnL;t!BQi1lZ%c6g+gapJGZJabtx;i0t zEz6B~R99|ev$5HAHNtTAK)TG3wZ>XkS!Ni!5ei$uUB+VLG@#!j?qcaSLmn}4=9{#Wu)G(IAKLb>T;0Lz}e;V+ypwY#E%Y;q`bO8Ra6$~|PbYfnkllqNz zgfwLyqpA3jRFkEp7!{CW1^imsp{x+nqR?pIfb9h~jruaK!AMg63cO#L4>0sI_PP44 zLI{Sj;lM`IL~YOR2|(4TVP1D8PpP+S8VogqddXu85b zQ^1gO_^HJVoK=w=LSs7hVuGdP-wJuF0O?7%F7_1Cp%CyQtA{_0!Zf(n#7IQCXCnFu z^`XwtF&WOu^hX~Wf?pU*gFYFKvoT}Phvu?{=tCq)aa00$NzhSi;Kaer7{g}c*92*V z8Y8S9PE8o-ePqBLMi2diCTc)4m2fXcZSlkCYv2TrgnEp6^uvf~Ab(`2e?}M)klEDx z$q+YCiRjcVQf7y3KS#=_b^UXs%sx~zqI}MjZxAV8FH+7!N%e0m;EP25 zP-c`=|M~*{kb+~BRKGTW<|wEKY0|F|V1pIRjq+9_ZbwPUP-#1>Hjr&M0Btys&4K3f z6nK0V^5b%iCuxEaj_|3cP^>NpA(-L13wAXAGQ@>;9*Qu+t%bO)2*(WTf>DcNG7#lkRFL?Fc;=mD+J`c;sl2$Xg??BU{>t{`s+2tBD+de}>*Yk_dN*&pDcZ&1Qz z>}8BPkKyFZWc2xLq)7C;pjVS@Yy_cn8nYKSM$8e)&JcEn@@HV~vqFRqS9ZFvGms;K zXCMbFkpqf9+;nLeVaZU9M|35OToRAc&@jUDqU`lV`$S;Orn0{%O0`VogKUa1U!@<4 z(?ijS6NQ?t0xqQbdQH^HJdF1)_9~-Vc^-2$ikE>JAv+y>=MjFYL*mH{)XxZUDveI0 znJvSpWub3~wpavv0D7Ve+-zaoBQU5X=8BLIw5kVVTP(^^7O9b3Xv5+~Thm-jLVDp8 zo$O1bQxi@#y&{BrZ%5@NU2Ji?g&B~9_4DNNP`Dc)N1e*?d zHX7v}pn6s;Bv=$^y#l&4vPrwYuqjRQleAh4Dil7zbZHo2$xw|)5D6of#G^0`Bex5= z2|=DD^=Cnypd=MXpkMYyZL~s-HG($jpI2(vR}p_WT9NwvYa-{vh20;}d4gs-B)rV= zaOI*ff>A!Si(oIPv?cdYXc}sn_>)@N5WG!oNIvkl=UTYHzXr84gM6p@ehOtVF-Vqp zY6(BX9LR<62{`G2t(1xC%gE(!`_D)g zcoqHdCZnd;T0b@NmiNQz3;#3bZ@-`v>4y^y9@UbW#%ww*=*$z%D0++EA`wn&7rM4! zyAGpXWAVFBgogWt{(u^+q*^3Y)aE@nFG}$x4VUY6aLPS6NlNd2%wP|&ne0J~0}n&e zJr2xS>`}~Dohjal*d*~8@=onHLAXVGyROC@MPs{MHH@>E51&Q4G<&aXe2SUzOw5LR z8_7mJBa9tq+t^po=3hoS(;-#U(RaIoziDi8yFcf1&+j3eGrnW9JGvF zaKc1L*nBn#XH+NSjPqS=8hC#?o67D41&@-{M75Nl7a{1C1w~D%DS9TYj$EuqQxC2W z>200NmZxUvbT-ka+)`dK`grRav(VF-H_o#G{^z9a+Q1%h&8N=jl_QQvFF%4fSAzrIXTh(%FS|v=r7J zI@Bm_sIEP%-tG`iC3VZXyq`IiRqWIa^QE2w&A1j1E-X}#rbZ*=wE z&*Hsp*edU4Hre|nTjkk^{|E4&h5tBP_y`! zyAyxdd^i0)nF97=s0(pc`m(`Ftu|QYJOg3UP2pvk3@7_?;vmh*f=AxM zh*}C9OQUXYaL}X3N7=FwVoU%M8B$|!!h+c<*k5@zc_*`je1vzm@OM}6>j9_m6cs*6 zg#>0i5VBQt_7ydU>XQFZ_9IH(&1A?M+wtS;hA9 z_K2tRN%r#4P2LpPZ4|tfpv%}}h(8eVx4TDsUPQdR;2#0I1(ux1?yCG<;ID-Ezq+c0 zj9^`SH^BcQ{IwB&C+vK~9YX%z#t_eLKE&5Y*|#e=(UIZO&J4*B@oTAcG6lUMzPp5f zh_9=n|BGv^XEgHh6Y^Jy9p@8WBS7akuk9Iv#Vy9JhusBsmyjWz4zMM>pO4^o!GFj- z#FNJwp?;QyP(98@Jq-i=QP(Cf3G1HlPX#<5_9XbK@gq4M7Wzl85Y zJssqs-tFE6>;!)U?b#dlo9^A-Eo`6fVOR5zVX(`?-T`|&>?yFrVb6knSowd0J(*4P zwGVv}@lyH2u7?pXm6wOT1NL~>Q(%X~o(21`^8W^VGP{|#54|fS3i&>S{I$UG#Z_pZ z5Tl-Nq48Fz_RnHXTy@+VU9;Geuv@tou;<-hBEOqpKdJ1!sE_IFS>Iyg2S(*tZWxBN zn|cV0rn17)40u(>;4Qw(o9cN&tf*qc*PR|v9%F+ zUhg5;FTmcx)&j4J=QGBE_XY5pxnA+Sig9=#UyNsOh`U_(LT?U+=vm+k!prvjW9YXb0*9`VOYU(D}{p?M?!dU5U13Me`bl?rZqGUGv z%h%gjjLGB+u1{TCNz}UDMLlM~o&|p<;2*;t1phav*F5&TYZ+|9Uki9D;M0`}=T6A@&Ct1Kio{VW{H0+`HLMekx?Y z_i1+2bHJz)qHPqy7Y>FSfsesM4(W?x+Cjnbx ziyH#iYk<9Bi(3vb+BjS57k3?CKiT3^y1xMSw+&Vku-$-h8%)%Jf$`Ij+sRa3+P16; zSaXAxUuAhK0@f0+_9kAD!0QOu0D~s_5|8R{AYh|SJkBZZSiq($dgxZ7p6>zdDTU`k zx>N^q0efBH5lzb9GQd7ncm%5p*yn)lw81FuF2D->V1axi+|NvMVG-EnlR4G zK}By2SfYzYG%5$@)RxJBb#=))pn53}yc+-;sPG6zc!L2OW5cTeym5d{S9nA-0(E{5 zV2_!2=Mc97Gli2h6qUiHzJI*Pnd9ZNym!zX029!;1uLFJPw?%+1d8(&(pW0b@o7RWIjxIAAWLK9>0;jPObU76DjAKfKC- z)mAVU@Q7#X0@lQaN3d%EOEh8Ucx_1VWWf3=n41+LFEv>|z=oK3MaWA{Ol<125jH$J zJaiji6a4UU0h^*=F3^hr-c-OIu;CHxLBO80!D^$oJq_4fMte~Q^dOnI3b3yX+2)KD zin{s+uwN9Ohn+<|*Jb|!Y>RP&iYxK90k%is(RC|jz%}~-J83i)crHv(=;h?ofElhv z3RV`F)tK8=pFzhIFgKQ)sVz$b7HNagW>{?eVg(rEnJz%{Qo*N+*!6Cf?OE+O2 zK+3XAz;ewz8^9~iCIWW9OCANHZN-YHg9iY6#?{o!O9kZRIlx{w>A8VdnJokCLlZ`l zvpic1*ajCZwa|hG;Z$3`ONe?qq#HQNcc>k8OlwIW3JBVgVA zU_Ah#t0OdCDc}tREW-w)xLJUW^Mg$QY>EksKpIuB+}a%*0d#zW`feG(Dq&NgJM_*z z3KovOQ3HBscU(ImUVH_dwjOF=Td6xX3?+=@O9*=OLco@=ULvjm2*F+hY`LmGH|n_- z^0yMOuf-xGrCSm8Ssi0aclIM@bYwK_C%}GFFcDyTXdT@K0(Ig`+HR`Mwj%BcmL`G; z))=r;fK@j%jN@ooWz0moL%ydgJkAMkG+?t7jFx+A0X7@3*Hv7CT@BbWz!Jp|Z?CX92!TMO7uGcM*Ht-tH{Mt?AJPVO1bUy0iW^SZ}~?0qj*9jPPCu?0W_CFxrx@!hQg3 zuMI{U?glmo8)Dg>4B_+h7!T5@54zFp4`Huy+-VIE`TM1NOTOmH^nFfR#67 z8XB*nF&tyM1tYvxfQ?o#+I}Rxcr0MK#%PhhrQP}ts23Z(rc9|CrRcb;fxi!xKUIXv z2f^r1W(59Hx(IK;<4FYxIU!?cTgP#sL|7IPSehnDTWx58|BAU%16OL`N)05Zrqc4X zHf2(kY1`0}7c$V*Cg+h^`8Q+ZLK=Q&TIOh_2QX9+`8` z0PAikvj_<_Y0R{l3!^61q2bzQuF9G|ds;?q1-Xhc;xO2+^sj=>sj1L=Qd7Iw$iR7B zYARIwQmLu!EI3f>o4%Tz3<~|{g%)p~6uH4{R zuP(T+r`%0#l$u(}PEomQkD0e@!R=D{bq2?zrplO%T{YpwrOytp=?Hfx59llBYyh{q zZ}BTLF|+8LpO5YAAW69>ZoB+^v^7iz?SS3}6NoP%z=xK6=bP_8B$AGswQ9}N*HQ$c zgt5Bctk<5;G&?mmW~eOrx-7t{!omj5ef`BTa;;9pWNq$W^2!64;HBp0BdS2EJ?*ts zv~dMfIb+Uii|)~LY8F0!e!dIcRvo|l}TKTJ|$)$;Rmy?zAT69oa*FW+Y5G*89yv&x?4{CFt~*wR|4KDp=` z3(i9GcFUl%uSZF+4fWtlCX3pjgb!3FWT&E8YU%+=@6@6?CcX74P*|Lw5s@80b}#3- ze&Bk$INyLqKtfJjk@C%Unz(Tc25e11-=aRXS;{%>&5#Mn0X;H>EA=~CwjvyurHX%X z)FaQ^YQF8)k|_UFt_tKUuVvK@EWsJ7K-b@#+XXah!uc^Mk=_%$5T%ypD$rS?J?vr5 zr^8eGDCa{>Aj{@_C!KqKo7-QbWEId5x8WT&D<{$=gkRW$$OD<(VsiBW=cyJ!z@eThmRXiJrIZ6s%LbbT&eS5OZPw9T%Q?T7B7S2d&_xnp@5%J#GeeUPVy6>|oYjj^TR-RgtkrcI z{Af`;Gb#|3zoCXs2Z0N7e^Xhbg6PRCO>ezDhb86ys#`hVOsOMj2LUN}KCshdKT(Q` z`}qVb$m0H}jaEquawLYMP{-w>1wt;0dTyLf0Vk@oDH=(@eC;)DaEX5jzO1etw?_HE!CqdCLdWc;XBHdy_W+;<&8>z?GiKRKj%j`OcE{!}$H zq8wDE;h(^B=0BU!wS70jZ|lA)Hg+LC1J2psL6q;i+Jq{{3dQ~9zd-PjUm z?7{pcy!E%kd1@=<)vOC}zXwe9wJi&p`$LeqFKlp4-Qxa1=lHf?fpwtF3hw*uXVfZx z2v{gg&!haz?}y+og#3W9m1SE)0syD&HtyTGon+TMg5X` zufy^CM;nmaFQuT1EQb4sb3R{EWem)o4tGxtGWLN|V-Y%@b2=bG0^-lor7Gs1lwrT zArqe80ob;Bf{2C+BK@h8IUzj+Rugms--^L;ExOL!kGMjd0MEg^CFK6djp#t4i~AFh z-0uTdnI;j5S=@iJo*7_r|5X@ieXE8gH0v^BFHolm3{c+E2j=GVM^MxvsW`G?QTwJn zum$mzq__>vC0(kPZ8`*u>7#+BiL9V;7>`v|p{w|pKyrUmu$6rl0cqEsvNe|T$H*r0 zDiYvYRViP@kID#daK1*#1oxMmKQ58;FqE5GLyC`|r)ff%%0~bGeS3F9bu1>_SISI{GGg7|3tHKefUkunwWT%ovs3AmoBtu1=hYbxBkhw~M^;rS5&j%*8O++=}n)?mNjaP!OA+8{DzFUv+RK#0AKzg!2A?tu6Zoa9>1`KFH0$-4et^rzz^=8H^GSkeWHHH&l>!Wk484{(Wj5Q))K) zFKMDSRAV4G9fdNfk`|CgXHp(FjIsXHF9W#WBp}N2_hs3Nm@&xzY zqSCq_z0u<3`keoy0$|zF5kSfP>^si8T1fGbc7mg?=KKc*HuiI{)p+H*iSsw4vl7}x zs2YsUZvP@Og9#>TMQao8A2?o2_cKWad=IAH%r*ab0W~$u2b`nFONNpV^-P6a5W<(0 z2K#3@!Wdg2xgSVh$$_xSA=)nxrU`N+Y7)M5NDirMSB3Zu&ixJMK2Xk<1IG`fQKnO#9{AEga-` zrCp86u&^mq_GhZ@|TwGa%wohb0) zsKz&H@E@p#Qi{gFdhx%f zjw;F%Q@{&i6p$qs@xoZ?8IPtAVq1s7E(5_Rbu<==t|kof0GjCMsS2R2H#i@p+-*M2 z7wWPOg1tg={~O%bSV5r%Jt>{-IDcNFqAfIG@I`R$m$+sYOz!uCYmzFnz`sj^?lfgy zA5@np%tD3yBIh^5X)8;!BEAg}#6AX*Bg{Kewg1?faE(G%CHFt8a2P}XQrf1BhZ>Lu zOm60#v7w6lJ96D@7~kcvldTx?BfjJgew;LrE}TE55@_$k4Q_Eo{5w5@bPgn-P|N=z zr)y@`{2jmWqux@d`OhZY-p|=G&aa`6Qb#zy$wXmc+l7+pKxN5={45D?CzK)uLP{S~ zx~rsF^Bm{UmHhEa!ufP&Vqb zFgcJ&U-9FrNBqorl!ObM&mjIn0lP!P@T!WO>*Ltrag))lP$+#bSw87X;~b-n=)R66 z@Q5?~kR@KtSb9->h2VwKIIFIMD){(+q`RFt6=9W%)bt-AVHk)ZDnwGD{hx5I$lEyR zg4|yoYv)u$lGpH5eK(WyaWduhB4vaOlYV?6f{%diZ7<75oI^kY$E=E;Vun$nP$}k! z8j0C}T6E+nH25P&j-X%39Em{9{l=gCd}V@EE0kBJqD_4KBLGUB z(l2oEM~S!}FLGE~3V7yQ zMWR2-C!O!|^AV45o+5lzF}E-iDf0)Xxg+KqpJU#xP2?WY-5Gn0^W_?-?Hi!~>hX?X zo`zkro;WPlK&bmVm%wApS%!`d+gcP~A=m>*^BZfkgBJj$cYgk}$P+YT8uODlf7y)x zxyk)4G0woK6?wT|W^$0p{c`KigqMZLo=^NIAtsz*>koaAdS!v}RF0gM2efCvQPPi#$YMbNItg)TGHlEIFC9=@NS z3@6lAEKO3x^pq}*%(Qf_(xFC)rTw5mA&c!pAutTL8^ecx%o1=vbh#q6t`;mIP@L!4 z&kL#`kW2%tfw7I6`&YB-Cpf=O1}&94evAA|3SFR*2nxYlH*&t+&QCH; zdX|j)p{c&3<2=LpSmmx*#D5b`?@6qNL&?8W_y$;dBK&Q!1X#K8IesEa#S#_-$6=^y zD9dq_&tb~#;@p&|M%U+lIE(&FW}0Azo1IJG=%X~dbr{>C_zJ-uKysKeVoIPuB*1#w zhbc&;#v-XE^ieOO7y;o?Ik!P(n<(3vpyD%ir7V&=LJO~{dDq)43 z<;P(8L4!g-uNp;h8vmFV;r=hg`io7Y4on&pUt)8(yZ{`DL1#&4n>6=-&R8ziXN7#z zn*V11lD;g~{Tx{vj!h{LD+wyb525*o{K@%mU7W?0zLIf&=wYSZVIV%p&ueMMz7?_w z98Stp+4E{!8SU27mKK=f$rWlZ$O6C5al@ns^8uv{TOlu*TU*%E{6@0q;WelT$^AQ> zOW?O~F*;e%p^L7!7RC1n_5de2OjS~dkDS0pAvEI05EsIT#yDv(I7sf_Wv<9?q>u-M z{wtsG2Iv0pk2!x`@`fM&#kt>67I684B2BT#BXYK%dbk4#bK?N+XYJkxf5IFSV|wxX z{h+%>tTnrDWP(K(nCll3uuc%T#x_C#UiTm`>>$H0 z@Vs6rG3XnRHNv2ue;Wx?uGUSTV@+JlHeKhftFUd994rC&5A;$bv7jJ8L?R=p8T|3s zh3WmAWHT4x{!dxzdC?=yECnP4z(U;is;@eC@8YM*(2hSkAA-ICD{bWb0TF+xsDi+p zO%t-iLHQ%G%vBQ9$Q_*DD99&aF3Y#gXk`=GGTSBNem2)sS}{JYGE00v=eLq)x>$(( zf}a(lY%4#nhaCzG{fVPDt|X_;M4jeEjnS>X^UqQb95K<+!?tjK;-#!4h?}08`~8-{ zp*_s8ktJt(z@m6(oKUc}Y!a~k$RQrad74pmvKRQnVBC*WIXxf#x(F?vC!$6cJNA<}fn2?h^S(ME_7^sS zs)|LOMzn0Th!;UmQzmp1nzN{#_QJ+$76{C=BIwpi{4|4Z~xKuFobJKi3 zz!1`SD*F!xZx3C**mp= zhA1x!8L9ehM83OWKFBQY{}#Z6LVms_1xoG@%-IMW>2T%(9CM;{utEf&HvbwNuXS?kTi^8%n9=93DJoW$ zgjYT8K;`_#i{k7sxGU_DR0J4n8^0(m?Iz9reoNp@xV50CNPQfzD1ME~r!%c~){L`( z-f#y79deOlKN?S%7v7H7{)Yb)?O!I zm9cFu9L|S7vtfy`nUjFY{U%AVxox9Y`fX)i@Q+DRisk-qeud)bSU~X4km7#S^8%6c zH$;BSv7fjflPmrWjee5*Z8;M$=wcVz4F~0~Lt(=bs_INf@fjlWxl|dH8JqP$- zVk)lV3cH=f{cM})JOYftRnPksI#%RI!|BJ(LAc+8CjKB)#q!6$IQQGj!hYsHtcW1Z z&osRWV#e=1!p~scLKsMbAez4lG=5ZDc#-DV4~d(pLZ2|XpFM@e^=5K9sRC26^E8Kl zO1?S#Gl<69RRGRZVYAQZr@OQS&%^p47Vu}8NTh!KRl)KGb3wPk%z_M}XG<6Q`Sx*S z@*c;;);EEmr8wUuLmf|`uD1g|=e(X^ls%7|<|-fnuMpEaX}$!&Mh_Ju4U`Mu{W08T zF2enYcPDh7Q({*_Fx56jO2b>&ICnqp6+guHJwq| zFOX0TL>m;rfIUecuie?$(fFf9rm%LwiHdi_jj@Cc8+F@s;NO;X1UOSOTYU>RtsJ#1D-P zx+u<`wGIW;esfF%iQmq~{ZQ#X5hD>Rg&Cyce!bp?j+%=o7v=sNmS9|#>o}BxAO6L; z-(HqplI=l*C(G=HVjkBD&V`~T1Z)99>*|CgR>iSPHLGG#pfv%}$v>8X7)#!iuF`ug z?vKY3{uI0_AVcUGa-2W9L9XE6z|Ur@tv3oOjWyRqF*!0PB#;Q0V@$Ubz%ie5z$(^2 zRllXGFpwqCX~@_)D64=M37z>QKTjRr&)88Z2xkkR5&J`fK`eS@caqs9rWXD&FUtK7 zi36_R%T9v~_!Cgv&!9MtKrnVhFp=})emXgQ5)CN1AFH~4CB@7d!wx8$W?MRlzyzS8 z{znN^-x)H6oG&$pAsGcX0d!u3`QRF>HcQI=b$xR60)tz7O$;_htxbinFWF@M9?gDf~TiN1~mL`y+Q^%cu!3V!q@@H20^tMA`N&N`4G3aWR%}KJWCsUzGHI#2EUv!46c|5UQl4mH_AK&m9blt zUA(gMsC0@$e}7fj)3G@YYv zHAn9xD*{*b=@V`1=Msvk#v-^J1Vl(6Hg60Qs|IJvYEG0;(M{hksY=L?xx!=?{)~vdkDi zEtbfkmm`D1oa?Y6kozMsRx0zJ)cqnY?yvBx3VQlF8~5WZCqFCH1YP$Pp*gA<-dMzF zr{LxO=Dv=??ZQlq6k2I&0No-Gs?!&?7{4{5WuvxdO;hF(%((?OnVnbQeMvXS3W>AX zYeghgI?dM4MTiU2HQKUp5}=?!NGdwHs9O<}F{J)jAfAo;!7vXiTr|fxJ1&+{cU6>O z0SN&hxPJ`sG<5hbgy+xwY#7QOiWVmBN6&TS45R64fCDJoQLj10cVa^Ztwhq;Ld9(S zOl?50u~@aLwPQnv#a0L~J)GQM4(u*Oy9`edGa~86MGU`+gXq>e2L-&qd7U2SiPb;( zMmoSKMq3$;mNh>I+tZuj`3j`1`TpErv zAk1$WLcTN)Tofmyj-Lu4apGAu@;i3?2!VB&xn$Yn=;&as%NzSKm_C6qQzw9~g1Liy z68#KQF=!{`juk?43-l^7sw`X3!O0MgXXY6y-nuvm!mh*w%-V6Fvb}nZ!A1xurS9HG zq5RiOC2k{aD~DwtIgFs6Gw7TZva8G+_Va@R_L;eV*APp#9)$HS(IV6m)AtH^0!QG#H37oW9~?YWo%z9cVR%uQ-}LbY5#GtiPt+$@R|u9C z0%Shs`*S}!p%$_x5^H=@S`V5r34?zAnRY|tH?eDX8`QIr3J9_&E<>tz8Q3>DOh*so zejS2rpkLcqYN+HC8-!3F)-x#SkfGksA??&i<%h4yU(7~Xdg-#LT$JrR@J1=fRb)>> zzrOJpE>}23tqOIw=smEDsC5Cr(U3iEGgViEXtO#^;6HUhU}F) z=f!)^dhF?ou0sR6FlA7|FT&+O&4ljJpMo7wfgcEA1E8P{5P^7j-^V+CL1NmtzbWS3 zl6^vU`@dn-=%jwHG$0`W7K&7$_jc3*ERg%_pXSHrsh!wf$eAby#jAjlt|yf!fHH+z zsEB<3dUaOIhMIywQ!?D2Hm0T}_)!#J7(ecxjJHVZ=&-g}8M;Ax=U8bT7q&0z-eepjlB&@vKPe zIDfV8AWIxrA^(yKTlPVAREr_*MlChB!t40tKC-Rzvg@J2+$B+#V720IIU{Th75{Km zfGt#4%3EmV=LM<-)x-lKVv)@^6SHKPrcfJC6O;Jk*|?wm2Xkd&Ljx1@EpXUzv#{;g zm?+tS3+)w*CzWYlH%oL2oCukiykg^CjQNuCCN2Q1X z!=8zi`UU|caH6M(!^$njE6G?&P^P|f?5B5YceEE_#$e=$rX9+-apvP`+bLfK{*Hfg z)ZDel3cwocs0}b)r;dq72!)_mvKU;Pk2VUBqYM9Twa@LvMA0<|k}QCx>O z?RIwV&)lUI>jWR)4n!N@4GlW>f1!^EIJlpY%!GF6q#*p4^RVS2So9So;(p4K?bJ{( zLjMc~7P$M8C_oeZ#U2485{-H2y!+>qS@h0MEF$oAN^Ra9I$a;H9Hd_hV|y*N?hs6k1%o zuj#;DfrDbC>;WkAE|R`Q03`S0B`Nb1bP(?MY(%g+g%!RU&SS4mo%q-WIL=7H??{gF zDvUobSo8Z0oy!;kw}vl9sa{(({BAA=m= zzuQg*K+`SUYdhtuXw2Y6Hh%w_`*?wrsuVsLHG&q9CItsXTWb&5^PSxPCK7(~xff)+ z;TdJeRcu=p#ADV`0#BSuW?JuO?nzuSOImQ{;nqm4yZVb0!RS&v#_TudVjaq^c;A0?q|8^ zqs~8 z%h`*NO9D)D2yIT=H`)EUpGA|PQf74xK~g=M6O<&h!?Xv`;`jUDTW5yXIqG=d=YFi{ zD`_rJ<~zxqL0r3$(##|#9AJiM|0QrI2Kj7@;&Y2(k9IchcdbIwTV9^MYzyqQv^Zen zyP<*HKOAU!+02joY1Qx#5}P&(l&i}!7ooADnBqCos}|cttwuP&|AME>d9v`qY`IJAbvQU02|Bo+wOvd6@B5^^$deNnOIx%NV&+}Yb#Q-$vwW|r{b1Zr=Vdha zGuA@oY{PAO7VJz;Q5>~^T=WWF0J z<%vuQPf(z{e4Pe|efcLQs2$=>);<^JoAa~>w)cB$$(X?NFN^zI1}uTwl2A@-QM{zw z&)g4?1SV7Tf>2&;d^a>G_ZL2->c^k^OaDyEu>V`!A6Q5K7UT(3+4%?ky2`&r+(yUM zZ1I>$>{78(NjDq(JQ&U7q3Q!@_He zPo-+=z_f0$*5cNMvFoPZ(!XQX3y1$-hXgEv2WChwiU)>Rkao7QAJB$(uimorrw=BJ zl@<%ja@phoOPZc(&L|}JTX_GbtLp;~6t@;~>jQMv;Qy%^ICtJ14gXJba3yg%*SYK8 z-^saBu_dU1|Ciifg5#|56RuxBmW1NnT0jN)248f&Ymb7>*@xz4iA$k)bP@xKaaGYT&+ipmIYK}qX~M$~jg@Er?a^F_CEm8cb#ZWN z)5XFGzj^8tciyDl_`XUbNL!!yGTP=oI&*YmD-tf#?sMOhGln?M!i8O*lRvn+9SKy_ zn{@S1pqzLsY{uKmk!0Jq-&O~i`-*FYXW$+ydB3nprp2U}rUJTjZf214T(0^zX658$ zr#iBML~2}f4UQC7y5<`7t_@r;^>C%1a9pDg52N0yMrGxpB6RzrBFpUpdFW* zisls0TB)hc5YVJuJ6or?{`qWij8q*wl|<0%@yY(@J~P3((!&owY#tf} zC^R+Id6>FWx2BrD(ZwvIr+V;AS5Hk9N51Ml{>3*hsf@X&u2}IT(P*{c%P(iulhmr; zvv$Lq{iPevyH>0iB|PQT)%Sua!f$)``?qc(_&f!EgFMZjS@+rVir+dc-tgtKX6K$Z zzaAm5w%17`^76#Tcq`mk_Hl0!)I{}5;aSS{inwjRj1R+Bc>GcG>I+%+y+4Q|)hnXYMZu(O~KX0z1 zv0V9n!1))fqjdwtZ7PK?Dt;$a6oYy)tNoTFun|2}n0WbJ#_$7ti#&BJ6aJX7iV{=x ze9uepz9*q4WX&lGu-Y!bWggP}lvmnma zOuh$z(V{b-R%I*&6$z0NveNfGa7dEpV!BSRm5yqhhAoVDjp!1wIlu-aaRmxOY8@m|$|z7zuudpEr|VmEUo zDS9nRIR&5+=jNMj0IP9*mREo%U8TWF#$mj%FB1=^IcU1s(AQuf1SToJbt>ZLaKpxMPLN?&(@)Q zyQ(AO1rv0xr$rX7+EfEeB?y;Rn1%{K#=XJ>t)TwoY*O`sn0pDQ2zA5HVQ3@ceZ!3Q zM#91T6pCVrIb%Y#$W6HoA``-584{-nC5UVrP2bcTvBBeBmc-M;$vk2~I>-i6g zmLJ34tG}tTf}~ltyklswxYbEK`^j(e;gv}+`Vb`j6k~a?(2D7>FlKwHDOA(TG%C_% zp84Wd@$)ZUWu^YSwPTPg&(8R+aPOEla;88;iy z9iMzA>b4iBD=+6d%s>$<@+d#g&)Qz44ClPOrYG!e=6&UCGCu>(na2NKh4-j~-I31j z;OD4sh5pI+r}z4f7f`Xj;yZpO?vKSCRM(~L6JLe(?B%}>?TnsGe2H-oGF5SZIzKgv z40Ys{{QT@}bk}UWsx5B1|BWBONBs)<=hwFWi2JT)>{@)}rDyLAyikqRn)?^OCk<&I zuj95im?(Kw7#gb&dC4>!@(X{#rYGW~PyE-Ok>PDV#GoTJyc-Un|A8ln`?J7flj^$4 z_4yg!z>qz5I=JAG=+KZP@bYm-DchwX=)X?xGVQ4y0k8(_R0Q%-7l&v)kgT^oNz7aGbzbBHl++=>00C;Edg#*+p z3bgS$x6$-%q*j{Z*Dc~-k;g?l+`PmI$MKlH+mq0?v#;#sau{U{V2TR z`bS!*PAuxBB7U-)_Q77M>hx~gHO~M)CcfS!x&I#SGpA;q25EDRjfimKy+-N?^F-T< zXLoTme_46+!zeT#hMi~>FP*WG@GSr~I>rw+RlLIeB;${ChdN5b)A@PzfK$A&^Qe0t zI`}B`{i8QA&#U%ttz!x`2TiEt3irKc>ffMVyPe&ro=+dd+hZnOwUGZ3{;;qw7Sw#;{N_W{e+%@T1BUg7&;U;I7JGf1e76s-!F>$ z9~9SI5fK(6h9~3c_ht$&V^xE$|E{(D79uKlN|UKrn6J#olI+kzYl3eouzIc|lNTN?SE=po)+ys#YsS*fq( zeRr(tDSoE40MUheR9J0L1K*n5kN8z}fU@I#{C6pHugCr9&Ky@d!Ov7Dyr0bbD(9<< zaAWW?Z$CjI@-rnO*v;}LX?nx=Lq(k~o99vDgR5JFh%g|XhEpK4iIEZs8ktZ6f^$DK zYjdh<{AIf=otpo|ZG!bBokn=O_d_{}pbxyx6HxDdek7b+!KvdT_D~0;sej(~J+g`r zYV(Br(7|mF@w2ho!`N@?7MErEyAU$yC-FfB$^C5|;>3#T=XzIuDr&Y+lEky4cuhG$ zzIqqAiSHwoJMrG0ct(89chX1}kuwP_qD=XFkE8qSxm`@SylKC|Q}7(Rb+N|Iqpie6 z#V)+O$NxBhYvfcdB7U`5X8`#V>j*Q`@jXg_!lVg_oR>vKD1kuk*G|QH((|QT(ZXSg}pCDzj%_L zp-%Uy_ypDv7d{pN?w96XzE?XLD!LnaB6jo4j}`Z?`X-tyV)&V5} zf5=Y*qOws@;)xzTPFx7om7;kX3%RK%7&R@{l%R5V(*n$&Bf}I1bM>W(>bbeKH6W;M z=TQt1?Fp&hVd{qmr5+4_)yHTaw>49#R7OvXHoRyNbfw~qo$|GBzb&Li8O$Dd!Bb`) zo$<%wzDL$fU5n)&@&|6XIZ*(Ge0w?yivjRVzWXaat|l7g{7piTgZrj&A7v{f@T#r{ z`Kbni{(JIX_UHA-Z!PYxe@tJ&RV(*_z5FBMN8CcP(WSY6GvAN#_DAYqukjD>gu4jb zEKCa@A5V@s{OGJl4+%|dDc==}W!~c?Qpzt}xbP5vci}?h)Hd&bm>(C3&}VB%e^+KW zDs*|T_xR1Xeuv?o@R@_l9QD~#Oi1!5 zViSBNM-ZR~`V%Iv8FS_+PHi9qf%A#Nsp^w=n+4}j%z-p>e;MqQ38|j| zcnX>ZDsnt|cRUSD7DnIlWd_gi5V+ILX8t_4%Bke%WSI`8tsa%A@o{~M|B-J@itdP4R5b}N7XDEGZW z38N_m_n#Nh1Gv9Uk=80dm(1}X=dyN8?ysK{PTpqqUPSE~r}z9-~H(>{GfvjPx`uNqN+&A3kK zJOJ3r2bId?{)(J`WWyxqZMM5~{DNri{iuM|weF+@Hy;AbTV(6l1Jq|D;^qBWq3h+x z9wj*_l4A0piFe`RlNZnvZr7l1<#aGOf1+aBX+-useNa>y)X^~Es+KqJmAkKI9eYBe zmfI)i96jW1#}w{4!cS0RT^jXo`Dr2CsyzXn`s^z;mTjcz$2zrYv5|8c!XdaVt?eK`La~K;&!IH{ z)J`o6sBp4UZL6U8KJ2M_7Wc=9&mz@MY%XM%h_g_9^p90;Sxf3AHbPiSI3-sJT)X&D zrSrCtpTH=KD`ibo43r-W4}|4ytSZ(4ezFqSD~2=^mp)oYx(qD6$U)lftpHsTe@32A zt6o#jQpFce{aIWaJXy}JAR2DgNxmk<$0bpZ6$|UQqfCrJxZZcappNb-$R463?dC3> z*eQ3KSdRM!}pS&W%T`(`-*%;{5SRKddA}SBR6kH%BaP2 z|FitC9vc_(LnIbgiVrNqp~n7@)%;X~Bqg3<+vw{Ioq1ui(xpG;3rbs}^iSf=W&8}y z(H~HJ_#WRbRFcQ}DWp^Ns8IclTi^WTp~}z7&;43szb%nH=7rC@)IJ&oPLeZE!Ts#! zxpOI72l**0-j|${av@9r_k)Rg=|E{;^{4s0SH|uX&2NtVVABV3NsJb}sn@SI^6kts z{x`sm&DP>j5q#8~Mbq`HuZbd8`w#cErto9@v;dGBsca#eB^B{AqrvXmZIm!bTFX=7 z+xDj`1N8tut(HB6nmrewOX5$=`i1@kK0p1*TfZRxf4>GoJU}yPhAwQ`hD-O6yVFk7?3dNV<4)-5j~t zlQL%(5XH09oH^IvGj-25EIuYeJ0WMgbA?{$kLwxoD+0%%TZ=Cr>PH%OS?ZGcobMNQ zT|L4&36NY_rr6CHg1I(y>7a6DsW_)~fS;A;wW|0>4DN^SI97yUBpiil2wHD*+r#IQ zpz!}F`pz{=p>Dt&xl^cK#d81kwx!l_Vp$#^TYp0)EVd zeD#UC=MHM`NxX=1@e==ws9^ZZARBXppUL`~mJ~bWj)UYbeMoLK5IDeWRm+Kt9py93 z&?)j7O3D2aYN+qTN*~6q78={1`}?DQEJ;-Y*H>DvU`HHvQtdQ9rJK+F7Iq*fbiMKZgZevEpR2h(sCfUtdb6ZY77#z zgLA77RR$VvU2N@+DeLaNZ?yr`>yJgulN7rVp+`bDX4uQ@J=hLmhfp_=;_ z@Z+Y8Bly2>I(>l}Jv3b~iJf}*sO<>YJdlbJ+ytMUo4B<|Z?C!atX(FRPk z+@W@3mh>W&ZKR~LMQUoP|Bt=vfRm#3{@FXKh%^hmDbkCIQbj;1c0?2b6;Kfs%TG~J zQ4kdc6;TmEP~bp{bdY`=u+*d8QBOJx(xmrtCI9a?GqXFhJF~l2Q1QRHPi|*k+RK}~ z!}p&*2jf%yEch1T&6S?qTwYl zq9HggmZ{U;>ipd?hy=B~Nrm{zfIYBpY7+B4_VH2?;Qqk+tc>Lx`s(e9<4JEd7K}h=0AMM7wCbHC=(v_ zK?0sa<{Xg13MgGgHB3k}6>dSuOq@htNvy)6QAf1fHo>|7H+is-FE@O239(^@4pH3y z5Mat-P`Tz0?eStG2sC-<*6ETzxbEC}+~PtCeu7N!yq7&;WABt)IWUZ8H&&f*!)nu1 zm5*jr=quj*^tC*x^~BBfc^(y*0~u^<<}!2t4PZ%MLbORQKhL&se=(1ezr0b~D-^bT zVGO+;^;x^}#+-k(=Rd)!CtTi1kn}kxa~+fHzl*TzK;_-&4|P}cf;`L7RdbD2s=TQ) z@PlAhJqenBZ9LpGTwcDY54CqwJJakFR3!e}Az`+mxzeZb9m}cbmo#x)4_GTDJ{dlh zulK=>nc*07QjE3Sg_CtwyP)nu#=b&}sYh%$yaer%6Kskz(h5ABKy*$@9gs#>F z^la~Ay83t@t?9i8&G^DN)xR#6TMTik)DX1iGcl?`=l%wC*{ro5bNRmK`;CF%2YJ%R zWug|RS9b34O@fJQ(6{=MjW8KA%{Fs?fsOjoxe>Q)yJub8|<#zZ$BlY2lV)7WoB zM61Ps@jhPB(a1~ayy$XmR)UW9LP4mH{wPF8bf9xO-TUDC=qpcZq^g3d%cPm{7oi93 z(U$BUgIci|9l+a6*Hj5{w2G02Ebu)R`}B(UFm8ELML-{@Xv?2n<1WawSG;J5Y?u$- zGrBHt)s`6=DMLaPzRKXwi$c8PwIpKD0x3(|=AQS?!M=4zM7%jg9)7w6utn4Bhj*;- zv4^BvqUGL8p4Q$UmuX5^MaSyvu{a0&lnOV-ezD%%k84RY_+K~o7scBvQOLRj=m9L7 z$ds{4LoE3{#woe81A4rmE-W5x0OHBiNt7Vr$_~cYmKz@r%KKsUD_VX*{ufF}C}ISw z3Fjp3GCMwN++V#t78&5$xA!W>qUdosL;-#Ot}hzA6BO5#$mxr1e!8iokA&A0H*u^l zQi1Ee&)G^B@IKo;ME|MnCV*MxKy!Fi41I7X#yzg}G(x0Md^Ow@DJvV#X70ZR8lS;T zMmTM;W;+UH2tOe%(F1p?&nTqZg8BbCvwn%9-mJL2$#n5h=tpuhJKk!j~oTQEY z3z6gP273I>)_RGWb-7;ao7(uS!53=$2}>#=KC@hpF;T4p*yo-$*DTmRwhQ%nUK#zf z=L0>Aue(^w{Y$xD!{#IX3q2;KYMUDLGi@@$H}=EIDaAMIp+(Bz|7U?3noZf0G6m!n zamw!3m)Aq85dru5ml1LI^S*}bi(`EXi~F&vgbo*zE9i2Hj84JR<|mEMY0b{`7G}<- z6k$B>@~xBMQ|7TxF-PvQodLCv{r5t<%ag>drA)1CDLCr!dC4TP{Hv?>(e^4?RgaVC zYvE?SKl?1q4%k+8nl`LA^P1?`alh*vMP%VZTWM+_1CC{npxi+7&BCMfEi(+6;eF25 zYVtz)6_5iDN`uz1=;Hp2z>upnVQ-2=bya| zxs5R^PsPB7Hl9bb_0JAou7r)lc8+ zn{wE!4Nq#5*y3# z^BXeQZ&q1UhL0u~*7$om8PZm;l3KpeDOj*CtT8%V!_I%pQ-<8@3lDg@BHaI{Z-T{< z`(Ki&cL!keZvo^v$SqEoiyIbzQGrF03V>XyM(x?A;XcPXdB7VT0lE<8!g#sG7@54` z>Y<^s>B)t0>`iUwHCjx3>(sU=EWlBa#MCnx23LoDGM(>!ZV zTm*R?_ijPljAlq*m>L&+*;vKt^64jfbl2r%Z|BnAtYl!ylLjv~{CV38SnrJS6<6rE zy!`DW6vrks)0@JgD`(}{9a$~>@`xhJPg1|IS&6z zdd+~bZ+8^q&69Jm)0H2};>s}$wniz&E>th~=e{U^&;m32UpM!M`bYzEbkv*ub96nn zz8m)V?d=>6@B1eX`i_MK<{Weh!IVK%v}y+?NESRsM|=And`jD~|CY0A_{oC8u1FTQ z)Ps8Ks#h<6VD1xSttP*wLYAjo)!Vcv0A8Nqjqc-qsO7thdEu6aV(1stnwq7BYGzNq zh{t6rTM5MC#s&~$c%uFcg}Ao?W>eedeJNpn(V$3~{k%X!wYp{S_O`L83JeraqPR&gP%-06^e%S&>* zPmo0~wo;?%R&6&?l3_^s3@(0WUWYgr(qO=VLD&_n7SK=0)%evLdrPm`u$TKW>9AEp zIMe@ibN|;DeubX+PV41cdgiX1U}kQspk}u8rsxxE23Ek_-E)PQwdSXqoyj8bUjJ{~H z*~W_D1^pxVxc_}eOIod@4E}%Gx(hdhx%{`1y(cdlP4a51n6~2gsy=@H;k8t)d`aK; zs48S|KjIzp3G4`3Z5r(csV(if^h1U416Esn@4aZ)i=?DnOE*?u&A;}fJY7};<;FB# zEA4TJ+F-6d<3G6be&gjUZIJDwZ8Iknb8K(o2OMEY4y6ubqS#P|jf9rp2F9cyp}CXg zVf@(hCuY02G^4qp#)=!)U?E3|YN>M9!`DVv87Y(HB~avhc>xPWt$aHk5$9l#6+aNN zfu6SzsI@LMKc{Uh%z-J(HD7-+@zSN0n8v8v8kmAQzbDv?k<&cas$RUpEViyCyc4 zxhzi=FlF&FjQR9#_1P3{{406UP>NqolY5lFg)#PBEz!N;{$!1-qDP!rpbyst=bjm( zbC<}UX3U-=1te~$(+=F<+RFVGbhH}l{nOhNGtwX{^}yQeJW%U%M5q9}0zID6-Z|Z1 z-?v~|-`uxc)Kis6(Y8YsLpMzKdhJW<_msW>@qupO@Th4 zKB<}Rq$_vs*-}P&dEQz2=hkWWVn^@jT`7M}uA-wqN~swFYRN$zf}a<43m`oD#(o^= zbi~lMd{Hy~6wVHyOc!!hWza{SD|b~PtdQNK+1x7bcg-`F({j&MZ5jWzXhXDCF^&5N z8DZM5;=MdGyugcZ`%%Hm{UV=Qyk*RbP>(ft6Zlti$C_%8vdQvfej`}@=9^>0k*Qzl zjk%uzI^9B#+wznFl6}DH8_1nq02;1^6YZ{gR37*7obilWv)f4@f4*c)GUmGPg*LY_ zt8&lwc%{0Q=kF$o{CkbS>B}7_ICy+(9$Ka*CSI#=eg4(FV|s#*&rRHqi4}{*f|gfi z|A(^!D6?f;Rq5-cs`a$13TGgTG4vGJhunWXMw~PH0<`PpB(lAg% znu7{zvLF@7&X2y?8?ofjU${`EkC+t>$RsaEVQO-gp02=U?aUE- z{)7^Qp;vb!xEV`xIU(eeh_R$q0E#mLC_ z3uB4r)mwsTvu4yW-#IBJ?k{cZi}EFpJY_ir^6ZfFOuciK=YOR#%t>~P|1e&DrdX$) zubi^?+*dKm_Zs>sZ?z^3%Nv@tWAx&6npO5+E-lmO{@X$=mRwi0X~PQENH^ToyoM6L zy@h2j->7Ld%W{nAT&i4VtWVKl6j$^A_WaqzyR8CI=g5633ICNpEmNJQdGcM5 zyY=uceKi`)K7Y~=TBHKj5Uao|E$^4sQDRK>ECCB!TztP@A)_Hm_ATH zI@vyV^bOs-sW$0{bSprv5%?sFpVY_y=(~D#^v1J)z-+4_Jel==t^-=+H~Z8P=>Jm+ zM85zVS-R7M%m4o&L7~%fmy&F-&M{S08~oWIXHLp4*frkYd**h6OzT;M5u$I8GYkk- zd{Jo|h?ewYoB9HM=y&97XUv1b zDkAo`VbfQi4Lm{3SD5x>w(BvE{VlyVK>jHr?-2@+0!8j!DZ5tkPE-HCAj+ruVZAt8 zOq;6%f5I))Z`SZ~e>1G-agLx6f45q6mOO9nXA;BxWzzgs$jgOE#?Hn}BXS?wvnOT> za`)`n6XKRWTk#X-R=#V6%~2nWzbwA(c{n93W z1Qelwmjc>~pZE3u!9)HN>Uf`$$;MS94%{yu*ks(clQLd$w23%5TJ&Fsg+`bcY#822 zNeFr%rVD!GG3pO_P?NhIa?b~oWG>$o!cSUvQ_k9B!AIWf`}64UKWtelAjl6;tY^;} zUXYpl#jEUdA}&G!XA1oP<9<=UT{{@Y-rBAm>~QHSs{^K?$mi5fn)b~nmTC?I+ng=$ z*4IO{=)V8Cir4Or%KztT7~%f^+$W7xC_;fSDG=fQFbNfj7NNj@jsg+x z|IdBWNQEL42$KR4?hlhtk!TSL{O2eT;r{>JCyi7nLV++T5aIqX2^EPJp}>ER0uk>2 z&wbKJg(4IPlL8U$50g-lXb}qh=P1y%XOsV2ogx*AP#{8q2n8Y(h)^Ixfd~a66v!k6 zY7QEtZ40mP%yV@NYxOeCnyF*uT(zHl)_YV>u_rs-uQscy5-T@rrk(1Wy_x2s6*%7` zFFpYKcAP$~gSFGTYCP4>uwsvD(CJxq5|)ZzrIxm9ZpC)Zsr~Y`Ti3gSKA|GS>216T{a#lB$w68+!xR1RP*)IPA} zH_`S*3g-}Wf|3*Rn_n~k9Cn6x25uS)D7~9J%bmT~jOR|ZwPjRz8=PB_gr_^z#{UV| z5SaH@?lwJikBWd3vkn^vfa2UP2X~x^d7j)~@R&R`0Vm&J3z`tKWZH^J zC*=vAQ3uB}HHWIFmNIQ49XGP#c=}W8F3EG)YdSZ9?j#Su&$(M(95d>iJP0z}h|}$7 z&%-sLj1FgTfALb9Be;h=#-ID8tc0J_Qa*;C?|DU*z)gbZdcU<^o-)qSxpt?VOE}zH z+VPp*Ukxhy;M!gLlM9?f^rY$su?{P#!wk&KbJ!WS$3f70;*} zzNHz6*Z~osm;=iCg}Xzv@NhKERuF8%f!Q*Rb(I$&Y1fBQSQFd`*jD zaKD~F{p`|ip^rYMKF6DF~A_!R6(Ad5eiIH?MP_$KPhiT!k+4mdEt@s@iL_vj`+pc zU$kC5yuBnZg8MaBP%4?6lE8?CI`@yz&z47w4!p@G%5{AygCV=hRE!n%)Jed%NC|m; zwmMQnEaOp=Hzg${U66Z{lDa^?SE52fLPO{Y#*IHCWrAKVe)g(MZ=TX_td29TsCYni zEPAYp7bO)3Vf^wLE#t6#dvy+BI>QCFEF&a?zu^_DXATJ~jM2Xy#3P%*HiHNvSy1+O zAg8%B8-(nt9cDCh&Z2VQ5PjvcCa(>1WPbY)Gq&mBRdYJWXJ>@$l2i@J{)OdkCY&Vy zNpiTAaP!X*HybmE7|x}%zudLL!d*OaR@QTwQE`C@N_??GsCaKV*k3h z$RJDE|Dj-`|6zNAnn_XDzdy34b^zY?(abrE%7^?$e|w7BVGyd{^6y#5YeS5Qa0?R7 zA?9fRF!Kvh1zZ(W_irc%6X8YL&F}qXs#aSc_xG0<^sjvn_%r@4#?HPKm){uNKVG{- zV>IA1W#V6{cs;>sI=JpKg@T1By@;&6svI71g;z^;iNI;h>gUOmXPvw|Pag1f_mMH?^9Aw~@TGFE8$z?>q)Gc&yO+gL}ZIN6d%xt+n; zziRgyjK5gBGWu(orU^P;?tj~NXGPs37v+Ts1rYmfxjUDa`$bf#qBR$&8|yK(KmSJY zz$IPp&v#s#FpT;>MV`cs-GeZBidnz3jl1P);|eMpy+6@7XfG<&cQ0b|jhJ>&x7gkZ z&3>Q!hHq`mr{NRe=g=||jOjsZkaLJR9%6+WFU-+HKO@7S?qFaRFQ$Ye{NIWTQB2$x zm`!>i4|+#`8-x#4RN2{tSU_HcoT+WW+c}yA`=VI(@$zVJikdO7pcdrC$QrzMxwEBU zj(}VojqYE<}txsl6NA-%-1kQUeOtLl@;p=;Zy4D*I_<)u%?9rFr?{0OC5Yaxw9FMJr zEo+P!n3?Anv^H2olYV^z)seDwF-mYqOT@ zIOfM~n4xkAF-JsmXDoTj49v{)VlBg;EW8LT1Dt;$2R2N>B56?FA9*y8@*^P{OZlOe zp~n3UFUr}N0;H?nty>wKI=`92(*SSRSssGGlqvV(&g#od`Jhr0^6l2{mh3Ap7FTI! z%VXfu-23G!F3P81>Hs1|Qx#5cVM!Xy>qES&{^ONx>YAjr$f%2ZTbn zTF|0_P|u(xXn|oyv*d)^*N}Bkiuy1J8qHFV3h1|~4cQ~xtRY;bnW)9!26oGrY((RKg zyCQbfHhJoY?~cpkg)xn{TBiQ??H-xd1i9RFPJX9l_Oz7i-|x0gU4(PvVR>=J3Mpqg zBQsiV(*kQ~c0LXVjESg$pTqZa2r);*_hYQAbNGJDP?tmXlOyJv1t4ZI3b@UL${jCj>-vemtBYaC6=>6)e_4WR0>IUBZ z^_a5Q8b-SCBA>sN=g}c3_l{Z?IQKCHm)cmcm2$4b(fEwsSxT+Zy(QCbRO#*hMK6;Z zbJ_)XVQ3rcO6D$7%<5RMeTX?;5iOSNZw6-OnH6DUb;kyC<3U0{S17tmvSR*#5Oc)B zxhic}ycRT;IYp@FlDIToF@GSwkR>Ow83cxyA?V`C{K2X1!#j+RqN7|Scc}!${Gljn z#vlaYy@qQgZVLrH~;XH zw%R&75;y7GT8NuF>yEr2a<+J<_bd8R9^YGbW~Ts`tyRy@+g_tRM>Tr$vrZb*cj(+) zgW-RHHt&DfPM2Hqw(s+PFo|8#-W&oR-jkPW5ExauI+r2r9JuYT26h2# zX$~Rg=;YRS%z(^3GsB?H=KEzIs*U_1%6{au&TAp!g&Q0-QfGc!kwY=RZHT#g8uKe2 zb)>cl@9;hfD$w9Bo%uEPHy7#mY{I}V#QBv%1s%CHZ`rkRk9PjUEf1B2G&#|zW z-;DP^{XAZ-Cko}wc6|Og!5IAm!dhZQGj$4QENvXx(bBGzyE5iPlyBkAt;P12M($#b8OIIE9 zi>j%T!odK!2?hf8F2@GBf`Kj5B9g|8q01Wfn)IkMc3UUv7nf&}1511$Vl}ZA5aSwjbV3(Tr@=?;B|*=GWNY zj-r7WgeJVM^BcvHeb?Ix@6I7cTK+QDCTHE%95uXWW|$b%yv>?V$JDEj`Gt6QR7Xr9 z{y3_kwKAr+KJr{Syt?+th1ZC?Q;a=BrWo2Ixy^p`b`CLT!dU+x``@%VEavBSfbwRv zGx;3#t9Ru0%c&47ZPL&D8jl8khXB08_$cp~hi78PG=c2z$NWywEYShLH_SwF%+UHsr zXCZL5KZN@xi-A6t%B$LmI{evu+`LcifKg#@KusosJ5y8BO>k;1>cEi59`<~D?;_bhZbBHgs zYBc+@^80ZZ+#gnk59&*OW^4kV^Ce}i*e|M{)0X-Ii|Qoh)X*b;iIcVn`BU2gZ~tf( zEONx#Fu(dl7K%D!V2NhQI^TZVtg}x}d%+UV`{Eqpje-Yo>-XmOOg|&TA8i*R{d3O0 zI`lfff10b|9f$tFB$lq0IynHl0)s3Kidv4w1nNAqp?ZN}V5QHC}IP71Q&?(>CyDabei$6u3FbGEY zTQ}QgoxQ1DfrL4Sm{Vkb&is}I&)J_3Gn4$v1SibMS535n2aHXCBA41qQ2Z=U2)J@C zDyy^3@(i;WL1+A&L(C|I7t5R?v(Nq|`oHV>JAk#&pn;i1Fp@rtC@{{nslTU`%f=p( z#~c>?v#7*KHW3O$DDbbK01o$h(2Gd)_c%sE{#nZlh&)6n5TU^T8w!+Z_sRpIXQKY! zkRwvj2n8Y(h)^Ixfd~a66o^nDLV*Yc(no>Z1JYqn98T7^j71Qlo$O?Nz1$b;(uk569&PqYRW&snssDe*^bf?*uRScxnsjNC=3trn-9oE z_U!-2<>04uX!+l?%@!dXpk%pd$pLTwXcnBKf0H|{JgS0`#&QRfGX|DumaOxxv1a|o z`Tg}R5h5=U3jF&hkOK!dhigw97WR2@n5RAaSHf{0eM9D9j}T8!vs|5d5KWqUr2!*zyI6V-v%LasNZ!;c6i%Fvrs&0xP?bmm`$1t zWo_wMa1yzUu@oJ7iBRC*NP!&F|5o-7t@{TuA2~GOx`aTyZKGL89yQFL{cY2PiGek1 zOV3jNr`}=h(6D3?d5KWqZ>K@bU&7iaZpA;&*B#$@*1JQzmNji&~CF-_rKQ1{(fhV@<`FoH4^xRm|rKu zuFr=MIn;K(z>2qHG%LmXn%SReY_CiT#K1P18R@a(aj>=BtfIC2$XkR0|1t`6Pcv~_ z7T|X65@YlSw+Fc1s)PWrAHZOLaHzvYOSlokhj*}OmKt@w?B!81e-LNFAV@50P=x(0 z#E!gN4HS6z)D>cX|2s!coWKaF*gr7D3^`+r{@_rDid(FGsg6jMKQyFCgQuXE7u?FZ_KLk|gSA6PW#w zLv1cv(h&3E9Wh-G-z#BK1X^L0u-yn{xw;P3h9VSxUKalRxv@^0gBN!AAcG^IIq# zdATYmkT0v5zk(z4KaP?A{*Xi|VylO!VLgW_u|3`a(M&0@cPC)vXRsRm%^UKZF-SB= zvt)f;MC`ZFBl2=}P~gL?o$wR6=V?oQfweEUFAU}v*(cZ{6mXrAJ>CJ)ENFB}vx@y% zAlTn>Ws)NX)@YWTU4#5I3QK6@B|?FJDFq%3>$0%$k3Q3%Eu}|ZA{2;FAVPr%1tJvqXDJ}15UDanRu^{& z7yj-LwG~1xks>SO?M}ej>o6&kWerhNbOh{sQP&%fX@3B0u_{Hh^dQ^30F@rX-)z}^AuGgI1|0s9=V@vb^vT$pwuU|UUTS?+eg zM3k>?bpVTsa*4Vo80%3Vuvc^#@w*-MeHF0bQ9k@CAzln%tGw|psCa7tlTi)4xKu$r zSF}r1iT2gE67s7CSes}qKMC5C&g}sELXYPX$&&Tx3)rk^t!~CQngiHhdOX4!vkcKt zG!@!?*@Q7|bHLvBgLMaNq7GxZ6%lVTV1MYaG}QNIQ9*12Op4c3Jp^#6z%pD7MMc+p z3P!rzig=X)Yw6PB5yo%c3a}4N@ya7!55UIg@lsH3F|?|2fUR)7ujh9hX)B18fSq!E zpqHB>%OSrrfEA9?>PDRNBW=;BhN5zmmR6u`;bx~=P<#7dT09Z>s9$`$| z3b2=>jI(7@6wX}$`(7_Y%Ma}tuu&#hZQwT=u$@t_3e6F}3Su{4*`hTVX;1;M9MKI$ z^=N~B#}({0z}iG>b(6q_^=${(r#g(~76Ys|U?WWV@q3H}Y`LCa3d$%fYKVBi{)~P{ z$0ZrKlt(-0D{d7t6^!kmJYW?7Ybju9_1^Sy#EAXA4X~AYTu}) z=mFSRZ#;fClndAf6RaxB1uQCBD}#Jl5U^~~eZ>trj672oX>S6oUbI$jlsJRC#_tk$ z1J){fjb67i@&>@31ngBE7L9m?0ec;=PyOQc25hJf6NpzF@qPhpt|?wIz+wSQ^1@EY z2I4lfgGA9#6m+rRC~+RR)DevVYa z06XqFqT)pXRsgV*fJqaqIAE@*L{ThCizmcM;F3p_0Ia$W)7n8^aT{QbqxP!&G}x`; zKER%cO4eafqM)cMS_Afy4r6|Wz@@JM_EA(0RW}z#U~+0tz((uwq5-Q2*jT{UdSTfB zDJ>EJI~#RWuZKjuTg7?6Nc$I}4utU)o_5AXpjIwBJf!K=m^;V&_ikMIhTUH z1=uH|n}S7y&h;@adru6Mk!WH}D7Zh)1D`d*Khtm2i5>fm}qhh;RqhS0p{~v@BWw1HV&}o z0m~h2jh82SsH>e0%O=uLj~e0`z^B41OOY?QekXF~OL2 zFJQO3w0Kb{gRq*e1Q+xdm6ovA0qX+iS$;y{70j<$xQ3N21YYRB6OkgQ>`PehL0*0v+5^9sX3eQtHY0HPS?h zr=TNfxn=X_k<kFZ#Rj9QlnwvxcV`Q+xIZ`9ZUSzSi=C;l{93NBpk#7EJt| zoHL!B>EV=Ddf{m(-2bbj0v2vQ|0M3ga3o$=TirBTn#Tw8mtk$#9?YEJOaUz~b9&?X z%C{+Jth5kk+zcH5{&Ad``RH%p;s48TkXha^zHj*XgrcWixK74}zWB35T%akbwcP(7TOA4Jb4;;%;1(xyP=QzB9i~p}cSa4suv?yJ&q$pXe zm?(DRjpD}q`9=QRxkc{Kn1qUvwf#+w95TnX*UD@2=a=~l6_SN+xIx}vc)kfh?FQfe( zg5Oo9G~A7F+2E{L`QEaUzTx3_9=Pp?-&v*6gHri8GeF>EhIx?9nS1yVEL>9mo zzyA8`eXSU+8Rg4-`SM8+LT1mNJ*&$0uah&KPk|Qu6g^770Tr*kl7Ai@5 zg;Llu*g`JBAA~22VPNbVqT%ey_@{8U!^I54=gMxb+hNot8U7-?Q8%_ReDdp~;LK@_ z=fAsd{J#9oKoO2Z%C+zY|EA&j2>fpNon;CaK2SJUuJgGJT>XFlZruIL5BBbin^9MO zDSolis{P#r$L|>|&#VZ$2CgU^3Wh5Qe+=HhiNEG=PzK+iFup;q415FTbp~D&8~Dxh z!(~Msj66tVUtRW~EJH3ijpq=3N>+7rdOi>VazGTg79v4@hz5mBqQVV)C!FDomt{k( zQ3rz{`fupAXa3%ypG%dJrAm~LC5jZ0MKF3?kC7xVMhdi51A+AX`XQS|ub?VqH33;o zKvolw)dYHU&GVgbhBICk73r8wR@0wyn?ZldpVwU{ugjTJ!Vow;boY;IpzjY)-+Oel zjK1a?c@1$YSWp&Z`!Hl=J`={U+_`0L#?2a>h2U7tF`HwzHi~n^hgUXaC0+$=kps@e1rG0pi!05JoqEH$3msu?fnQ2D3$Do)`VlER>X7=H}6FHcX zzkv4x@MV--OPPZ@w13^R@}06XpWS zzp&9gaJRuxp7UYld&+A1hQ{wA@Y)5x5J68)2eoGmey z$@1mPOOzu)3wZ}xuF;BX)~qRSzx{TJyU%1Lw1jeK$rvM)E|31iF@GQ%D$HD;;B?*! zS}aras0&vCE-S|B-h<<2^jZ8;{9^Z@t_>UVJ92LRO3F7m;UK`2xeE>j z!!3s22!90L!0Rxcjc?Ep-=Jm&zrk7LvmIU&8~DvD!ey1dPFa|8Ni8@O0>`>+fYN zrek{M6S6Z6fs456pqh)iH`jV7H{N=yfUG7Us|m`tVY?icyU>r>n$0ZH!*NGzDxMOoF||T&NZK#Zv6F);n z<}+nXhE=E_D^Sj4n<35_Ti-Hp##sw+9J4w0X(Js+bB^k%@OJImNiVUBn_k#oD02h6 z8@^E^_OB4TO6P#{VXQp`v=6FrhC1aFg&JK6S;$N?$_56-d=j%J3sGLOlbNl!#~}wZ z@*ccX>S(BBj%sk6fA+6`R=rbZrtC}^8im49rlxFt8vYOXW$xCwqXd`!n@;F`ff4XtEnd-C_=cNBQ7gP#uHv11!J zbSRA|bDH&$jJ0C4XvpG9&xSm%baV6z5@VTc+O(;B=%I%scvzxO(ONNDax1jtmMvS# zN70fWc;EqfA6jz#`t_AQ54l?D?Ocn;glu|o^0fjXg41~gC^1pd1I+aV$0*Jzo6~3X znctk>o^60_AqJ(eWw3>8fS(0F0{&^ZCUBJLIUbqI`=@X><6!s9x>bf_-JXSuK|SWc zZ-!qFKNH@l8$RoO9IyQ6`v19U59A%HZS8xsB2CR5- zPT&!^4sabijzReg;djC(!(W8o1|J9iE51R8Onifb$YUk^WcZF9pM6o7RFhAy-VV&JnvCwVLY3y`{ zw{I`oKmD|P`l+YnQy48;Qs!ygST?52K)s#o6x7+xGL`eQ3E2!hf=bya8!>P zD%GqhYTkXfxVuRc0a;BzRuhob1Y|XVFRyvN6V7nPV|=D#dgjCYm~RHq2}p9K=uf$= zO&i&Ua_7Sj%ZDj*a?J&^?A0l=QQpkhoJshMKpV`$g=Jx`O>v!yb2n@U&6~^Sk31qD zdF(Oy7-eI#oJ<(;WgNt0Q^GVDd zgUZZS+z_bnm?swtB+#Qf>kM?}Fa}KVLyUf*aYs zeS6L*?*;BzWy9*N6Ywv#uUN5qOX}5YH>zE`?r^H$Ua|Pu4xbD!buaK6p8xn`@o|qH zqQ?gxh!5U=TfF_+YvQ#RUJx&|X(QS^^pJRnYwlI6imIS)aai9u+TVHc^gg7I;9URa z93T5S_8-)@sk^d23Ti1_O+U9>m8LGa(6J!Q|2K9V1O@PYh*^&tK)y(C|v4o}^kV=(8? zITy+~Q1<`nw-LB<`1{(LQ$0_h4JN=(f$!Dp4Y(=LLlYqHpTJ_RiMr~Ogx|KE%1bZ& z5U`4eA4l|hy#zN4GWr&bJLgSyrs-?Ns0wvy*c?gr0g%O&eooyR(u#y0AUk#HB)fF! zBLDZl|H-ahyUKU?_2J(}OMVM2`SsUdmoLBkvV8ve=Oy}CrN2W5l#p>{Rm|XEZByCF z0X0-c-q4cS6yGVYTi?M^BQ@xuIT=Xq5G?c?$4(Gb0c*OyoNibaT}EZ z@-EAMvv4VvzIgfbAJ1Q-#=06UT1;+%&-y>&Fcm%#ehvIYc)C~N9(ri{LshD5sB-PK zr+s^+viQ3wnmJSfCXm;aODR{b{*Lu~OK3sfM znPEo8(q~lZ1EothSl*yPyD{zBy$`1f?==TMf52aaKL@`VekOcozrjA_umpY-v+9NJ z<>gq+5p0KDPcrR&fP63?s%}O9ELybg(zE`#^#f?huBdya0yu(iqySV~DShp}slwa18xVLj-sMqQEN{Qs01R@D@ab zx7o?^m+yo#obec+>6o7RFhAzY{DTt30k8D6z4VS*vKQrs4?mP2zW1Jdk8%%X9_mAs z87MDNXXF|MZSz9U@q4jN_XDU?pmb?bns%66*J<8dG^ZZ^^wR>ent-e(Agc+;Y67yF zfUG7Us|meC@%?qHhiyM-QdQyZryqX`tpNNZb?<2lcE?J zNIfW2?VwP3^{H4S+z#)CkAa7yEW~wT+Hq^=Za9CRaK3k55UnbR_ArQM(2VjADMopS zauFTnB+5&ant7p$)m~UPHyR}`gb4c?Rf`@RB3K55MtTp!6 zsPRbrBagf~`PEmyjQR4*n3!=fF)_>G|AgNQzZ8BZXeX1s_4viT)G$tk%){U~ji%9IghXshJKDd+ zd{Ms0{-t|&+5OW`<)>eMDZlLBU-tk0d-?qjKgb^j4wM7=%lB`-k>B*~EBi7Y^5V*2;*&wghf`iw2m)z8}L z>S4F-Q00ajeh9e5!;e$EI}W<~Lg?x{psQcbsIE?1b*vdFJ9e(2Lmq}G__RdcN-xKJ zp8V{y&*WEMeI-E?Ibgs5`2$+;K(qkg-sbmN24HYxiiPp<1$+O+&T=H|PO}zLl%%v|Qp&Q+4D@kaE2F2f-v?zO&HPw?J2) z_bA&!|Nb%k(K1vES&QFE@cm-?_2by}a4dB7B-`7UPIU#8s3Ig?toWG${&hiBtp;B9;t`(N%g=UNu`KHPh+ zyq9(r)VW`MRldsd*$yZ}_UkA6v27SKGUZE18Uw!lR(|{S*YfMnKbM~~E_Ha$HDCqd zS=x0{XXhF{Z8Jj8GiW$-jOJL)F`HvI$8c>l=cs-R-d`RIrkD7~P4D{!l*hh&c1+Bm zL4&BP{|;S!9dz}hwz~Qu=<2JXt4~$(1f+?fUUkZiY~Xr9prEZG?YMdFM*7#w0>5Qm z5UnbR_ArR1Xa=HD{vpM5i5X*WOn=Hrl$R(sQGPO73HzTL55bmcbvnGk9v9*Ng5M8+1AH!c5z;SOBo@WRiMY9Q#oXV27r#%K zASO_MXTRFJx9H8e(C3~L&(Su%P90H)Hu11bwb-V5akM;yQ#%Q3Oqq}SY&b`V)x&0- zAEchzt()uyt>!c8=0k?aAtOi1k-z;Wf15m6PM$tpPMLtQU7H+ z>h0{qm>=_H{w#;<)a>`dZndaN__QbegZo8`_Agqv@ZiF@xW7R6<8x=sI6GtVg?$-#G@bKjqvx`+v?i&_;m!+QY}4^w(V$;~%PizHsoylqpl(kolLO-${h7egL|< zu`X<&M?TC-`ufTb{vF)zc(;4pxN&aGvn_)@y&1atQRwQF&kfv2#jrf>(<$4dFH$-+ z^hqq;yrT5&&+#?C!{`a!QjQumN{$~tUV<*k)2C0rG-Jk$OEYKAyo}!z^doZ8q)8Gu zsZnY$Bm(O0)Z5XwK%9J0qQ8UbW)o!_kNHWcU)P4h&!|giI-}{LcXdNorwJIhZ1H4} ztkKmaboB$!)e|3mGKn52yoq&WCpd@{NFe86f}n=;{)>`T^+biO|&-|L@yx z-QP}~>YmC`Vh#MniSCJCf9-~@z69&)8=$KnhOREHx_Z#Q9hrprT?~4uUsJ@goEi{*JwQs=Ia#+Xd9DX;u-RK)2b_{fb|JOoS z-wRzm*-lpv+J4R>FUkmTW5$eGj=91=Am5!teiFu=%2tQxY6PRFQy}0)FF;q{1zmj= zbanR!9}IC188Xj3Z=Tz|4FxWCyGM>3=|&&60&Qt4boEmi)YUo1fuXxBhVVNvlsChW z{uGAxmoUV?OTC*qIEMb8AOehpDDWFZg2~fUrca-NPIe|bS^n~!aE3D;<1-!8GaoED zc4ofJpXIPTmTTM4>}X;%K7cYKy%ih#~Y1>JAbFL3@{hQ|= z+xJvP12XV7-UEMWSIN9EQ(uqkH`K3Tf%iIXGdW*FeSFw3IgB#o#EEj^)Twgn@4w66 zDI?SKnJ|Wp9xX?+%}|%`(?|BS3i|Sy1Im}eh9kydg$s) zUh4;lPTtxYZauVew%1zpwkKRtuJo22+lc(a&iWQr!YSyfiyHlr6(7}#y zj~KDQy21Fu)INUDnoVw8l_<9?#~4DMq<_kHYR8 z8~%DO>)G=c_bp-tw!#%@?$L)-ZTV8wlax{n8bDGYX6Mt8Hyl4LW`JQ3Jh8_I)wQ$Rny958r!e0X~ zg8Jpl#qw3F#Hxe@k+5Nd*syY?Sh-+}m`a`fn{UK7ocrXwC-)iEswHaiJdn_P zih|Wj3!_cC(f@G2IVkrW*OaNFv+v`a95k0nbLPl79Dt*r`)frb43|#r~K5KWReRkVfI-P8#bj z9r!N`rvgqD^M@y(qR>f~L04Y^U40Ye-@{J2dbn*nEAfj5evr?XL06B5uKovf_2U`Q z)oJt2^(mYkrfk-^4*o7?iKwGP&Q|(4=x|})ym`kKFJ63L<;s`Ek8lbLEo&8NnW3VNX*oOtRtB%B3 zmd4zN##PpS*VGDBK(Kb~LGS8;PUwNIz8|`}I%OPMlg?}e`Jfv)bEl91p@ zSc^(e@<3PjKv&-jUHuqz^|U};o%5gQ{_3IoYlrTyJG#H2=>B5S{cU#x`4}MI3&?i_ z@;oAqy6NAitEWI$KLTBSFLZSebal_v#6(Zxnl+v^6DNA0t9ziU?}e^@47$1u)YXHu z|8T#H!S3E)y+LqokaoQg!D^xVdxUF<5b-`5czWQ#iTfu`T;f@>Wcwd~{GrOhJdy|V z;EKLz#~zHY9@@TRPW}?lU_j=7N1HOr$;hv{{z2Qj=e)<`ISOCM<00?;{`>EHu@-Rz zYY{0}i@@?~eWk~`u$rRR+v(R_W-z%z?W==)1Jl*U9WHk0(Bn{#9zT12{&}uv?%X6# zQj*7WNXO}%$Af;s14-M1KEwlE{V;UvOZXP*91L%P8e+lgnT$M-c|7QEJdj)V!+wC4 z|7z|J^DsDSt%i}OfBnqGIMs{m&vnm3S3d|{-2+|S16|!SZ{9Y~wrw8tcOK~K9`s%N zAsd~Bt`7A#-RtVKN#|K`vqB+xin=V9XUB)e z(iG!L(;xjsV&Wf(D^~1X5f^tnZtB!jEFj7l&Y4hu<{At4i_=b&w&mK~tUq@d?J56J;Xh;`Qq#FqP{! zZj>7dW7yKAa_Ri}az59%sJGKbgE~9c$JlPT#!fktc=LScK#78QX-?_te?eE@3|)N% zboFJ>)t610v}_qwkz?@E&ztp;XoMAquMS^l8N{NMTj4yOljyHJo^@~tShlPt{4MbQ z@}=E-65^qcTLxWy8Fcjo=<55RtEbrN>Jqy8dFbi~p{skKt9ziUdt$dkp@Jmjh9nfz z3j#&Qj{BjjpMkC}vrt!0gRXuIy82$|>K^Foo=IyV2vHuQTtxYZauVgG>eW*Nbagh9 zlIKg7tbee6{kERAZM%7}mW+YocMM|dF_?KkOAlnnef|3NI}X`f;w&|_zN63c8ZFQ6 z*Z8(JP0`X6*e7$(5T=eE?sB3_m%jV@_8sdPJ9fEe`Et*eEn7Tu=FGuRFvJ6UQV;C7 z_G9hmjA?&(xaEYx14nvY4>O|MxdwshtJh&V^7Z)RZnhZ2C`d5EX~cynp7* zP3S*Q|ArYh+P1=|L7fkU{Q>F@CLm--k8ok;`l!AiV^uqBWToxaP%PBNO6cl`wzK7FBB&obR*W4#UW})1&b9TWOU2Ul>&5ytYs8w^SP?sJoEXPG^urIu zhumL88>QQB6Swi~PA_h@55a55{)gvL(!QJh57)lA4~w?ooTr>JMNV0;KrUe4vSo|h z!hUAO3b}$hJNub=^W?m_bLCwA@||#oGalowS|wMp?_xg8kNI-`ko_F@va=86JV9_9 zCB0&(O1QK)Q(l7dwjOI6TsutxFI}8Eb?O=DWJfU%wh!%SE5_ipYj>|*xbWD*Y12}s zalU~z0;COTWZ=%ZhTyo9{^`fx(sXQ*cdC>h`~=<;;HSVtS4Vg8#(e1NNzm2L+1bET zR|%(WhecpNTZl-0_xfQw-D~XovE?IR zU7fl*nn*phj~}|avQsd0bs$f9o!jFmXTzNDZ`Rc%bae?`T|!rv(A6b$bqQTvLRXj2 z)g^Rw30++}>*{vxKONu2`dcFa1TefY1tgsuh!a>Li z#~>@5f~;^BvI6_qvyc@|K~^}1KK3B`*xiYY&vbET?I&8svt{6DLkQhk2!A===VJ%#eg} zV&%#|SI(JpVGbn@+CNj)q29&*hBnjOzaGdn2C~6_si#+{ARw!W2HacDwREoEa2;p( zZ~<9OOrdQi*L=2Y5um>S{RQYRKz{-H3(#MH{sQzDpueWeGalnJ9n&)(=GU*E=*RrI z#?JD1{v7WnN|(NVrRYC)?AT<;ZRatrpTOMv0nD##gUrS`+4=L&%!kF?_#c0iKhnm5 zX9>_w9W&~6IA7wt58R*rzD|T|+m!XFr*nS;$B>UdmLGFXoZlL=`Ln2dbF87vOF5V_ zG1tn_NZ}jL%XsK)AkHMRTFMnO6Ai}G|-w)qrG@A9qC2+>NBTNlvAYk<9 z$KdP0mw;y$<}A(67Z8>NKO0`Rfu99kJrQHrF-KipLRXj2)g^Rw30+-6SC`P$C3JNO zU0p&~m(bN^7VGK~y1Im}E}^SS=;{)>x`eJSp{q;i>Jl=ugsv{3t4mv5o%c`TIIG(@ zcgeAY`+mkju~>c@GkE9|B-eUqn@M?*doj6|L|aVkKk~J(AiYAcNIHLUE|Ydhv`OL~ zBQ&|`iH8#tk3%1Y`O;+hEu*Lt>L!QHu7}{1$4EMfit`UC9z`NjuqyORy@x@nPiLbu4ImcdixXcIwI0(;2qW0G0qh1s;yN`V{Eu3DDI~q(@h`Yuj0Y-yt9s z4?m9B_3lkwJ>6%^(e9n=>6CT3pPc8yQO;-o$niWbE{;0-mMuHCtXT2airComu$hp` zZjtTyXY4z)Ysa*!n?5~tdR*LT$Ug^GQOExaI(rJ{DJAE5xX+#Q0X&0_XUS7v=lZ(C z-EQIbA^&|{os@9ymogeg`yB4``zLjE?>9CcP)%H^ksU`|6z4hD+=mO(oad-ZK_vT* zHo%L{ELxPbJt^q~xU%B$mmHRWRs|&538#h+oh)=w9@#6Z6IS;T8 z^TMgzi^DO9YYV)8g5R2VMEc2bMwPt3Hg&s_x;pL8xvot;o#(k@duVvBxD{MK`78E7+)Cj8jdeft`Yv1fp@_Rcy+Fc|1ZWV_01r60=*w3 z=WZc0oW;2CC))0Y4ZAihSa5a$~*A<()c#bNdFe&cC!j=ejoabe`qj zrHkmo^N6|soa;E$zgMpopuYh91?Vq8e*yZ7akO#gSxA&CY3t5kz8g03w1;PWrek{Q z?97icDEDBp9G*efs+GX{w7~kbz&fw+?u&B98X;Gb{+uu87!3OqG?lTG*$ypVzI+QN zewHjbv4s0ExmL|R;XFft=OuA`;JIz~V~kx*q@XPX&qSuqOqq{zGWT^-?xk)=8&a;B zFJCT~)Ap2e!ff-@IVmr4FD2UnJ!N0+v8SCp*WoEAb1lr!52?d*o`LCzH`lbNCvx8< z&v**lr>)`af3}YT0zj}3b+RL$T#5Dx>aeV@xttV8WUt&{Y zW4G?wy7dCqh}1kO`w{cpv(22W(X9uxL?giO*IYK`3ig#~TEph2&Y!=1_xA0V(7`Et zQhWy`PvE>8wWej7_x6_Mmv_+q)h{m3;Ncw}JR6kug6ud)CXXDsXy+pA7pk~m#E51i zU_#alB=~I9eWOOX%emcqFeje|U0KQMT$AA0&6MLEiKN&9%td(tNU*I&i2JP(BD#J6Z6TJWCC z;A`vlHRK!}=jzBK)R%d-1!mkoVgJKEj`Mu9ZKsaSIWG1;>|bch&V4zI%egGU5I+XQC+(dmuhyyX71S^J2D%}p!d_J}A?J6ne?sZ*)Z3wtT}1zX5Oe0Rj>e|N z<6hjUuM>Cb1;m}_fP|c{wZq@A$xym*ZFdYRK>Qt>j->)dMS}dEM%YI9necE}rtJuJ zy`Ohmp{wxi)g_7l3H*)#sWtFZh+U^nyl(_cK8>;Eyqx(iPWx62l*!lJpU$~TuBTH^ zXTJnn8wpzp>OVV@k|2K^hWv499@``3Mvk9s$83YNmGE!J9E;hGIkwWKf;>U_D} zgnb(~ZdCm*<^j~aAlnS}b*`^d)-HvXj5{>^aSpc+$*5lC&EK&X)6S*%=hxK@O#La8 zx%YosS2t>9{*A_k1k|Bu8-}Bf?&nwz)7ba8Hwz%FGvW#7)2!js14s*jOxuZNSplbWBeS}%H4 zSaXE)VdP=j7Qk*#tpjr&44r}#PTum22Zx9_7CH}1Su1icGzS61E zT7>22k$w8S+h^FY4#VQ&8pZ8|uAYip+_OVjif7{b?aw!=Wcl^0-<8tUDGx$aC`)@Qu5VLMC(WR845l59 z`;^I_1GMge@`Br|~ijo{0`yWoZHVFE)l7@x;DrTCNcD{K!qTNx$*{U9O_ zOT+5v5hX9|+Pq-Fg$3NVOj{nF70>lp%3G8btaphS$lCw%TsQ6)r`8J#ED`e*TrdvNjZ{nKl(^ORuhob1Y|V@tKY` zoXm&$F<<6CXpn%cCa^v&us$uY&MWHjd}B~Q^gV-Dn*KN|OZ5?0vx6DQ5YBa;#2U+X z@Y~X*ra_$3f*ibg)9`Nw@B1m!Ljq*mG5ydut?C?|8ipZYoVbn4hF zi!wWHM=3vWjhN>&@!RwK0{(IzCHF>952w95E_qH8 z&o!nUtaYA2Ycm)g@<7e0$ooopucQ*DI?ce(diaxUu_o`YhZIIwZTP1;K?HseOB{pe zG%|GcyP&JL{FORZrxy_AKKNVUv%wqLn6u{dCHQUdaquHMbpq?WfaRe_aoxooFn_$> z+RAwj{MD_i8*($xpaF|NiBJ4Cn%wtj9#hvHUbpTj*S5`b&s^7`U6yWR;Ma|-F3n0K zyxBzp%xoVhT}?vn`kr0@quTAQofmVdeWrT zn^v!$IkWW4L4z6$V%vflX7?fWh76fge9jyQ<*UxYhC`hNg>zffTs6+)k+^#_6R3Er zL;M2zev?d89=dvc=;}?NZ#98l)TBuxoIq6u-UTllJm!-k?E`|;>#5$!|L{1jMg&drfOI9JCxJKEY%XEFCbfBYda zrzV$j-jCxbZ9y1^XLcC(V;J|6^UO6^Ftp`48@xAz;f%-lOh=ua{o0Zxa*2^IbsyR& zVfCm7`w`lR@m@6EgAv#;_4@eJ-jIc;t6v9Q9mJ0T@q2;z9YK62%BwTM@Fmn8+qAF5 zUQpFea4(^<59An3+g|qnlo!Eg*iz?)nckMAOOGw(Tmy0E+yqB(W4@MsdLZtkzXSf} z{S!)8FArV4K6Ldau=i=w(Xr`xE5J&G@Tmy*z^{UjZPJ8yJn{}+-q-DdGf-2%{`CDj z@H-E@cEB%(p9^k*!;*h>bYo}-J{Q=EL1~_^#Wg7E$JA?hh8)+^`OP5zABCN1Qqs<( zB}+~%Vf$k{4WMlESrAVTbmGc&Zu7H&EMRK`aS)zljW)xyADn-F{(R~rl$_1Jm)?G>ZhhT zaDNIIWrZ4-GTNs6%S+FDNZd}v#=>lAJ=B|N)YD<+^f~3i#fxuW%ss=(c7)-2Z0{C`Jt`7Az>Mpn?SzR01NRsHjn6jm8pN zj3!2-Dfb!^d&#|tiHRw;#3*X6C8(g%q%9!5E`1RZQN#)gN|X72zh~#noSB_7GrKJ4 z_5RO?mnl1Q%6r~<-{*4UN6V=Jcjjcx~Tli7EclF)OPnY*@oLd{vIF33Wy}L2`A5$UBi*ZgcA4T05ef0V`8W>CPjL0Qn)gD!L?od z)xhfJdAz=8UKGJ?l&Q72X$X22(RF^uZU<60JK5)9*yX zH1-(ku45=Lq%!<4QYrdPTQY6hM{q#Hd^-Jz@M)8B<7Oc*X9G-e5k-rLB zP_W5uG7ri6>4zU49Bv(mh?6eSM4zXeBxCDURY8@p^}Fs0?sDEf>7?MK{r3-uktWq} zt9!L-S?Z(HS0`4{cPHmY{OtUrZ%iIAhYksku3+rWDgWDLE>#@93e^1W;+RpOa;J7U(<}!~M?Z}bKM#@u?(-iq@ zJ$Lh&>Yls(U*P=nwGKk8zAadNPq2DhuzK4|7&$!>hfa6_29(QapYfTuX!F{(#RP0a znXz4vr>g4{=TtNOyD87lqD+fubKABhgt!-OwVj}VW@`!2eRW4M@0p{n{ca64;~wH= z55VcOX1zXZ*sztuqTDqxx8qTNl540w7JqAMCPd@dxJNXO^`pD4>Pz=N8;3V0 zuPr%l+_G^C=gph9O1!SE4b}+rZX7qPm7&&W9>*yf23}0Dey%UB%Ivq!le)e3<-_XH zN!LBsguBic3V-&C!0G|(N|5jKN?($g(DfVY>+zU*7vjGBxq2&@j=cweMof9(^y%hQ zgqe)4A@!r`yU;hsj`LgQSou%#K4?BdzFk3x?1BDE`}c=LHlaIy zSxP?qUabC)f1L4;p+h?kT|iuYeM92vk!`SI@L)n5GvwB@l>FJ)0@Kvr`u07n@8rp| zCcmo=1~x;ef$9Bs{W0#6S64p&(!$rV>)RMu-5ho6rs->v6Jq{2;l4i`?;icp=+U!h z&qwh2V`?muG+PR*QA;B{U;>v5s) z{SugSvA-$kx8&Y9Ov5Ox+4#dYDwVS=;lQyTldi%b?c@X%Tede z7>&GiOw`ssHn-C}PkjM$ zC$tf?dBx+_I?}FCQ&61(<50%tt$(Dgp}pwXH#VRT!I+hCbjQ6qaB^DZvl=JUZi)MO za&_UeV@4h#X~-=ZqHRKBukB)ts!wV8Mq)k=Y1y`O+s>WOZ3|X!3s&C?tiEG})xQB& z-vg}P7OdX(@}520_H4`E+cu%acawjJb^LuPZ9a|7*iaoFXL#yg4Xp0EVjb2xc$JA@ z7GQmadLfVF(J}AVajMOF0A9NS`bOD1n)fUkrs+qh(OatD5$dj|F1t+b5w6aE;+`5h zpN)s1y!>aVQKEgEph2HGO5Xl6(^HA}Nu_SCu2CI7#ln#ztE%3p>fL*r-gn-4z@1lI zanco6^}>(Q?(Vyv{Q8qmj#@Zs)SLC_mlCIuIo#UtVn*GGMK`tao5%$=J;B_!|9=#`<&DG0#7} z@igrF0A0UA>u$ihOXuH8=U+nS_n`AD>HMUg5BZeFE{t2rIiwa+xDMl+%*_AhjOq(B zFA7Yy3~Z)-GGxf7L!6_XyNSa8IQiTQoIggV`U~1UP^KOVbqPbPzIMc;TmT~ntlku? z-n0iJr!sNqghXqGRJo69nl{Ca*CguBI*(EReo^PQx@l9~X$AQq_F_&`V@tb9KOB1G zq29Z(Delp7Vsdllyf$a}@Revhq@L0rBnQHr1;?Y~(($RkiaoS(96RRX<5<6oTyfWR zF}*%@^~$s*$&Pzi9hiv|mrZ;N-%ZLn(Ro-NhW<-qqS_E@u*J?h)88ffxg4yX*;m<5 zYR}}$i`Bi5cyHDJ`IiZ+?-=a3qwAM}IDhMES~Jc4H+09x2Cg1BaO$F|Q{N%>rcD{< zTWaIW=P{4Ne2#tbTBd8N6zhr1?dLwo&wpYjvFw@;sr@Zf7EzKhO8j;&E7pDlB&{3m%Q8O1+vCx0sw|5#7_%pMANBKo&+oe*_opvkygnB~^Y9hJhnq8^-W*io>zT&CquzjZ z6^!fDnL47*p>Ims3;{Mn6KsYi*bGgu8Jb`-xK0*m;ZkcJhT0L{8|QfMlk1RLDaO~$ zqekyw1+m@Pvlq=a2T&ic*QGv|IL&n@o9`E$Z0@6Wpr8ye?b2S z^nXDA2lRhH{|EGcfK3?8RWHf>b@k@V)BMhNg6~*=y>;uLwLO)4=CM~BtN*pz)Y~EM z5YCnQ@mzQHm+;#%M|U-`mHG2un?G#W+F|O_h=*66d=n!cF!B=2eu%M&$*FL&J-nYr@X%HDtD`;40I z+K$EpZoYZ_&G+B`>HYost?Y-VataLL@qPNN>~rtEEAjKJ{OMU|eRh`iu^1tH-gVDe z8?R|ojHyJ!Fc*ikY}&GA=cZuwreO7^VD+zo)xnsxarIST^#H8i6s+D9tlqR|n>J0` zH0206a3;wHUCZbEXyW$O!0HYoZcEELJaBxP6W*IJVJUMwG^FRkm=5b)$Oq9zbo|mc z@m8d(az3c0T^-$Z4B)XJp^9wP0c{I;CTf=!@pWg87e9%5Ja^q;$al`+|9=YqfA-|b z0|&l8@WBV0Jou|$?ft6@FYIyQ#Ya(dr8S(j3iA2i8$8&xZ&jK7Qtm0mucsWFD8MEP zSaSogda>9`X20Q++OzRDEl)~ptiRLx8P?)(zEo#U%{e(qYRox@ckUc?b`H+nSM)0P z3Y{zT@#)hOyNEsHo$15Vm#0rp-=1qFR>QCK=gHp@+j~w#HCkyC7tZ&o)#32(px)j6 zyuL-h?|#Hf`Zv{AB$_nN`A(fob-nzX>zF=x=TYW4+`z1ZU{L2V) zTJ))Se~d-xuO!cOHTM1M;OEh!mxtq6|B&O@xQ*HZjz{%<9G~LWti6zZo(U4NCiDRS2IcdH{aefoRTKX6`D>(kgMHh##7)Ydlk5{s>8y1GO^7sKkAeKhdN zmlCVn^*LMay{J2g3;%snWA&uD9k*!yZU&H&B={(+^CmGpOk8uZsUW+e!IoSRH(zuH&J9()HlHma&o6VV{3m%Qug0Hwg_bqJy{O3{s96r4D@D0Z5pK_hdNp0Le@$~Z*TVlKKz9a5??zx?x zn=oO)gtz7AfbYV*XZ3m=3u;y3=O-<@ZjHVI*V))u-5NaV8)GV*qF+P3Af~-X2CW%1 zXvV@BGhTi5!&i+NyQaGaiZ{d^+6wwX^?^Ev7PA%nyPxy(aonPB-~Gz<8#CsEG1YHW zR}Wk}a3FN@e*n2|mkY1I1*qRao3+jlCF&o47aQAVFfT*A4%avIvuG=rhed4tP57^) zN3R&Ij+pt$#x3;yY8w$DNY~+tPB!<^n69;cjD25qRdCfEcLaAt982@b)iE-@uD+c4 z=kn8y-54{MqbbfbE-&WK-E-|L-}mo+&d+!69)KGIaAWX;n{Eo||A77v=>K3$b#(xr zKY-64{6l-x&zQ3*)=!QTahy%j|HdZeAUSucvwZ`oT3e0z^&NOS!~l0&*tTQLiBM%gg^XZ^K#~#+&s0(JN8Q z>!9q*=mY6ThS?taE44Ayz`>35S0KrYq~267SO3QK#Q2f^EAw@%30feBEOUUo{bx?7 z$oo<4Q_SOIb&A@#`taCfX3JZa%rccX7o97X%l`ds4eo9=yI( z^*%04rO-#+0j<}o}c%7eqWrVZ@|1Qeay~tYR%~zFs5%kQe*3WPR>xSR%iBt z8hhP!&jqi*`Cm;ZYiG+zR69(LDz^1n)?iGP8KXum8|AsX28iLbQ{*_+&ACsXKL;u{ zc1Daau3k^9p6#0Kvw|0tzR>Ka>OR}p=U42!qQBKWtMHxZ&*7tUn{ym~urSBPS{Sa) z#HQYX^)_kJ(n<2(?c)l`cRNQ72WP1GR}-y07x_M=9O_gvke z5hFey@!D&PUR&+lC>P$`AaO;cACgcTqu4k|!d1AOl6^J_{g(fB^X5Tw?G*dNF>6d< z)7NavJFBS6^WM(C7o}c*8L;~H!S>s?3);1l3ui2nF22^>H8I9Z>t1?k5(Nns<4azr zZ|4y_KE{9?FZ#V*yW10y$Z`a;Y_%O&Hx4Ej{#$75TzTcWSKfa6H*bIBkzF1c zJoux**8BQ|s*xdo+I2>rr1`k|BMZ$T1+KF(vAQ8Sa|JLJx??I_pe=~|{?FimEu8I@uHE7e@rUt-a%~0kZ~c6PTlDR_KlN_JLi+QkOnGa{c#^=) zk=MTh-S|JWQE>|#W$eFBY(3ULo5W@?U%^^P&ilsJ<#vZ2WMyKrrr<)R;6kS0 zLZ;wCrr<(GIn%L|O+PhgsSc6&SsU;_0a3=ft?8?Fk+sp)F48VEf8BgYwT6wQ8!z9U ziKwWLKXyp6&FKGt{tv*7L6_4{59t4Z{txK?fc_8Y|A07eFkVilT6D&V%zM`_U_7B9 zc1s^)lk~s!s;$qg?_PeA+y?x7%kcM2o3?bC_JDqSd8BgGwbOEKOec!m&Ad|c53G+Y z-#{HcYcW~dNZoe*NYOoYpYq*}0VMhV3$0&b`nGM?lCAlAkBcrEbJ2}A_GkWk>A?YT zaAwo0XuW&C)cf|^UxE`b5Uf0*XIr@K*<<9MbM{qi;>)pVvY&{a)xqkiLRdZZ|FJrD zld+Dd-*UQ@?q(X>jL^=Wz_xD2;-F#F)-9vpXElPvYA9H*fbyXK6>8F`lVu)9j5}3# z6lRW=Gkm@dvB&zE<^mCh`o8{4pu?lXJ{vadHFOo%4kr3)9KhI-JT3D?67X;42uVYx z$oov&+pEW;h6t3XA3-zzFZoZS-yA&}hWjXOWNVyVb=9_4oqX~^C!cZvHQ>MTqaX28 z3hUa9p+NRq@qRJx=DL^QS158SncES+o^oxX`o-0A&qAJy*uuEE^P+L|{{4gg<|kPL z)0!LlQq`A}Bh{)^&?<9|IZv|tbv@KhH=kVpu6i5txXrzh`>vLynv}k8ZVs!?nXbL! z`{a6R+5P7XrgYEwJ@JuTVso`ztM0jH#XV0w_3=}$Qh;#!^pB^%^wP)Z1b+Ot8*cdc zhV#$=`223&R&_I;ncZLZf;P##&*8$WGA4>1MwmNloWnI(EJp4+sd}TUs~1(j{POyj z^=%ltHRoQPQTr=b6Fzw)Qe`#WG^+%iyst16Qon&#%9y38Nu? zUA!nZ4hms)=pmJjht-2GCsxntq`VHx-hKX1_#S=HSbf`I+ifX7ys!91J{YdYYoION zNy@~-FRp*_#R+dum_SH<${H|R$nAE`HioL6m}{x|YickgUSZuU&-79Q##9)y{UWhC zdE8?4ox$pdfz{7MTkX2*&c5yse>m(9efsPO4}Rf9>t=?fd-ZAPFWmt921?nE`JBP0 zK1Vb6b?gP^j!Jm5l#n2=?usj*#ohYoqwOCZF=FcxZ)_-5U(I!5KQ#r5wk}-n^Q5YMe*5g0V_%1z2cZUF%E56zh)S+7a*f^zFMJv5&rc zbIi0+hH+pHJCDN-HIKyKl{$CkNSbS6ZbQzr5bK{!Vl$XWXKY=~IsGh-SL(>Dz=u0? z=38(fQ!mS(HmBJG_Sx+tRuzt^&srymm_?l}1`ahum>mJv&9supT?l%zU$9#$k^X`=jA&ecG&d8&N}PGv*Z@3-C?fGG};*2zi5~^zhPKi z*{j#UUiXt5a7aPyZy^UFI;+8})dUrv^mqVq8Mq zT>ZIyKOzNj3Xi!a{dV)eI<-TT;MFKt3?x!{&tRW{ z6W69Om#By~O{vq1Ur&AP>?EFKW`CI{xn~f^>iSgUSlwEf4?i3{Twkm%f8BM^xyt++ zdG6}G8&9{Untnoad;Oc57vfIyb{jJP#DBECujO&x8FYf_V0gx`uC4NzJ`3F_hUTxOeZxupDo&RZtus=UZ0n- zF4qA4Q}S29W1oY|jJJ#&`SD1#M)YquXUlJs&sR|YgM$n=a9@15}(w@)g$}kOBPp;x;^piqYo5*+>}^7-w%t_$Ty%%?t|O3 z8!+Q6Ttrvv>sF6~kWODpM~{1hA+pNYDY+V9T}bS*kh3XwR(;C`)GEnzRXxX4$Vri3 zy^*kb=Dd88bZ+T9?yZR(?TVBBcfI_UTXws}wY-1-Py1I_*HnKr9M3s(NH{*^ zTc~sG_3nxH8lPYM((<1-CRWe4!Hi{ND!ef1ok^1>On7%fWGje0wH4IKHg_#|-HYF$ z@}6rem`5guDdPTVr_^%)mQI|=KV$2dDSskF`ZIGGfL-5oK21;4wbOO1b2bT9)E}!~ zU!NP}C)9I#^wCGHZ#-|_8}rOrmxFL4A~p7xc9?m~bxt;Y-tr`kaT}}o#V>+i7|T}Y zUA-dX>SD$}{VAXpSwQ~>^nXDA2lRjNjo78v`>E@LI>*Nx6VU&;@qBB!*^I4wY_;gj zTUW2n9QdSzZIb?X9&G|98WnK##9_v#*U1EM{9eb`=GzX z90Yj=YD3B8^&VJ{$^3&SpA4SVhjPUg!4+!l$(Jvyj$K|yk#-N)twRSw52r#`zM5$; z+@|pVxb$-$kWTsmWs7>@x@$+OCOM+TwnD$R!k^Dzb?}z_nc`SImCS*6_vIfpeKKDy ztRClXkrol^y@i-`)sP{$8t1K6M^x=e$EWjzzF6}&^fjfsFWoV9AJQMNz6_zvO#EKG zP45}*z?WydF=NKZ=2N+bME-4S6BUTx*EwGFMwyR^*9!igGNoq93oopGp_a=M{Op`_ z0yH&u@7!e+kC~3+ z3+39``Sx69A9bGOo`u{otRu^I_hQ+$S|TTk$>7jqheH=bDd2bg=#r9R`j)VcH9ozFgd;@Lm_=>U9( zegE|Q^L?N1I|Yz6zHi@Wp6N?I(eKD7`Z4)L!^tO_O+L}b>3pJWhi$BR-yn%@v8i2?SE27VZ{GZQ#?+&6Ecac1w_1l;zG1YdBK?ps63k&Y zZq&W|Vt9$;FSy}`m(iMe{;$u!2$+~on?xfnral%FjOzx&hn!A*Jm`5l<{Yg=KgY3p z`Ey9l5fWeIVs&E$Um^~CbZdM}+~Oa~ef~vZ^{p$m-dfRUbu>hibB_FD_?z z?9#DgUng&pwB^BS9)jfda(zymuy(7*p@{b$acH*-Z~-$d)Uz71n& zv2{Fito$eG&in>}X=pJAkWzZ0xz5IAjS~-FN-iP%_pedsX>ao4k70hl^wKlH>Yc#q zJ3UrawX$l_hQ`&`fz{`O)wzy}haY~p6}+2ol2?27+1<|m$xmAS2Y5ZasBOg?;1t!jZih#x{Y<=$hxpN2O`!J`(+HvMGm@A1Zwyj!tnW;P0FB{8d zNDr;dAwz$P8gud?vHcey)?M=*kglZ<gMpPwfCFf1jM@o;@trlI%v1mR>4-ednx@2m_}W= zwrv?Y=mnf{#Ao>*VdJoup2wbCGMke#E@IvFJ8sju*XA=3^p65(ol}oG z)Bov}#2j7MQ}a~iteJ1ChNWB{m>Kv$QlSsTSfp`x{f_zzs9=pQckcN$+`2Ev}v@ywEOUtvz>L;GvpJEJ8Y+&-bvRh&tA>On)jn4 z6ueTo@bVDz@F8@-VRO(MA2@Iw`X9AETi0Q=bqaF$ON^Z?R8Wdo;QDs-jA-Y z=wsRESAULvbRJ2MMb9gC9pRj&-Rd0YZ#tcq_Ccukw!B<^5m=M500W&lKO0nV|t zT#NL9%DgZ7ej^{smf+pWipoktOW!2?a=N@+F@*J?p(*uw^}SbLeGQ)XO7W(?ZgtYY zq~RJ$j3ms_x4%Wr_aO%6DPh) zEc0NW(GEOu+;K-8 zck#u0T&yo%er~^h>-$Zf{O06$yA?!q!+=UH*HNgNUh+ zwW&TFi>>fB-t|;pW8HCEp*5t1+`O-(5nK9wb8*dAave1W9qH<*r88~X;%Tpb^y;g) zHvY%D3g$?veP?Y%Lh{WEEqEKtW~g%|^1T{_6?1Q>7*XtP9dr5Zzx{3STeaTDjt%Jl zfc_8Y|A77vu+;)=wKDYBa}QDUmAUffpqq#_b2jaL+7j9f+Bh0zg%3rhrw!>aWpF2=Ylxb1-{~xPIU7Pn9YXaIQwQ9`s~qtsq>gV4zA3dI;A>wBDM1j%$%QC zn}enD-|C3)Z$h|sV1tJFb=EReMgvIRmk-uA zre2i!yz+F~v}x96-_7v>2ge(1|)b1d*gop|-)(-#VT82+d8>2jbjoo{oAW1$Nb-w>ICr>E>RT>jsp>nr+L*7?;>`!4@Hl3o`*ulV(dYs|ed z*GJ3)cUHYuq%W)0evj+zipS)JE|K**^QU-6tByj@NJ-!~d_* zhSt9He<=Fr=?539=j^3KKa17tH;3fxz37X`{)p$`$!VN2<%21cKAJSiIp5qTZFJWf zb3xTNjdmLLjJ^l13;C4uDLZ&$sCi7xn6iU4$RmzWFdti1~I=4srsPh=_ z?)dXMA1w4n_6wT|t4DMD=HRD2fa-@=9DX>{@TpKCuRs#`Cb?bqwPJgPxoXrY%w9$A=qvSprbqZ-sdZ^%)NIQfzu1$cb4%-^uO*JW zHTvl7$!F=s@A`@>j=18EJ9fUqJlPQ=mXCOy-}dLOB_5t* z$F*v$&Fwu>LtxPIL4#(q@0IMk3CG_X8{t^S^ktWI1FP=|REWyZ-0BwZ(n_NaJ6~I=4B5Zx?(7)?Q>T;X5?U~#bNw~cw1Np zl65G+uT$uHcMW#k)!acf*W^V)xqcAp^`Lo+=FMAU-oLpQ@{r_Ss$-~@MtooC!KPnB z+g*M+uKO?t5t!U>`r_%>3d=I_5|l!~X(?=l6t+SNTOqxJMtGD5Wo%u|ZhdRkb+!KH zm@yxYnfmtBsq*pV<;#QD{{ofmaLQxtoH=x?gEp4UkaM*W2V0Gj1P-=w^}-zN*vaxV z-&PZsi<9$hwFoCSwyoybsO@fSUHj7U8R@DSPd2VB7hGPxJovNE4zSmf<3*dK|C^+9 z5FFQzZ~UpwcWc5NgoPz?5TZModmoIEw{IV`Kk204BsBw!p=&2=gR8@DJ*Ed92p%vO zRepi|2K&LBe>o2FBXajx{1tAvt&l6(;ay^hpBPU}x8*!8r6D=mqBQqCI!1I&J{H;d zbHlNEk$snWc;^f`x5iZT%b6o$+{>H=`M%bVuGL#MJ|vej)k8mCxw@6b<|K1_%~y2} zGkytM_8$Gi>INFWR1Z?#p&Go#IGyK;Y0E`(kNh5FH`-VSCTG1_GicD9`E%xc>f8<& zG@Og&h8hEm@x2r87JVA!a*@{Aov`4Abk~jY_xi8v-+v|rODLr|B3w7nU4cIKgV4@U zMct_~P^h@Zin=a*a}JqRewIbgJ>|DP;>qhqnf%^Mf@M%#$fKFSKqQv{qB}qKkry zoLkJ1lglKhN^X^$Cii*q#lgk3K9HbI5~nHC7xjE1W)XvvK5?>KpECy0rqkY_jiL2D z;|!<|r<3w^EGgpc3;7Q>*1R8GN}+onzNhAsoD8`c;II^Sw>q!tj*mq+LOVix!h2@^ zKsILQx6>O>i;bOHto{wKdJE>I7R*a6n3q~qCXXB~n&NF8fB%T)W?mE~MN!OD`uqaU zue`W_Vqel7`G1SLDCsf$y!McOHtqC?=SG}r4KYjeL0@@g%`0RAF^*G=;}qjKb&Q<# ztY)fvDQAnHUER++j&XAzMmqXQ^3uNj3T*~$32h=Ry+PGIjx^*0^_l1Fh20nH=Pj9U zTQY~VVh(A=9MXz8q!n{Ws|pSE4Q$O@wd&BJ6?!?X(93BB2Yz?r>f8S>#_CbeZsfT7 zLa>%=32Ura{cGHp-MB9uD?4^Px#HxL^%J{piXYT?srs<0Y94jEb_bvBMD3I<{z$eX zSTMkj44D4{;wJ$)*I}jG=n_gi#z$rJJoA+jV=Cfmgf=Ya9>!@IQyY@6o;h}@CrRh_ z85ov6602_pR&EVe$3cDW1s8O_;FrH_`OEt$9`y3d>t3EQdG{?a)|C@!Jb? z{F&42i^PpT#x%S#?dosUYetAdgGwldg4;lxnr?}4e z_#szuOYmGF>6N zE6D3obN1xj4@2k}D%%{WYy!6KgGvr)_b@k*un4b}8o8fCw z+icF9MRW4C&AQ-J$!eR$PL`+T+G^Q&RpZ-g+LPLq+LzjyuKDur<-yOH6Tl^`&BL@7 zk$LUv8yi#3y^`8j8>9bo`kupt+KXZm=LP47w6@>Mvb6o8z2%<0`~q>e_ON}eeXORl zcC&pjYE&@FxS5)Bau3W`P>WwXTR(^WXKtz*b@k0-kIpFmn0t1mUX}69^hY)ftGkng zEjopSKi9tf)x+w}LHRr`uAlP0~FUUR}+TGpI=zIS4Gv0vjR)`QK@F?Y)|ZLix#XU|K@}Vqem~z^XJy`Zu}hxfB9(~67t~FoGqZ! zQ)8B*t{QOy#8!lEk^L#j2`F@`@#`sOXYYnhaR7F9N9?(d*hC$*=QQX#R?vc23z47b zuVW_&4eElbziq>=)+V`#5qFyK_92Q8?`$1S0v}E*PgYQ+kB#^PNewX z{Zv&2Rr&|5$??~}256Ml)@xTQL?1}znj7hfK0#_%O9{J$p-pau>CXkKirjNjmi;bNT zBX+}wcmPie+kaTCmYL@M)|Sh}FSbEx}p`b6Y3w~^CaoqBCJk+ z9VLD5-x1D1N3iln zx$4M1U#>o)dHq!@sQ5hdjhTh!SZDci@OOPbqxP5aHm>@+>UWzbKqi0xS+icBWzAyw zJ9OD?QO-JU*}XHh+hU!wF>HqTZ!mn0KQ_!~u{MeIjLD7i-Yv&2pFaH^@)lF%EvEi# z%`oFI)(kT*zqU`ZC!24pS<^eR)n?8N=>NL5S}FT4yZ8D%Q{PT4BsG!nGSdG6{U6Z( z!K~rKgW<48AHD6i;5M-0VLOiJFPd^|5<&b4}$Cs4pk?-FyV^hdR$sJP|x$F1xubavk(t$Xl~d{I0wQal4Lo zlxudh7F?lziXS~XqxfU|@1>td-%R*q^k*J79ILytgssS*YQOQ-#Ojeg3UWy&nHOgr zjmIfWP_?|OY9U;%^>Dc|d~f-Qm}Q~fMTuBi93xXY`1&xHi-A6I3AtP)d;qaqo^fgB z+@7Qlx8xKDB+-}c} z9=+{o(h4mJLtx#mWE zB2F~EYpikmk7;27GLNfi>8%)MYwUTy(4`f+|ClhFhxG~do6B2~r-9#G&BbbpC%P8L z>v!sZ$=2^oAEm_D88PBuuyC(%>@adJDLrzc5mfkb2_tMG?IoI<8luQp90HvxSL-b5 zgp%i1$@z8U{JMhGyMoob_NaslT|tA^l4&77n%Bi1SMm4PX%lH9Y4R=9S1H7$@btoa z!oUbM`SqPg{t|RL^p{MT@=nNEGDlf|i8Xp*7&DHe8n*7C+$}sBAC5ofei!QP*F0Cs z1Z=zQ3X27#+vmPfyCsfec3W%?IgB~vwDcTuA#=!uJubZP!b(~NE!e=;obPv_op#}c z*Is)eSp5%R^`2n${k|etJvi9dMx*De$FTYiVD$sQ>c<^@+;JDSzVJe1Lw*jdyoYiS zPax4b1V}S;6x*}JYcHVw)+wwzortyViX9&ual{b;{z{gtdhsK3M_++gCB{^Q6B{G0 zUf}4(-%2_+u{u7DwP~#Wb+Gz=VD;mV?+jjUdAT^iSn7}=pAPYJtJUU%M-%FH@wt!_<6 zQI_lF9jL1_ZQ5JYUS0O;tCa42lJYBes}T*=Hx1-u5*4;xk zcPaU)rJ7JmF=6{XNeej4H$X;tXV5caO?AKd&auW93|-kdGD(WVs51M zGF>B_gSCA|elT+68IO2{m?@#9ui}V)13pHJLK6UYszKLm})}7 zH`M-!G%}1==o?fYWBmAFyt*akzv~lJukU~Zf&)t7|Lop3;8}i$m$9qHocEIsmw#H} zYx&o%$HYS7`Dp6P>F2k8>faEQit48xfsj#4Cc&JwxWw2Y3I`TGKg8+`k}^h4{z%Hc zUeEUz(1y9A^oCpmTs63Y#`FqpCC{%D=XYw_mvH_iJ@5xs(kf_}L17DXn0a36@m)SM ziPo3an??r28OX48NjkUi9>{l<18VF-T{-pm(O`O)n47iQ)Ji0p!8lGaj%&5Uvg>i7 z3r>13JQ{z@e@yy(q4)i6!p5J9%^}^GLoQ6uA;7#}|GEdQl2$9-u9!Zf_%L%e`KkZ`_=G$R%%)6PJmmxUtiZP;0s%UwO~Aozm{)rCJQ%FLedA;GMU0aGwPWJ$C-a- zzNXwDG3kT}OD4Q6pTOF)rXEu_-`XT6;5br~h>9j~ zBXUnRZmWs+8q-#b&OO#OMW2dOBfBv=muH_1=>J-~@tJ3WXS5s5XD?OuJ}syMdh`yJ~Z!zTs;g0`6b-)1HyQ^INiKBHEfKj<0t(D&F$H< z-<)l|JhcK)hNsSRZEl=-8@ai0x%VOJn9Vi|Hj~?L{XFyX<+GZ%ZeFVKGwo-21|vrX zBh~yfo+d|C?yH=6^`Gq@`SDsvYS5YgZjC)-?rKfNx$>pr?a_HP_%Rv;vB$>4>V^BT z__=;HvARF&LK<-4+Ou^0_;=!bZ)ygGQCoblP|NFRbVdDFC{`b0b+4<;zR{$|3<%J(94fsX#p9l|fJQQ7LUVm~ zK6&Da51)Ac`KixCk$4HW%VQ5b@SO+lxu^R*H{ZPX&DUPr^jZUJ;&l1+a+J#XZ5OLO zp-zT+8TtOvQu*)M4%vWb`5j)yu8Pk44e*f{I%NK7WnQ~JnEydok~F?w8{?sD;*hy> zU!#uRx`AXCJ|I_59sPp&XzZvyW2d~4Q+PzJXQVBl4W+q>87_L_qKl3idDKyREMoP^ zTvV(Fik%-E&JiQJfz>Yzu{xCA8+*_yX%#fW6{ZfB-4^rNQM72o9e%lbZ7jcSpTf_d!q1=D&%DQcdnkY3zBW#7 zPCsTB+hW>>w9pERJ%$;^ye{+@eRef}UrJlFJ7&eM1$hF+`uVMz8cd8al#qIs;q~{w zpH1sRYeiE;dPCdR?4v#H1lomv|2rn-qofP>C)H`jq>ukMYRhHsW-SLkimOLhy|%{A z2J66s)xVBkZf~&qk;HAzhM#p!_iL`XtMy%XVYWTe|KtAsk;tC%Cfn<5xLZa%`L8FR z#GQ1HuCnvGoOfRLy}EaAyH(q^NERjLdStJrp3jPnsTluyL*nX*XXKBw*p_r|1+e<| zVD%38G)_6C?(wJS{%V5ng~Zfjn1R6RTWm6{9)Q(%*=4g`jJL^~)xNPtne|i$4O%^D z=F9~%mlkg{tHfGE+ z>Qoh*V;u;M*FeEvC(_g&78TlFsjIC2qb|%?g8Y*aim}du8pudLT}!C?G^BnyWVGO; zPT{en&9F6CaXYZ$c3{Qrz>3>}6d!s*+y~g>)26*M4b_1c_2X+B=*6~Po3%-@`dHbMEoQ4lb;25AtL5L7>=Bcn;Zdaj zv)XFLii{u2$DBMlm~4&rNC(}qE7v=2lSg|_?Ehxi6s^JPhtmID=>IPCe;4{6pM3=l zw^L3-Hc{!sE+Jn}JIq@4a_r>2V?TsA-P{BCj_c5{F{VCt?8jsE@2C+bH^ck`^G&l_ z0r8%R&N%l`|B3b3e87jpL-UK@G5?(vFDA8)!lJ@wy!Q$m!p)==`wF;*otAp zUW2QpPD`jqf)N~I3iF$s)3HSh^xI%)oCrLoA)WYd2E9QeL6^OK;zEe`(E%peNu8r;^Uc=?9HLXx8dh>3_pXc*6U~N zV=mfza>+{6ppTtW&b9b{X5&R7-ukenhE#-V;MvH!$*zkz(VLWhi>QqFTx z8#Yp?Lu~Cr>^w@nqR&@@&gj}fR5W~0wCZ{+13a6@mp%$RD|hU~N6sgVoQAXod~Yc2 zAN~IEj}WWB0#=_6R{x*@SRD+>bnq(f>Sv4>ojL9vVSYY*{*ax54+44Oe#jF~A2sS@#&L>qoMIfO7{@8b zaf)%AV$E}B#W!(v2R7*VU5`9+GfZ zz81&oLBngdrFFdbps$VqtA87;e%1G`y6U#>x83&8o)10LuX(?I6Zf4sQ6CGiYNY*H zJ)^pM;E@9dK61<>kKEb$&O3k9=0`s|eVfxyKLQfG`hZ_!LC&6eBRnfMrWygOC!S&6 z@3i3OlFqFFR^J?~-iF^zj~*?1xYkP~ab7itd*;m7a=C->6(XJ60G-?XV9buIyOzvv1tS5>e+J_uzGNLqjTUB;_71cX3aKhrafbg zEd9K)qp0lndvU2gir4X@t2$o z72AUq4*@G40#-Z(tcX9jf`$ZAriDIY{lqr3UqXD&EFWsg={r_?+?cNZ6k;`>!EvQb zUrkljr&XS_y0dZv)Uq)*E1Mr@UuCzAVXGCo8`&c!Ka-nno%a(?23=jCDrpt8Al5>y2407c`sj?I+viu| z#laU!+UqT0JRO|=IXN{;@ZV3FvUG}kM`K|8^%l_l<@)eZpxYnjJay0VdnUOrtT`{o zK>Jv{{*#{sKXHyQhHk9OJbvpb$%&701k7bO)~XZlLh5HM$4I@h#z(F#kjDm3}O8N`cl}#BOz+5ubCX zN_~*OC)T5TZ}jL%lir-9ZkM_c;!JrcWc(drU5otD;97;$T4z$f&Orv^XQVg%NuTiK z#giw$56&o9gPvGg8K;;%6dz8+TWf1-jH>`0reg|(TQ&ce(`NCx8f?5pe0MyXaWy1~ z{&dehm)~>OT_@aic+EAsp~N44k~LGkEaY!J^d$WA#)`%~CdTn*N@~+J;sS9r}-IuzEFd^|@g6rC{~AUT(ard7e17 zm)DpwiuUQKQC#xiBWdku?=`S3=6mkw@xhNje#c<2`UtRkHCX+9usYfmnMox*48mbT zp1rx6p&$KcWR4iWKr49>wrmyRN7g*DZXSxsjN=qqH>tg;hQ&CJUu4~JW$c?N@_-ZA zFbBSS!3F;!TVM!n9&HsZ^v;y=_&%SRLVJ_4DeQnni4w z8dLSzjq5<$lh%wDG*a81{p?HYG;W-84dI8`@3VdNv3fv#^`QRs)zeu05IC(TgO`Bn zH=953*Y1D)>vMZP_uQC0$BZF;WrTh#mfputVoZwHC+;zEV&A>`_Pu}i`|rPL>zi%@ z0$vXN@gz*KgE9RIYG-D~NPV9a8&eyI)e}!8{894P;yS6dI(`Z3q?%EoT^?!0ssTVP$Nck@{(`*ZH?2KJ>6o93JD*>RJ@6X# z0DhM!UyxFJS2Te`+Xh~F&z{?Ca;z>VK#XHtO-I+T{39ThBR9 zP61d2d7hpD`jwGwm%-|QoQBqHvrv@!zJwpomfwgR+Mfvvzld~a8FOrWKy3JP{jB<1 z^}FhSm2dC_q)&YY){isCQ5#Qid+O$^Z=dDA z=l*ya+g3}u3;Dm5La&Y2M4H6Laqhe`p#KBh}9{__{$!&N?HXi$ZH{If%ih&%l@|hpn0wG>&#DQ{#{8teX)z@)VHP%8ua-f zZ36uy>X#T(S8o;-x>i|EL%fFy^+N4?s25b=6>BM8*G3jwTi;kcIoA{;7RJ)eV>h-g zH(vW%pN2dH_4CE^auM`*Sa(S~+?e|aHR8tz9`4CxgO?I z%S*{03E}kwrp zZcPo{HW}Icm$lnYqIIOTqo`(ut7c{H;n|FLB_kF_dfsZvnM_K_~S=B{=fsR z9`JW9k4B9PR*N%?`I&dKH3B+yuOM^AL45unL#Cd;HVD)%hJ&o1larF(t>P0yj4T-Azo!ZEHqr}w<_@VGF>0xSc z8#Q6nsK+OO)hB?}r-0R82dl3v6RWF>Y;ARNMHay)s;XMaI8HH+Q;g#j<2c1QPBD&4 zV|BUjgi7F@5KCf%GLhb@dHjSHW{Q^axP;G)q1A4L{eB?+hbFl%?#-;`cdgHG0>KW9|o$0wI z>As|~I#7KmP(9?RkP>o1^vFF&j;z|Js_L2JpLqrt^=C>2-F+^31)cFjAt5u9 zu;?h{K%OR?axY<&Umy{D0aNTT$T0245>{soD?IC!@ov(&$92s(uljP}6OZf^9dC&r zto>ZUH8TOLZw*#&-@avgajO_X|C<_f)*hdaUukukyB^juyBOXv-1UGMLR?pWON7!1 z3GS<&Ij+0QGJ3Ngz4FRDa{E4u*#c^9G{L9Vrp=yhdiLBbjnz|kG^(~-UR>RnAj9Ao z)@K$KT2tJ79_KW*_2$f}nG@;}Jpo#k=V;wfY`c&rtX3g;Lye8q^>3(| zPkD`(W-`f^o@3(!V#A+{)wOS|+h$Bu&Q$F^`D9e};_7PS{rX>w|2GJ$?+#YP$M)!V zYq26}RoC^PRnjVG0WHk6%74^HjG!vVDhkByA5`P@o|-t=#m3A&1WxqVpMg6XKoa*vxOkYeI&4*0^y? z$Mx_3d4F*_EQfnwJD|m!LW?{Ol6`TIlBIy|?kOdg(rO5s`l?4;9bX`TDf9qhUc41N+}uUp>Aql7iU`*W5l# zVY#~JpRx`S`ywnpDD*Swdm^t+J*;4FSXNE>^~JU@4js!8&%~kMm^>Ltw^}_mY>$v< zLQUAPo+)}JdAB-#YMJMm!<6{2x}DIRGHWVjG;bwU>yaTVh76fCt7g`K0WAhR_~4EY z{_0md{_3KOcD(55qjx-duf2BMtBG3HdCm9u(_Vb!Xg+!oU-=bZeUM$$>f~XQhQDul zWY)mMjz%K39ccwyl{n4J<#>UKa}V(gXbr^odiN%6CfttZ|4Lc~E!4y*_V{}u2=IE} zCtf=j9@P}k)&zH0HKXdKmzKQr#1reDFgw}$(dKWtrt2p(_sMlF$#_bd|D(8i9IJ2g zxH>&+-{)?1---VIg<^Hm>Y2xhMBecXSiLV;eJoggaRab=I<6ka>P6$~c|Z0RAA;4V zg4O%gVs(Ck_u-~ztEPQI3un_Z9+&W$F|>a@EM|KgtUi=ErUtCOrd+Jf9Ad7sS{3HN zgxk^lUrDQ=DTK8ltvTQCKud3Ock~HT&iN~XL*?>!+))4Dn zsi|#ltuaeXldjMlE1{WYeK^r1*O12&>rz9sN5 z9>$i=J1;m-y-vJxe^Fy@>eNM3Kd_dWHacTC%FTnSa4@A?Gqp=J4lr{&LOK36(%aV{ zul_rvC!ZekCh-h8cIJPBd4nILnswS~r!|4w+@{SAFUnAUF9 zZWUw5CzMk#X3YCz)DK!`{aj}vF(P~b!V)Kk*N*M8F|m3SS1&%t78)20e@~kximT^h z_1d_4Sy(-3?D?DKo|vD9u2hUrR-q@HicYPfZSKeC8)?(5Q*Y_dERjz{PZH!$Wv7Goq8v8}hwa|?( z@ZQG7>K?Gz_SMGf5njyacOf16!ej#CgvCdhGZXSd(z@pX`pI$K&`f?ePHChOF9!bI zj0^DZltvq@Z;DcB1417bA)+uhMEz^zow3T zckI}S6Zf9jr_Z5%?!EW0d#}I#FnBhHoe2f@u$^gPjoJc_{>_b#o_XD2*IoCA!~XDx ze;@kqe^1zZ!i11VMjQz_lHZ4ZX}L`5cd8S@2^VXb>)}ZvUfc~ky9<6=0xK+~w<0b3 zqaL(MS_Lhjg>_d7J*o#Fmr8CgK9*16_I!l)>$}(_3z?s$VM`Am{?_nkpZ)wRDG$ikn8AsW%t;4&!V&S zv79Zj?>9wU9StK&3LYK$zu>{*QhMYw{6Dj2FPPoG|N8#w$H3V5PiuRtUy}8or>~5Q zEavBFNDTp)Gem+5wYp015VJQ(2VSp{SOs@edaIWwitog|Rf~B&D%=n0>UUhu)^jje zeI8hSO#%nre06`X_#&O>0iVr%Tw^$2S zUkFwo4^|&Sj^-=mXyWyL<&|nYG4GVomhzcTXiI2gUU`Mz>?>gP;b8S?VD)#w>gEk) z#&?NvFoH?s(MP`%&LLM_LPA~-S|zQ52484{T64bNfyQ<%8?f>ZClQx8;QuUE53)5< zirp7{RY>JeVJaTf4itt!=ILk}IdWVznA`AC59XM4gHdFHvhvj$Ei70Y}aw zs|A3ZOKU=3N147NADpTAZ!ZC;s^?T!8-ok+E^|0#x-JRG?Q62nKKrz3)9eey>Ylr6 zwHjUOsky$JXKS44rI$W`Nk7GV)Tgxe1=R09rMPxz8W#k{VmSX@x7u}A#kfrLZ72s` zdyFz^ohT!FR>Xl96H}J7j5)T@z-ah;R7u@jzWDLSr;b-YJ>tNR9=&`tW!t93Iq=X7 z&(MynldqmM_QdKR)YRbqmj@8LiovyB>>7B3L8p~EI)$7smianxQNdE`)e?b2S z^nWb}-ng(hQ(d))1FychI!bl&)r&zLAYcV$#UsT*IKV8i^v9xxx zadtWE_LX|^)^3yEu5;afk}aUE9mU+`#YZ~wUa>KFF}rbgdG+FcZF1*8`8Mj&>pv-W z&oX~2H2`9N-}qSFgC%WWeXMRh-?(l{#9z0@*&Enh!1fo6(S}?dbVCDl&mkM^7WJ3{ zmj!jxm!g|)PFPxZCDa|&HdgDB^)q&hGiF1f_oCwz`%p!yqJog{p12#kF`b={Oy&*Y znu=XhO~LpPBfdGJckk1B-^uoaADsPz6XAUAPuoWBS3>h|e#XzrLG`=rqPH0Ob$oB1 zCur|c4)1G)t|NLcb3Hss#EaX4)%ORh_ekeIolO1{e(y?Jh5qk!qb!}J>2jNOqoMIfO7{@8baSDu_H@d=|ae(xrIYk}c0jtjdtB(V#j|Hob#XiLb zn_r~0<8O!2qMYyf^Oww@4-aoXSbaWN{dMM__vG%>8>_bmt9PfLPrUV#OHRDx#2zHm zR?;eH!3MVGe7^&Yx#mQ$`Uzn5Bf#o=enD8BGULs*T)E|zTdt$QvC6bhdCpeBmNyg< zUzXOED->5R6RTrazTNWew{c_7ns(r{X|L}4>Z{M~{M>U;9{+aBX1CmO)$Uhab;jXm zoY9rqY;Ew5Z-f6SfG@v}d3zD#b1WcB-D>lH#Zmgl8KTEqXDWj)^2TK}K8rp^y}@^j z%`Nr8>d`I>J|>-G!4Ie3OggtBSbYm@4SmMuWYucSrG71D9WY?cfLRoi{e*mkI7cqv zcNVlqwVL|0<`$49cYxXe`b9`{zEfVqh<8VfSb&|s&U|;{rFc0*I|AQ86SO6B`Mw)J zt{!st(;WDy`@|C3g#K2-eEJgD`uZjW~)Y1$WKq6JZJK_aSO&(RlQv`aNx3mefoUf2Uhl@+GhG# zymxZ!^c!1yE^80u_eXTH^nWASYWYXVf1N#I@-y+MHPfw~E-zc{b9L0zqnFbyu2d^c z4!Af~-A3*C#Gj9u5T|buIvFby) z{>r1rRQ^4~`!=~vS6tEIigV6sagG>T%{yzi=zGXHw?=qDHSDdeE~f^cHg>meK{s=E z><>9X=9QY)Zk~X-?NQ8KuDp74;`RXpf&tp$@?6F4#^23pm)&87oy#r@E)zX?9cA$X z@^5yD*P8~bdlH4VuRd0X9u(Fi$=8J&unNBSJnEto;fd>>hdLztfWp%89Wf7O0&;X$ z`mUoM`2y;Z%Pny&0OzvQWwcgPXswpG?HRKob61Ldfi&x_aW%GQD(;1OeFbSfcaWa- z*lK#zHP%u*&uP|) zM29-`iO6BV=1YOsLSKs!N$$ro?&%zC#i2u&4|VN!4Ue=%&4bEa3$mvi*|65t(BFsh z?H`HDsvjd9QwSa}(5h-5w)62PbmD#q8vkixu`p+%#QEWd8qp(-)p0{za`MS# zd!q6^Q@mbAn@Jl+>v!^TY|v0yiwf7549AN3H`bx>w*k%!wS2&ta^S4vqi(Xl3G+jY zOBu%?yM=M=w_{tlGhU}3TYL;wp9xkUoaVs)4_Fg%058-&$cfYtW~tG5HIx4WcAk9IxURg$bzp^(%Dw&r}Fd8Qp$ zy&ZkHA6UH=efYs9#_B9XTR0YRW9-{*Cg=1zZ8>cvZ4qsDyLM{m6Dr+<6s5A}qG+De zPSSp?t*>5+179XqNBeQ*>Xj=OytiP%7!o6&-{JY^AK2o72ax%=4O8rgl;}9~$i0s| zvMo6>-_SR1zN_;#{8~!1gcxf$3CHTe$j253A^Ck?_Y`&95Nx%+OEezq^%xCn#&WT_ zrCh8Y?XKWs-tQ*oJPUpy{btg+6~XH2o2%tyJV`#7^Qv{1YBlDTjDH9FoHFUP969?M zt%;Cp$G7BytGBO1U&@hqt`7ixB0wFuo@3hp=T8FGAJByL|I2{Y?J?`Sh*9;y>xv_yW|KS>V^`K74##CQ9+SqN zSUs%~gX?1`VRE)(Xa~`Dpcy$Y-1_xf-mh=p6@B~kS=Hz1r$2vMK9_zZS@Y(mo4cUD zSYHd4T#o|}OdYuM&Z(UX>zUav*)5vG8pBpA*5lbDCO>l=stqL%TW*W_*w$1RPg-ML zL@n3b+Ux2atGiZ5N2*x=lY@XcUWGaSIP&@7b~OLDrd7~_Slfl?ooV5QJ^*PT4-XhH zXuyma^JW-l$Nkm^*ZrT6;{V0D@N7;x>8O*scHOIM`}Ui+cOKc;JW>4`<}a9A?jkL2 zSO3|ZQv1(2%zDf5n7dqeZEbNC;A5x?^>~&p76I# zht)lS{Ozlc)t!&*wj;afyh==P! zc>b#=Oi=4cUJEqiM@S6{w@&=O6D=swcI59JY0>6B{sI|ZA0#Y@mqNOxG;K!`3KelLCc7A zyQi(ACl@ZN*=c^XI@MI0Jc>KinmdG_Jm(ICYkxH7S|cK9&NbgAs*~xOk#o_F_<~`y znRpSo@0{j)Zp98&vhM@>KA`U*H&WWKhdH50f|MPbOB@6IZo+ykav#Kr=276|UCKH! ze*AmmwIQ@6)C4*O!@>1At6`G9B6eQNp8f_{xCL0f1z5NRSiJ=r;w@TKn2DL!2!I#_ zDcRQXnU82KS~O=X8mBCAez>7V;D&k>H`J}Tp)SD<)nott%ko6!d#2K_-{Wx}t$Kek z8$ZEalJ0+aU-T7XS5t!NTVbB6Sct6m_p#ft!m%Av|5i)Jc!oMYc)mLDi((x6?bsIX zjMwSM7Vm-8CxF$Tt;OoNVSdw(R!#ea7W!qB<|j}It)(RQFS2tt3RYiLQ}g=5 z*IyqyY3$e+UVY((2M>Ml!P_fuzy0d3UwyThkZ?-3eYV?YpXQ9_0KZcJH!4uCTW%Zk z#0QLv>Nlw4M)586Sd8^bHBnsp2*h8JCT@ic;@6@vS@vTztQpIt=9VxH&2KmK0rT6n zmMhe@isjg9Nc2UlaxxaH8HpA4N3M{W)AxF}CA z>~F4V3-a{ZlI5&zL%L_ScI&c1gXAfjR}3BgZ;@>SP5vmBh{`5SzSbm*)#Ey58!unI zP+UFgMr%Zw+a^x6UYVMi>Xcc#t{T$ehvw{K59qI?Z{9)s{@a>k?X->zvcKeZHcAr8Q^glb8Ml3(eU^5b=wElIWy;5UyU_^CgV83H9J;4 zWn8oWWj#JzvnLt%$@sjYj!YVRV)Zv`YDgCyg=_U+>O$6TNAa*HEue*!M(RB3H!*hZ z`KU22K39L#Tr}^Mv2=aKYS!p;A#-bo%wkjUhaH`4t)C{c)r#Tca&5J!_doY{9&4CH znnmH3J$mjl`RvAWRm9i#+0YnIqRpnZUsqyV=V7z4AszAK9<)kY1uf)^q#s-Icm(Zi z8m_tP?z!iC_dNB~F;9&iz4vIb0_p2RPQ*4jp7>%{^1BYArs1CW{+p|5)mU59nLZ|L z_NuxQ+H1!0ol~supwCe~x+vyO*3Ng;%~ZiqT{=Aza^KA>P>W98XN$C2PuaYCV{GQd z%eOaDqtAdyur95`5$L-G!rGzgD(vWH1 z3mKK8F9MJ%wfq2L9sN`N`_D|xocT7J{xUK4iL9?j(%RC#P76x3Ez_Uv%407YX+;Cw zm$@L6obyDyPNuw@sP=}M7Ur^>UEsV@7S|?wh_auNa=e4KH|~fwZ_t+0KBq0E zMVod!E{uY)w8rfFLML7De(=HYevn6!BA+s>qlzQQ*oL{%){v2Z9o-A-!Ma9gHDuC9 zV&|poXPbT+Ocvb!`h_!~XYGc;cr9_uwId<#x>2FO}drCVB z#RtZ5igBD`9H(~9|2^dHnVOMogzL$TJ@r&1C%+GE;>rQES+te3u%0oGLAq_vI?S z!Gyj<)E;{Bp@;4`^^QA!eE5%le9o@toYOt%-d)Xc>v@OuuC05`d0PuCtoy5WF>}RR z@INNJGhu?dGUj?AIdT)y3YUvBjAibGa7w*?(*7u-t&HDpF{~c#Ch;-+0rJ|sH0H=u?>BYo8&f|-lPc2Xf!gpx`+R%G%E8<6Th_J-YHb^6 z)BhU%*67j5%8gM6EY$5r8zz9C6S9@CZH&8p5m;Rwx;pUr-Dh5^zIXMJ&p9V`j=TcY z#(yUVysBz>)qHG#&xmEHZ5Dt}y#8JJO?T-8R&SZc>WFE}fuFl*?%Z;*y1K#>fnHQuiuiE7^$ zx#oz`)sWLqtmd!WfaD)d)Gyh)-`KX=a{SwGVoScpI{gOiBbq+Cm9$-GZgQ6hMZf{f z-OR`COc7)?$<=5xN*2}njdYIWbc;FFSTlFB5YIf(X4?*|ejr%=*jlW9$tlEFz}S_v z3R)0r+wih3>_6#2CMZ zworgk8I-Usa<96`tLSz`k3~Kj{mK52YoI;dx^>W6JKUJNbDBO#@w3?&(^2@=?=DCaKwFQi=t3O#MM>RU920U(htnNuh+y4!$P6%}~rt{W>RC6^{ z){yT#9}N|JO}OwMQ8P5;N%bUq10MWt&34;O4U%&aa$ySmh3>`B*`xgMS*&OHsl>h8DTF$pjd zYH5W`f9Ft33twW$`6Wj4u14d8cPGdPEGLd=4xGA`=KlG6SMP!q{-0`F%$XC+k$dBO zU~Y^&9sPfDc^U;9#5zu#4o&aC?raRjo}+2=XiI5RY0+jZ4HIUsLinw*`#yGh;nRZm zLX61oCd^d_TZHyExu|NuQ(C>|l~>lhV$4ImKWnI}UcwWZ&eNn!I@G8h-5A`k0we&tPuQ^w#?>A@80><&@jN=sJIK?UsTS;o`el-Ck#@=R?z(GH`XeRi+2fA+IpKl}5akNfkB zFCK;s*=oio&Moks!~7wzCMM)@;n?9AnO-l^&m%?{S5IvctRBuOn;5I>n~_U#^2slr ztXRQ+{T4y*XJ`{>(`YZy*o>c?etMtN4>@GgA=_>DK>L(A{CD1U=bb>kYrfOtJKs6F^5~<@O&|nSYwLQS zTVs`VA*!a%i+Z6DFw7NR#eA^jAx z)ln3dUBsFm>GFiJ<#WzCXLr8aW!JQALybIZj`jPjU%#n{y?iLPW!?_wZ)>K>e}K;; zRprZu)$Pva7)j>wSYMoo!o|*O>dHE%tqEo@BfNfe3<(+BxGQmoE-a$wwL9;;8@9RO z2F(5k2BroMtPfU~+hGjF@puW7tei`EAj6nsOV6?SgQDTDt+nYI>RKuerlQfWQNMq9 z@ZhzB=gpfxZ^-H)Ly%ejhyE^>-0vCl5ri5%4wt-WJSL63G*&+Zu2`fIa~8jgv(D(Y{Uv0 z<(G5XZoEF?hy#x}^UMR!Bzx=H-~M*j-~Q`gP5(7=`%2PtRBIJp{8%zEd_d6gG$KU<4k?Pud@a^a1w z`*%GUYERbTz2=`=Jzjs=v3k7rgn2>M;?3ID^|;WCEnNE@d9Uh7oO){NRJoWiWq&U> z6Xx|KR+cGpc;wFLlZ$wo&dHhEUe81CNWj~)Rk?KL7e%#6#=bq)e`7;GGF;J+PeR;i z3*tv@w@>rmgDn4j2t^PS#atbW#gk5V9;IcvhM18Ptk0>Hx*lAnyU6{sevLV07RWF! zYSKH9)Y*`L>yHWfTu<6TwC!nfHS5#1)}b!xu=0S(*=(!=hqx*a6!nYRX+e6upHjj^yzPAeB5%c&Loilh3rmQzE%8; zsbLQvrz>Otxi{)d-hY2^zw?1LA4iM`h|veV_3N2WBO-AgDVi6PyZe%_fLW})m6hUj z(uuyS?)SfG<7qXtA++8!QtvJ!Ja8JJsYByyM|6OV(f8553*H0%7}g1pTVkFx9IUXu zEqTi3s!yA?c$#q@`EvUHjR6=7*cE~2>@OyL1P)NHJzZX0Jv#IFW71W`Ka=ph%=4?C zFCpoC^50K5r}W#IPx~7^^2j5RqC7w3v6{09*JNSHHCY*QP2?m-ToY~GT0G_+k~dON zwl=;%*nYn`w8gZ~XrZ5yN6KMm+HO4W(q+Fc=bg9Tc{kj!AHSu2?;kK=w*j+fzcKqW z^I^edp|4X7Cp4x*{{rJbb97Fn*f_{y_0+8!Gp-)ya(n@C^{uvA16F?xtX>sjb(W{! zKJ^R%&k3|?v=^Rw3hA#u)?)P`VD;Hx^%Y+NtUeka%2W7Iuq!V)_Y&>g_Lc41o1&7r zw?1Ls76XQO*l~((O6U{e(v}emp7q+SSwlWcsN)9SKn`(>vKQOvz$M01dBm) zXvaKeetboq$39-?Y0|lsfz^#48AlSkh*!mI<^~TO_}Rc&v*ymCY|UWG*Ze(zch(H7 zwGV#0o^5*eylnH!E;AQOoH^t@Y#VHf)x_OG{dA+Gd3w8FMyzhf8(Vf=*H*Mfw{siO z7jpVm4VpRgwVBK0w!`688(|k}t|Lfy)_LCqtG5EHZ&r)d=PV-kuS^cS_j-2>A!`ak zW%#aozxR_6Dc^AxlkA8QD@x3<bo&t9|%{h6Tj(BjGs>YraN`21mRVP@?tHlK*HKDlI#c`CQO$YPVH9NHAVcTUPs)xrJ)4l-^tJC&cYu zp?#*3Rd>i)=bRIqW3GW3@aC_p;b&}J9q4@Ct2r_`dyIxs10KIH4QkzT8s{EC@n!ydY;Pir~Kh58<`cm?i;=X zp_Y@mx}08pT4o#-_>w#&$|<$ccdH(*zEMjU4_Z%7`GPrf2$KzC`g{~KGTgfI|L!#T zn)PYB^7(Jlq790{Bj&Xj3&ky);T(h|F>s2R89mz5JZV85*AQ`BEq5wsDP*l5`;A;zCj0tO&@$l2&rsAuU0Y1Q8Hi1(Brm|M%^gn(683Nz0=A@1{Rn+hMvZJXQ77 zlgrTuNRPN%@F^-)3X}Zgzm;YvEmaz;#39j%_}vjb zTC|weqH^W0W9*j?2kfIE$atm!2#k{DjSLwQHA=jY=8u z4rTg_LwM}XH-G!)enw8uf9^e`nSx!iS<&A&fpIEd11A=x zEI3R&$3lkQGu_tkDK@nv7IsdxFN?*d9gJ^I?0&w;uhAFlyOTmm?Klx{oK^U z?;NjgDe&rKhb?X}#+-dEw#UqG=J`AY=GhXmk+WonQ9*X#jb#VlMVgWU<<;RwU`dGuPaGj9 zHlOw|SRf=jZ{9aaaIAY!l>AyAfYp$BV^>a_p`(edH-G-t`KrP^b^pf#!}I8;&{Gk+ zp3p7fc|)gi#2b4dUY&VSPPAr4@#+=Dw^r1=z)7!S#iP(Ndy+P^V^cdN;>3V&kOQ;V zNYHkxr0oq>)%+($8FB*l?ZKa!_+leRS|i!lYu-Fd@VB(aSz6;P(Q1}xHA}RbC0Z?X zgsM?}ef8?C3tG4CJG^gSZ6oR)37uMWUv!+}o zWqE4}{#Hx)M`D;0lN?PMbs+v3@ynlo-g+KCYT|?7*NzR0se=1lnlu~jWXEQF=<#SL zCNeUdf`a`8Yu2n?e%I$|T@n|7cy#y?U=NS)o7e6Y+jw+r=-P-`7b6!b>Cn@6 zzc#OqjH`tdx9GX>bAqRVw<(e{v^=usfa{>I@i)O9j#yAZ{q`X=T8IG|f!oK1auv;F z?9!$7Qp!~{kPl3`*v}9R+-rvy#y2Teg}5}xL5VBtwrO9uN_Jl%p41W^mm2-XYO;~K zpswP-|F%aDxu68?-}fl~yKKaYLo#YxGHS=h6rGT4XxJM##j1p>9v-aj4?_=)zdZ5+ zHXYY=dq!J9jF7pfH;JdMd#K zzVuSRmp=V8`_qLBYb{i0lG#pd7R8iw>=e?tCUjyuYmKK5CQLXm0sD8&n8&0=y%D%Y?-d3N zLWeGYSzg@~th4C~*Q1!r!&X02c%pMmZK588zm#m!6S^yx%C+){yPEK5A zV*`&o(jEV?WH#{S#jDpCK6L0=;?--4S3f0wzep$J*lDrXDQk=PNV(QNi4C^1kDGX` z+1pEDA2-wINye0yS5He07y)qvk9eP%dib5=)h$I{9sLu!Cp=QHmqE9TkI0lM-%eRA zUD|%hKP<)Yt0sNU%n>|y*m5z5CD{Bd z!X6488tr9m#r}Q5gr6p`<`(QOC}0mh;xn>;5ROU;)l)AVSB~P$5pV3dc=f~Q*Oh9! zO=W{;>=_yFZk;wU#y zv#WBix{CN8>}U4I@HgHVE7?S$(rBgEhQIcjScAuJn|j-AUDkBza{hty&#!h;wQA>{ z`y0XEx{Uq8@R`KkeZmAw@tLg^Q>R)}-+tSA+t^_e2MJyi`|e;1qW_mB%|>8V{Nk~* zAvQXCSa>XA)f5))DBLFfiA11dow<&DRPq)to!iOYWc0mrK-|C6iY9 zy6G6DG+Swt(juiirT4pcm#K0Wx!tssyJ~g!_vv0^OMwqN`?KSVO8>h34X}&Hj-XE; zs}K9askG?wOe0Eg{svoFcI*k2xb zfkJM|%;VFA*20CePn|vcgC-w*@N9=?pB>P3zyOhyU7IMKqU6LwO5WnQ)1AWY(M4(E9+R)TtnjsD zd(IxHlJz<<@Pt$G&qc4kY}vYHUcGuEULCtE{5*{R7ja9mH5n)4HsnZ*P4vUWGRHpy z-z;QK;#AKdT6;7!!Zq|j$^W_04YX`EzB~Qh zr=FVr)a|!Vzy0#d3ooxaV6G;?dmnbb5O02z;C?|8DSt(({pS)2N@b2Bxf= zOH?8ATWodQPt04+-T|Vq<(}Bq(OXMj;KYKK9sCCI$}?u{lz*4~9)8Kh4rUySTfTsC zEXhmKSmKl!F3Vj3T|>_k#{pjw?Ecs}k~M_2q_FUt!X5aJvUfAOYRQ}&`3}AlXky%X z=_b)Q@aNU-QsLDVCgfM=)n&P)*m8C~Ma}HQ{(3~(r_UrkoeeI+rV*bKS&lzoVgyF) zh1|UpeU0sqcF$-=pA!>`ID`172-7by@_GE7u_Z=k2|o~w#Tn|Hl=|g2;?<86uYR<6 z^~5p%Wb$@`6SGwDxg9?%#UXU!nn~~TgM2I5<8P`ih~0(n8hg25&np$`#VPOOX2w;L zx|bNYM1weba=3pp_3%5#t6Pe^I`*Q>J9)l1G3MCAj5uSX<%qs$(aJ^mp&zF`oa)M! zi`*6K=2K-!8akNU!Ps*4rNZ<#BM-p# za{Bbc8wcK5-zC>E^@u$Ofy|nMts8PE?6)MLdTA}^HwS~{b%AyTgNFH>Z zVqQMj`N0SO(c~Zh_^i@rpDo)E+N;*>n?uYG@Tj}&P=DgHj_BHh1iT@(;gz$ z1M`KrO<`YkiZq9=6X9AjFAk4iSE_9{l?|Tp6-PFUT{Q87;N8%x5@!tibK}E|tuz`e z{OYkm#|QdY#UylcQPIcmKK}UHt83T3XwF3!T~~13b%P294SJ^FnP=WEc>C@Ag8ckh z(l>lu@bSkl&3Nghhl(D0C}&4bPRHXqc5HNJqed5ASV{1=1b<8Lw*-Id17nj-d^z?9 zN6!my3GbLFzENq@Y{Y3}|5`W#bgj$<;w(>@^7E8c3K+NtTO0WsIq|XONF_Jh?sAd1 z!13kvq3umCxqAVfy4-)|Guo?{M1X_Y|M`K5N~@G+auYm67TzqAT*cK>Tti}&l<>fg z0ev}o^M|yF1M#DfjSw4vc<{(dkj*e2{?mAMWOL}sk)P9_=(5qJLk}nt_zFb=pMb0m zej5D?@jIX&g7)a9_tHC~%SS$dZ;?9=ibl>1cy)Mo6N3s~J~{?fsFTwx(pfw4Z)0u0 zM9z^>Hs!^#DHl)iqkOa3fufv-S0njpUD@)=EAOm&=ber|Th2~1*Uy}}iI}RyL_$x6 zz6$dn}t?-cbd?+&Qcw*+t+@rljc0r#qOSb8j)>Or2n{>>iNpDqs>#d>Z z4jp<^)=f9H&uZWPY{|FSXUK_PoW}V$y<11AUArvFPO|zx`e^+}-*}^%>~KDc(p zkD`|x-%|7)#O%c{9RG0Hvv{fP~Mz;8{>KIdOhY6_@+?568XF)tz1lPq8>ywO?xOidm3T~4^Pbg zd7{OO*%xccrX|Rq7{`)qJx;%1zl3fUS!~dkFe>YmZ=eyd^^^7N73lZaLki!~i4%XG zxN_yXm5%O8_94zZWxle9gA1R83rYg#_`G`J73-uoXwZUd|eftt5W0^c^}% zY^VikIuepK$rG9=W_a0L6#QJ^?)tG$V5Yzc=(x*ow^520rWMS5Zm+_{~J; zVUPGgp9dazx$Vm@kEt_e4E*&EY3SAIZ}!tQcJL++5__bHT^6mdK{)oHFOEG_S^*pl z4GrDSJXQ42q0s4hc|U6}?e$BEM=!D7d+a5ra8Luao!eE!Yr<|nYMh#J7;o%()?C)K z^yK5IOo~+BF_n#+S!;|PJp8!vVP-!sVvZ4O2;D0>Z{o1CCJ|p88*xc~g0deKfAa2= zPp-GQUcGkf+qLVnzE7VA)<5vTu=T@+y|@0o_X^e*6ii(|b?S&sBSt)5{QUESj~+a@ zch%m#+cj<1?#e6A5&X*u{?<_VM0EM=BQ|}yr9JMf5yU#f1|9o%%EezGQhV2x8~&xR zZxT5Z!veV)@$A^I7}*`NU1a9-=YKbUi{erf!$xkkZ#aIV%z5ZI#m{s0MOGeuJrk)l z_R(L-hkB-B(!8&jG=KQR%m1s5fAW-8D9u)S@8y??e=2ly$K{t#ly2z@>6Z3~bW47@ z$A1QGjeLsugeHz+P{(UY$7`)ZzfNBxvq7(p{vADhq`q+)6u-zQ0Q}l|b=F{T8#a*g z7Q2Z#4_zfMW%(=HtF*JuOn7&re?f-F{ut;UBe955Y^-`7JwA4Qlt(ORW1A7&U&JPw z*+T3z+%c$-MMiY_A;_m*WE8xG;;Kre*HX=h#A@5CSZ!Y{Sn!SH&X#;At4QwLLUQNs z?Yeg#Tzl}~za9Ixzm57~)TqF+K;T=(HhasUr^4PEy_NPVwXp*(!B7Z~BhSaU``}vy zj@5SF10@=KRwBC(W?QpoD~!UT(bnkE*slG)?C*bneYxweZ&j{UtJBMAWQxWtNvc}4 zoO}|>$?vAzb%O_&9sIYySs^{x*23*t-`M-s(Ssp>gZ7sXik7L$o@$4i!z6e3nZvnf z8$Ji<;_#ox-&Hnk&dwq5iecZ}Z&Hi{A884GqR6A5k(m?5whVd}UY$9?+7AtYT|Ib% zciDf0aSDwcHRyaXUZgzU=R5qjv0p?c_HZbcZO~@PE}T2pp6k|4GY_#3i1w9_%#FhW zd+b&5Plm3REtA7vDXxRFk2i4wiAI3 zom)J2vuv6j`}bGHA-e2romku0$GD5qO(K~{VqV=q!kuB_Yx9M3J_2}kXcJ;Q%UUSg z*q^aBYX_3aYu4CnBte~ujWe~jwu55ayuIxP!u_0fEjP>`S9x4ilyZn z5>;So?KJI;KAXx$&WeO~B6jV@hxsuX!NRYZ_~7Wc@fpXDe8>=M2=?IEtg~kVzRb)Q zCpT3K9HL>9#oo^JB{~B3N6^wWQmV_@?CUrDH^YZNUG?dw2R0r!uzQE@-TU>ssNaww zwTArdZ^sM%mf&yACe9f#<aWOcmtd2QZF;2kuB&+PFX0}?CTD#5k=+TS zN<3%ZfxMSB1O9I1%8e^`;}?Q04K{9?3&fHXS<}j~n+>hU-X1e~(>JG{`rWC`n-?|j z-hHa}dKs*}US4`>^ysmp$1WDqUNCm-$dO}5zVO1>7w)}xf?@_NxTaaNubV~Ftlr$? z-@`kiS7u&ob-2yQt`%X!@p-_W0y_r!*~IaSicdhr<7c1rw_lrAr-F(Ytk~CfD{(s4 zBZq`f7S>Ch6$Jc<8--43?I z_%SLfZ~?S}wARzO#|95QKVuBM8=*f!BPDThap4|!544Ti15J9sqa}B)sl5!^N)FWT zihlhbY4FG+Z=9rfZd z1>=eSFm1hJM?3O2uZ#lQF!oGywLM&-9y)Y(JYM}6?LkpNaV;w=u4Uz%ob1YqYgt)w zEye#!u^p8hnE-MD60pYK;7rA#Vt+8j>v!ZO^u=8oF~oqEjoShC{m~xr1qDAw?XS-I z1|J@@>7?f7aJM43mmzlA*omTBM^=SB8{-q57Pt-_7vmSZZseBajjS8`n(>-!Uhq4# zGqOUy<9qlUY+BLf2lt4zKS$q%tqF8GbUAW#qnnPlZwa-fG+__Cp`nQ@u2?dTTm+cI zqif$T8y^sCEzpBKr&@~!+ACggz5Jo(%-J>v8Lqr1Ucq+|pE}wI4v5vMyZXY^KMpUf zp;MrtGh9PwsD@6ChEBGIPR+_m2ag#<3C_Fq+cqT~3a50k#1pZ0mr3epbaP(4x~6rM z&xjd$v1ecrFU7v?eiHK){f1o|_HD@7=p)&^?UIgd#fo(+CQsfsnQ_ec#vYpSM;~F| zfjk!c9<Jiv;X0KBYb@X@al{K=yL2_fhY15<`QvTSeKFCA!{Lqn301K zTMIoncJBDpfMZBp!^5DbNB@o=3f~|@L+*&}J9hQh454R-{wLlnbOH21a6E?KCm^NX zFIG|;cye{%F55`QP6ylqJ}=FE+)UResrzBWExUwJwm4{|vW8YJX2f*)fO*-&Z= zw|^)E>O&G;y!c7>C!Z{=yKtcsFFr*Zc!Q$pGxR?K_q8Gw?!iw3-^$PBx4vS<8sXSI z4vsang2AzFS|MT^7i@zU|MNRE1+)dY8Mz$!fV08f&}vhr{5WOh%GE0sg1!KM5p?S4 z`UQe(^tJT#qf>pW+Z~Yt^L()(aTZ*5EC2Oi^R9t_+L2w4DNh9*Vy{Aj*}rp2Nn%{?zy7pKK!ug z!?|;d<`yk0QhcW(#dj)Fe5VzXS#0j#zWx68@Y_)H$;P}H3(RNMF6ef6N7h2dJvxS9 z97TIRyecvMh%bmg0x+k4W9%3XVHh3>ygKw4G!}d@K57xa27AgEU)Wz@4-ZW6eZqei zUIBj~`X9K%Yd{;oH{fFjT|h!V2`?gQzY2UkxCwm=e7bgY8h~5`8wO+n(zYx{r;ZOE zv;yTIGeoY>w-NbcXrz=7=0bVLXz12zBSoxGUD(HH+>UYMiiE?<%08;TaCaxkm~OvT z``yVb=?FrKzUYnldgnP~q?rALC(?YnXg>X*`;U2{%J4(rNB`01P zG0RMn-JioJ;@hbBe+oU&#qpt6i~)x?LSHBSz;^k>E?oGvGoGMtvEP87X3R2PBRXAE zV^;^LCy5mYZouaQ{SxDme2`%ge~GaQY|ueL2Y@>mugoFrOPLp}tKcN?8Tm0EICIaO z;CJ5ReQeqIo_yeM@Ug@H1p3+N@u9i+o%zEz?1>$1b02C;X~G_O!|SpC7d9lai*#U) z-UXW}WLD_Eq?-_V@a~Vo`|D*LGjHDJd1J;L9D|GxpGNQs`#IxF2>;v_lynf=RAQm3G5oq4zCQKyqiY;Fhlf{p zk2o_5BWL%o?(4`Kepl`{oooCQurH_I*c**FGRzUts@pV27A^W}5iyXEwKMJ*U+Cko zp@pYo{|(l9#;bcAV^f8^1-uVkjII{h7BnQbr^vUkqsI3E{%_&JEep2^zp_`1Q$BV{ z*kUk;v5UcHSq&nE58Z@uh!31o)2o|Y-REEl?k6$VM+mQuE&;y;<_l3vBJL{p)?lbTr*vLtw z&V8;o%MKxveZkeP~TH*(5(l`pBOKNc6jZ?u>U69~>PvHqh*o_Q@yKC&Xe$ z&y8(5F~i}X2M@Ni###N@gBv?(VxYpaYbI%pJHC9a-_@$s>cm#9y8o_w_kraH4t%=u z(@zhtIed72o&5aS)fMOR!jU6i?)9>Gp+Ejn;jIM!c7lIx!T;#RbLLoc;2B?*(JTJc z(9-DC&z3_gHUR!D(9=ZlPsXbokojgFv}K0B!^aJMwDC#Hms8>L<(rr9nk%U<@nilb zE*^X<-*YEUGrBnBr@`ulhfbsz+k)RCwsMCK`#aon%kEqL^rzi_8a{lt;#uyVvU>II z)x@zFF=F?KXP@2kEOJBT+zZ@+T?k{IwFSDE7&(R}=L{c@K8^JcI+wAJeHk)lct>ap zXm4~_=+4lsBd)MH^c!OYP764LW3T~2-vXZwFNlpV`T^jLZ6A0a z-iQ8xR={r<92S|^A)u!Ldd`?JKaT-c$o-)E=|$uu(0LJ?2KUHGP=C=r*cBRobq$>Y4IO9fXz1kh zNO|yh2b1gmpsv4EnyxfbNr%h^I!L_SNUEhM-4eqiYHWMzR}x;`K*DrkbibNkT`-Eg z7;@$iZGj6g#Y2Ms=uvCAHD8|54KlYERE-vH(+^`JiZ(qOltR+toS zkRBw0^M3=c&Rk(FMP>+Wu}wGpHF7-GPHb1SD!q!`8|x-(IJR%}2{sNSd~{fUk)sIm zcasmYNA{ycrw*@f_bPZ} z5Xow#sY)O9=ppl#?h;R3D%X^HG5Z(At6Q49I=(2-x6ozuH+(8Fphu71HoCEF1FsUU zzDNA&3j<$xK_>CJ+FWYxcEv|`aJk3F&4A6Ep`njlUY%NoN0IDv?Nh;7$iZ1(@%Lcv zzAXW-PLb>%C{%d9rd?N&3U^9`@uZf&H^<2q zo_z-k3pWeL{v5`!Zdw5x>!uY9jy1FbxECDEd*Ebf3~)5K8X3&EaX*bCfBvw2R}=qF zVz@sEh2Nnm-$f+PdGHx7&_uvwIyTJ?R7CsQL1b<8Lw*-GnYn=5Q@!r@E9b0K^Nr_L#o^II2H*8o=Yuq{Y+cj+0u6N_! zy$3fRJox!G&p$un(h(y>dK74geEwed_uj+ad-(7^qSe|8{xwA*|E3VzcVdzM=}*?5 z$iIt>UeWJDTgR`xen|xXL*&(&v*@Q-r=c~G$D{YbCTsTW-Lp5X)DGRmst`@(=pkiO zY~zQdebyXY9IRe=_(a+f>^o%j(9^p| z0RsZyR*i_w?LDD1ECF&)gvc_kAOx%zXE)r5Adx;XNcS% z9D^+oI1@e{S%8WCPTtsOu(r^j$V9Q(i>B>EkWUlN;en>_&|4!1RW&=vo#__vn#k1Q zQ@#|hp?P?bbmz#Ov5$JS6n1ReC1&Rp=B1&btKi&LWw!*xxOjR|zlN@_71{KE{5vpkPnII?d;wp|#=5 z+`=TM9?^`)s9}N=GTN$GsDy%3I(2#=bd)yiv2Vw>n10v1aD38%`CF=)=EN?^x4u#g;iXGg zE}b@Q`!sk5aEHOo;Ba6MPL7o?n30pD{w`i!L#IGP$KlmAMA8^Megi7>Y_HBMl=75x zVB03@x`)gPT1s^lt-JDrud9BC!mGQWpw4u|#$20D_CK4qqR!~7*;BHOC_>~e_@%K2 zqx5SBG^gd>xVW(J>q24x;(x?ii@pqdIe2!)DdUxK3ywGA7=94`oHZ1`P}X7ONyzx% z`&m2Y&i!WYm$I|lD>;tP)(D3mQ0z{}76v;F>gCd@Q}6Jhn;0D8g7cy9>QV4ffid?- zh+dtVVS|2w?0LZ@z!(}G{}|-wqOw*J4-UIG;>6UrImWk1_~roqHQJkG z){a@T@FPWLjs6gQrbG*uNrcpZ{a#`^)D)GZ?xl`fb_tm*aZrl$Ppq))Gq09?=6UC_ z-;t@70f6s#Pr|DMH}8SI3VP1O?`7S3@4a2`Dca|Z_JW~Q%Kw%b)VrGg`OCGp>2AsH zob^=ujyt|hp;(X6`P$m!;mG0D{n{NFtF}x6>|>$(@#A1TGk1`Mj~KCEb7yVA1(NAs zA(}xZMxS0Jy07-CRjbn3v&}umFeSx=MWn*TC>K=Jy#o1Pu>X+Cd;G#p3l|m^epNVr z{QmK79BXI=aBM2H0`G&H!O`S1VZy!%0fYhiqa5g1XhU>7)I;o0YwSo5)!roCH7$DF z_}ztPyM9dd+Ap8rGg*|Dkrhboi!Zh=M$>~GG&*$lOdCJm8oyARC&90C{qDP#);LRR zob@Dol)zKN-x1H97;x+}dc_r$M5{Ftt=93XobEY_AN26U@a7V8eJs(|TrFo)w9U;J z=1*Z^R$(AeGmw{8Q}$_3N9bYJLZSfA^N0(Q~&(M1IFM)!^W3EIHz6OY~= zc{2Po_R{EX(3?TsL-#^AppT_a=-QD-A+zF)d>I}RngjX~egt}iwF^ES8WYxT92^=Ps3Uhy9Z5Zu)AQG@>CCf zgO7(qL1#!@bCHHYFOe@puvr?jZOoX(k~_DTj2XKqv4XPa7(RSz)2UO}YCZdz^$eem zP>fW^R?kCcrdzG>)D3+oH-eL(Hzek`T*LWxv1osmslhuUr9Ky^R-#)(ZYnT6hTjTm z6S0$ZWEydt1BY;i>0a0tydP^DyeaY*`W0Cfy5prwH!a;MKG^X!g+E3whW$CRH_;rX z-QiO8ATI9Nv}%=>N$qFy-h0{#?v?A6S}UEQB)5Exf+UIkz>pc>S0>-vy;^T~2zP!d z?sn0lb&IA<*);_|ov}u&5$II#2r@iu(xWG;sj+E6^cnvU^oZ!S;JsNZp_P$gz*EDk zd!F+OISs)*R3@*)-LYq`5rq(Wbz%m*|LpVgH7SKfiVyak&Vh*tPgw8Bn@RshGQODpg` z-$84T&ypoyE}8%J{P}r5<>et4z(-x=_F$23eV`51^mFBgda}2!;d|4!s(&I}ThcYn zWbc2b_tHuJxtOQ;owMIM`>5lS4j)cj_<#M&`WH6v3l>;{za{uvg1?oEO*}D_|NLi5 zw3;Pa%@VCtHZQuUuUz`4Q@Zf5L`}8@g54P{;o@<>e=$~xr826kA{^{`QfHQnVU<+-J{u}u# zax3Ub*6ZLNe|u(bPyEo*c+QZq=N6KF(l2bTyT?C(Em+kh6huFiE@$p}4h@NX89oy_8G4wt3qBp*k@e4C$7@N) z>$8u&yCG>_A1S;#a0jnpcYqEInFn+jauE0k@Dcb4z+15HBclh7&dBE**WqqDA&1>?7cDpgG{qkoCadvsV^+1neP@A($}{Jcq_gnsD}_1c`!9 zktm=EHcL`B3;@Tyzw7)@g`C08L<+P{f9?v zbS+bT9v=Cj53r?b@k5Ii-HR2czWDz8Uo3v{#oS`qsTD6+5GW1=Miq}5_4n`o{`bNA z1`pPhyN;M931fzY3y60ST~qie?8K3G4I1>*py!_ZUa=yI6?>?7&evMgiUo&a=~Rog zt`}=v-{o4@$tis3CXD-k(7s#6vSTim9dohdgvF~?iM=b5`~TE&I@&5VP^zF*oFsj# z-*-qauuZY!HcP*{UcBN;)hiHK9hf$4>$I_B_l-3=Zsb15ju=niDC7{}mFS)_HI9aZ z`yG5RkQ*WA1CJozW2}QWm=}iTMfSwJU>!xy$C!tvM$gFm(8`>-=Q+Rg9`ExV-@EB< z<{f-GxDPo2^aea{#DCOV=TKWp8TRabuwC-wt>U@Zvv95C%F9LL1Olr9GiGd_F@F5c z@yuc5w9I$tedaNC@8A;Z94jO7)}8zG>z~%(NjA7vXmI6?8z)PeSBDH4CtiJ;c=eUy z)qjZ3t9!?`c*tUn%VNp+isd7)6TTGRPnU0>U(5ZT8F0AAV&s|sm0v#LXV{uImBfnJ zG1Lp%oLFbn;2+FdrC?1-V zvr{q5>}>4ljNe-{+`>?BXYXAWBKIM^`d-m1-whfxs914wg@$u?$W9hq{BiNeA3s<8 z+;aoI8!({rzRsOB#2ezbYBG09!}mbzgoa3Op}&WP{$3aQ%U9J`6=;yC>cds_p{jb0 zs-BHKRdlSNNS*P=!QTKoHRRynHh6OA6nw|<$-xeX^%R`MbNY_;nZz}88FUWx6YpXd z#I;#}v5SX3LKo{FS0dE9QtcN5F`wQdwh{XWAz#A(Msl75())ZbKc^kS`^4EEU#xxH zir;$ce~SO-f9@*2>#lCa-MY0XZqcIpkJYOaRxjM4rkF%^_pD2$hFcV!cl79SqyPT* zapKkU#H-H~uf9gS`ri1wx)-l#UKR@v7t3b5SaQVO_=3si#~s(!G|@nkF@yayx|jRz z`&lvOcD^ujWbsJHCuO4!@uoY(uNTYSWta9egG zzk2aI%4PZ{t?9+WBMyBa+nFDUXURMd*&c@TO4J8p8j8g)7i-iOi(lR;e)*t_UyjTO z)5>tJp^Mlzg!N<}ZOcB|?g~8s-T&5GyWW~Gp?HF7TvYsBadGiJCBY+6+2N96iwIW+ z|3#@78;exvD7K@YNQDQXEyZ5{Q`2&cP~*(ytClaHH}C6t6DRI2A+4}`BHxqG%pEgl zYD$k5sqhx%ARmy5{swGDv9~PI{s`W&R|;!d7=?Nz`T?EK$nUgd&iDe$A0~+ zehL(Pl3 z)(I~qTra%f3l|vu1NaS;jf|`p`8~7&e$%W^CLS5r(7UWj@O;n^=x~wsv;MHI zaYnuj56SPWbI=#?o0AmN8T}agdiY4zE`CQAiB2wB#~Z0>_{ub*1m`fXF628zNcdGD z<2)hd6`NK_-d#K$JP1AnFJ5}-&zEA83%(2^2N%3je`abPTVZ%LV2%tQ-74}IU>XGGE9CLWN}vtU|3TM5AD~x5o40}ipM22hlU?gsEELo43EF~gyv~+7b=oa!*RFq{fB#1ViqjfU4En$X**UEUOb<+- z{%+vicb{JL^wW2)x%18|zn7Jc0yf5#o0@KpdycKS@sGUhvL7$I`R4W7(`T{v^a=cP z^5no|vBQCY__cuS3j$h41F|nzDxcm>^1V9{n#yd_so2PSI2IM_h<(?cjg~_I`|bkBjg6AUa{v;TS^)Bg@r2$=g(g?f6kn>b7p9t z-YHYIPRYyrF7K01_I(1(!41eCkhM`?^f=fqF^|z{B-f`K*e9=l$wy9VBP}m4koW4V z0rBbq@#-tYt8WvpZeX6ge$LnyuO1Mu9uTj-R6Ow}@#_0az^en5$l-xe2YN464MWLxxwc!R>iO@-5@?UibKLSN~3Nuvd#$UnpKZAYMHnUR}io7AR81 z#{u!`0rBdK#H+6nul~J1uP*f0O6ae*(BH#Cf3FMuIrvXiFHqHotLj5l^&C|_TUC$C ztD`4HcY(|ioCZ$~eS(Y}T?PC(v*&Xi-DtfLi%D}K>N4_MvZzU@X9NX1hkJ^ z;HH~81v+)gS}8u|8&S$8vlQ!IvQ;TDZdsywjvgHluO1MuzEHgS7vj}-h*!7$d3E14 zb45T#ws`^Z>H+cUE5xgB^YH34HFyLYjXegmd8bZ$I^A~L`rDp-a*1Nh1>T-IH89m# zdbI~_Kyq)!Ws!W!HVYs81YeBZEBm1;GjEGfBIQTF zNR}TEURfz#eS0Ea-M5{Z*&49{@#@eROU0{iRGsz_=gLQ}7hOkMZ=OWX%pJxv>rv01 zKlQx-{>}G4|9sK&@4p{-->LUH)qB6v2BidL2Vv`5M7UVwL*Oi#@HG{w&?7&fi&n&=SB+J$~GR>vEJ zMEJjHLP5OV@f<((1U!Q28C@%V#al%IcioxwA}A?h*<8%!u3( zBXVUU7ikQ=D z$dIv$OOyMLTv6(|i?5pjlR;|@y@#^{F z)pdv@cs(~aUm2`YIuJ^GbWS~u9Jy!Y#~<(e_`?r>`q0dActvbCu;TzfFkbQhLdM42 zg-=JOm~cFW0p<@E^zq;#?C9Z}u{$yIf;b)E7kC%=Z^pQhNip`(VKEPwADp?zri6LJ zHShBscnMrZe&8ScOPPPnL-=&`BI!YP#BS-F)tYQC0@4tWWeef=AHMTU! zMBy3W(ZSEyS0U#EXCqfk?4Rz1b>jLT+Q>;h%9vq!N+O5Ft78K{Nxb?}@#^2j>eUmD z?SkA~@#?waE5-?5ED*20rG&h?t5*e_=sVNh?z0!7G56ps`1SK7dFm)BIDToG$4;C; z?SJ9Kmyw)OWMKYw+4^k|P4b2O&6g}$C6;4dVE+6K8pj*O&2N~uV8NFQ7A;z{2-;<> z=$0+gb?(C69h+L&3OaU3#5a)LuOk;=A1Hi~frU#>@2z9FJ^H~9hnR67C=1NF;?;A- ztB(_}zCgVCmQvx>_e!t6TD=lABZs(rz4=WAot^3@#?wa)jt)lK3%-}TJh@p zqxI?6dJ zAUkJ$hIfZg$4-!Ip79&}0P`6CCU7P;hHkkgF=F8JV)0CJ|PdHG{9KPD2WNkjo83X6|cThy!!4~UOg^e31TOMnHY>(G|9s%u>_YE;9ePcZaO9=An zUx`hr~`e|-qNI?!PqA|7Uk4&QXR@y5W7k32H|kyl^Mebwn0?e>(rRcS-8=y+v^ zMt=m@2I!tM^cb`y{4y~V@Uz5rQ%m||TH0Sg2QE@!%buW-A|8IUY_Vs} zLZ3JM`r*T$y8fxB23|IB;I&Pzy|z^~?Mrb?&6>V@GnwXaHixd0!9VVL;m*%oJNcTM z$eA?++v)c0H*0^R`8kg~^4TN*`@gyW%e>76Cy=<7-(#-ice$cpCbe(hzDWAG??d}t znJRlH0x~h|+K>fc!$N#g))o_=4A~QOE_SH!i|EFY8*|S(1^*6DPU4>D(96)u@R{(G z=(3=b@qNdJ^I`e4z&je995iRFjyF={@VJkF#M_Lx_aXzUh#YJwval!gA-EWN5zGww z0}p}^!HeL>I(5D}th_oE1;@j$W4nr7A-ZnXa%_Lm^}!dxQy7~%;17JE34kv!$SByU~Is@p0_qPuQKKu1uDtTj zNq64K{vGe;zWZ(v@*r*yIEeg{++6uuj+5MMzT%&MjXc{WXF8OsnC4Kos++1-y1; zN|Pol6_;j0SB3l@fNhUc@GSjbs`Qz2H%ump553*DZ{Mkkhrf>f&Q125byIDVc=Z|L z)!z-$s==@Fby}r#K*=sqVvaKZ(Q9Kn1AmU4IR4=1yqUN7>A_cH_wKJxH!v`jHuv$* z_+!8?oBbo07tqW2@=_0Eh}eNM#u@9-#pv6?L55~FIEm-T6``BWoPb9sAKC(*Lw1FK zI`feEn3zwe-l0R6r3riNkdUciufY6Ae~x?&9~o?6n7_aR`zPiwx*Y5fV)b(IVH-q{ ziR*vpBPafd+pCedLMcy)16EVBrzVB!FbJ-y|dUVn1&OXifrn6TwKCrUCarF7%i6k*T z!fjMz{Kl~v2SF$gbKsnlGhDp-6!Ge7#j77YDlwFUB_x8?cjDC-3O=L6tBXAx#F!Ab zt`tyOpfqgQbHl`|>*zdMy!vAC>N}%(bq$efLVvA<{v2N2`Dj>l?HMDgdXB1|t*X~l z)m3+M#MTfV8hQkJh!}s^z7a!!`N-S^hrok_hp-z&=T71p`i9?l2iysrgFFzsowVed zhoxT<^qBT>`%(M2$=s~nILVfRYvT(7=SNC&m6j<@RC;?DYq-=w1F!k;nroWPY1XXL z)=HI}3FiW7qA^wSRmmT>>{6|kD2-O)VC}x^)V4wS215+rx%w%0Kyz=Ktr~gLb`V z4_X5@(>uo;bKsadbykU2pD14a=9~F;*gMK+x>AwSSS20s>K_TO%oVS`&5u`)+s;+` z`g5hXhly2x;-BKxC3b1IM!fpZCDf~%y0a!VZ@#{Hw{D+zyZi2U?#BO^_B(J`r}Ktz z(Y|GeVG%nmO%dTUd@}xw_{QMhqNV&AE$KIFX&D`^&%!OmdymsMhuMKTDbm(oT z-F91-W4d%|R#Y{~Gpc=d5Ulni@@VE={h4QmSYI6NLSBkK<9 z68dmro}sgK^Nr|v(eE04FScO(&U@HqW2em;2Az$KJoG0voaphPJ5$oh`GX`pZ+)5+ z{!L_H6_JB2MHco12MbCLy$EIoWS~LtA$Sq|2%ZFAhBHW%dEDPz10?P{;CZ_u`^5kU z=mKrx$JHFBIn`G-Z1{2$lLA^3eJ;2G|7di&*k<4(9qXU&?KM+t?{oiq>`t)hCVzDH*u=As3bH+L z2l{YyRg86XUEn6>2J?XV!I^vHRs0VAGIN6Ok%DNBBQMONcuT+|#!~wl8 zQnS0%z%3M7C1$^s#PFRK%tZes%Id4|>uqH6T_e)E!*}I{Xxw|~Ue;drvc6Cl9-DZ0 zYNCQlCMi|TV~%Y+x_0@mi9CG6;luHMhWq0z+W{xWBe4giG?csKU!6#1_`wsKMs0U& z-i05WxOM_sDz->&==j0ejDrZ2OCQc+4^LvhK5bpo`F0(_4n@O`MLf7Tcoo~SY z=;xWgK9{PD~Ws?t)Wv7x$QFL#v=g^g$`m+%VFHA{rIBiJb! zZgS^Yrpz%~%j=vaQ$WR-(^@_-R1wzlsiA9~v<+&ni(0pCU7dAYxWS!=`&uFTkJh&9 zwZ%TSlgWJla1JwdwM=PT@QGXn?#f$U*u_8ksP*X6Pg_sF_@X6R&3Xmi9lv&B#}ki^Gxt2__ZMEMBwFo!*%4mzC^9#A zg3Gi9o-MsPajr&I)4mktUwTQ~yFZ*`<>cUtfRC!k#h|<}Nt1pl_@8*>{@4E za!K|ja!KzOo(D5@wMhwt6*`bbi&ei?ta@{yV2|&;UxRE5KU&r&bY<{%(B|m0-1IRv z+VG9&e6a_EUxbH;A2)u~Jcm~1J@muqiqP+(%YqIi9uhXL(EG>-67!DfjUB(Yp#z}@ zp$oymfCzdK%nbT72Y3fjc(w#0Q5Tf|npR0rWdMMdb6q zkeD6F_L13>i1i1Z0RLiW1g{>T4QL&S+B>Pxu3aZ7EmitPDWEi7DYt7^h5flqBvfNRnkc$GdOxZ? zPLxSKOaF`>J0-7_Km5tG^|+axf}Q5p-lbVwbZt*wjBg>Q@0 zEO?a|z6wxY%3wX#5wc zef%*JA6nQVBQr#ofSjAXZkV6g6d^l=4+j^qPLsIi8NXq#!@Jn*5OWJ#HuhVIjpOPq zKhiVawjK6<5zQmljq1=!V%IP4+O>=NVT#gnrCmzvm1ZlA>Dr~sCtcw0B!)W2D?gzH ziRxb3xaB9+Xr0mwrO`^77G02S%Z0A)@j4<94PJTc78x_q*eMZi^5!X%cEwgZ|NQV; zK2a56EiY8cQ_>+b*Y+|a$vQ=c_j~U>`N@UmJh{+xbjXx^Ww1nvE|{e>CfH77lnP6A zw%lD#^yU%2ozOaqlp^>OxrU#-)SG82_Z2IiQjvDjUKJ8-KXBNpv_mOUw1ZQ8%l1x- zB=_tQtfjmp-`nrIYxKC2SSFX%T?J`bf)5u>?DOd~UA9u4 zwB|L}eo?1OGx>$$vlQsxh#y9bc>cii&ksH@cyR9ny?bXL$j(0Jr*qDc3uZarzHznV zmxRB}ajG!3ojI&wFSDDjT^B1c2R14NQK>`YgKK00Ur#3R;n=CcYPbU3^n zx?X5?=v&qy^lb3$*n%0p5q=S!6?!ht+`~`uJNza5Ird}3t%FBr{lXUv{SdaU*x&o} zi>6*KK$q|mIuLpgcnCrcod~@MW(NHM!dxX_3VsAnmar(F&rCZKpP>tggM#b=o*ft= zFM)=KKfw+UJ6QDO$UD$6lDLLWzzzbxTWAD7J)FO9T)aB@G~*B+G5#2b2L8@M;|x>= ztQ&a;=|NxIeF)`yKZT};#>mPFXhm6~v{7lV(zi;hlp1A8t=|wibL`yl=2r51g?%am zZG`MVg=S@~P}-*SlhS6TBBel97GoE_KA!w2(jL7Z)gH$JSI^Qv!yn^&hTjdmIkF`1 z0=^T;_2}MlWnkoep8OtP5&YmNpZcH&hmU29GuEMt(RaCNW^fI2h3Ck;psmfE;Cu2R zKk_C2#Q4WsN7I(zxin!9PY&Ki&kr6zjsShn+)Ym7d+QnRK7Re(Ga>yEliDczQKNCa zl0rr}$5}f3bV8<<@!`t^S*8>S&yT|;Q!{lj=Xj7!_*|3M;iV<}bM)W%_((2n%XY_> zZcM71o{o-O@?vKnBZvDFW|y8$p^R>jn`AFrvQ8(yyJ%oypF6Rn>u7cgNU7-3-SsnV zF=scN-Qyqv

@sO*peQ!>^-X&&t9g9uIaR&KX?1Fexr}*F75lMQU$wXl&ixQ1QRU z&xCo1O&W9yd^@xe@=I_LzCa|d;ph1c{v15RJjV7Ybzc?JBf+z~ZNEgn$aO;cnlpuK zLu*o2UG>FwB{0Hnr7xA3y@9N{bywC+#COK4`{BSXyHumKN{f^jOPUsn z0{WQzwkZ9ev`#66K#@oI*$$X|{M!kw^R-e0f6A4lnp7jDq8p)ow3GI#kYM|P!vUp( zN|B-+-0fSo|5Qx4RAUFNCB3|s_A@p08lVT)lx!zb;R=xeSBq4*UL-+Zu`)TtI!46C znR}k|JMZy6-|;>9kRSOv`$v?ZUZyY1Iewp{g0D<2rF}LHD|x;e)uPob(Q20Phb3Cg z60K&5R7cm14X|wLWXYD*3Lj8b18#Rr1AICEL7J-MhD1-Ktfs&9!Qk*{faPO;gO- z)1+St{>)*-5mN|=uzn8#QxImdU*g{!dvJ;aIYZcpgHJ8Ff*V74F;5eDfkf> zm#`S0&)jWEehp0y4*{JH%}&R%w+lKpZ1I32dN=G6NLIa6ZjA z#7A!UXJx^fvgWh)yODR09`wcChgiP%^Ak?^;e>MKie+WLQ|X|RgmO+I;!(MB(2%9I zc6rNo-?OeE4{!=SUjI!sIPX#_E?4fvU9mEAcOG0rW9e-j-uAdNO#PbqkG>fi^U(Q3 zQ`_X@Ki*;tPYrO8?=iN)OVG^V6liAV2J^rq?s?AdyvO_Krum+>rO%_Ax|_B{&ZPx= zbmrLJV*8TZZ{E}=-E*Yg=E~H+bVg3}5cV5!KWUVH?~K)Q<^1$sre5Z3>K8kP^4EqcsEuv03y$(=X$ zU7$zE`@P?J@BQ0JW``Gl(q0uJ0!GO_ zgd#=9$0=qyl;8}{t|k5?P0^}MP55n*0OyGmxIiSq#Uc%|U8KS#-1D5@d5`z`j_=8b z{8B=y@#^aB5Z(V;ed1GUhHpRbJWI5iC0flAt!9ZMngsFR44bHs$9|!?65)Sz+VW74P|DD_)D>`wZ&(MgKEl-g13yo&(rRQ|w)JY-W ztXZPZ54G(7z-}pFlMW4%&@%Awr1a>Tc;%L`8JW*A5cp4mK)mUZ=5yjDY3ctRKIZV= zA_>kAX;2eh+$8RK&hNa(`+OJ0@1b?w(K-*p{1Z^?K4 zvyL6>FR5QYyf206voz~`WW8=0k@^|CSGZej!Pr*`S{VKvei1ualep)(vEhWzgx^ff zPuj0OM;7GLuZ>@uO7;v|0Dc2lnnb^(FS}1xe)_G;Qwm~91f%x$`f1m zka*2OvfM}U)}>wc|JNN+`jr>7MXOnoZw3?hzNN_4s*-$Fo5@!U-}S-T_W-{9;fKd6 z6**VtA2G1H>X4ysmK1;5JNQ54IP}42_(pWU*vXp2J^W zAOnF61TqlFKp+Ew3)l#WQ3G4VRN{f|dw7TGe85cBYu($zn<9>~LX3ES!AOnF61TqlFKp+Ew3Xdko0jWd=-KBxZyMdnEeJ(!5Iq7gJ86cYf{PQmLbPr*zNsm*42- zdHj-oZFnUs-@oR`zDoG}G1|p7ysN8rcRf}+^8h;)Ws+~H=Eu@L`xfSU=vSWW=VX`k zzjvJq&f-MlB3Z6S*6->E zJdaoHIIkb-;}dl#AVsyBHP_UvS#!P8w@S9s4@#SqYSuhMM|mBGQWp5TqK-3lIGNVm ztkWJP%G#^+wNj0m{LXvCygZchs4w+araEW`?V+qHlJfojCs=eM` zrXAlYovy@jx(;pSc~ZQ54&^zt5V1*B-5NDiRwbnaO4Q*;B}Rk;XN`ex6h9?iS@emz zq>5hG;q4W2JKTv8z(}C%nl-5lb&9QTyvdySxj2g7o^+CZ(kZ9dr&O+NSFT#su4>}P zoOYUh+Nr17r&g$7S14Q7E_>W@_HpU0H_^4EPb%vOb_t_ts}jBPz0yXdH8rd2DE)m} z6T=`<%Vh8Q|1J*gm=jO5PXtcD3%D5=0#D!ye1UWNFf?VFTAKSrXY=l%og?@<8e4By zPkpJxaheWo9D96hNr~zg|2OuXWvl^9?|1~3z_)yPyFBn_ljiisqr2SL8k;BXw-R1! z-EcTJjcHp**{)r?wtd!FXW3_;eYTV8)Tv`@u1}t}vSpoJwWD70zj60(aMeG& zcsiBg@2zQR)scJ{^Rcp+ADpKGW7Jw(#wt^$yav)~)@i4mp-FF+{~DU;%)d%@rAk6y zWl?l^>l}S=YZ%(cF!?I!Bi4&E&$Q1p^h6D1c>3w~>EJuo4e(;>h$6Zq?o2zqqM=|b zfA>3YJNZT}FLUzA*2#+St~Jflnr4ZrvlLIx5+1gMhpqD3rR@Y!&*{_8sdT!}I|A7; zLj5Odd8iN%?`f$$MIbv#u`*_3GxReh#lAtQ$WorMZq!>vZ8MO5=Fe|0-4ND$I}S)ooFX`>LOQ`nJ=F zbqIawFF%a~fH(ls%Kn=Zw7^o9x8Inn(0I(-lTWr!<~`m|eST7x)DG4P+EjUq(k>;f z>y`H@eWUbsWzm(VoB|9}%PqCHfS*X2+AVctXHUw`&Yq@Jr1Y)QYNc69+1VHAI9G?3 z)(WZR9QzhLnzn{)bg_=??6%5vuF{uE`<2!!%~NWd&F}0<0sj-5OKMN3C-tS?%2WsK zpgqM(%ao=oP0GGNM_nDzzkWFbZ)68^&p-eC$!*)VEznnsl(s7Us6>t}lsHNQM&3Hm z=k;}%b~INxYn1jVZBkmGG=b7a4vI>>HQrf4qPKgmQS*f6P>((N=8frVyO5XBJpQuaDSC?oY z73d4oE4HqGiNpw?%z|v{KwTIo)GfNcspBGW3wTD{#Tnu(&T81uZg}2#_IWK@*ex!+ z(7v!$D_i@q*xHZ9zMy$?yLpo)c9RAT>;~dI>VT)fPtY03aZ{v*$j^|?&?iAyPEngo zudLMd45dlVn1KETM@7Ca=~b#QG5M$Ze(*gOC)SKwwd`8wo@<{AoPZZ_GcW|6z!mrc z=el+6x~ws*0VSiqQmvzDQ_0R|9tZJt1Y2)YFD+IoP~vE=1KA7fc63Rp*3%YMYaazY z%K9g6w|4#dcKxPJ?WT#wBk%_9z@L6dJ{G<8iLR~t&iiJ}Jv_SbzO8v^3uoHRnl-b9 zM{MmgY+rWSWp;-S9h~!}mtML_3ZxxV`k|u_--`Gx8(WD8bwkEno@nSX#F%F~|Z( zO-t*YNY5zR)15o=3g9F}*y;)$ojZ@|+`01%rFBY+l=78Eb#AXiUEA1i{cz`JuHlI! z!l}bLd!-I1+nLJh%cyMA&e!D60_?O~@*+#LO}O0}r{Q+16T|O&F1-C4T=fqxo{lrr zqo#nnnx$38__COAUdJ~#x3oZEibl3UxrA4Hg=mf%)j&P z^UoJ*I`<6gj5EqfNaU`!xt1jCx2(VL<*cjc>m$~QHp;}%6kM~02(vU}-2gAbGn5em zh>IM;5HlqO&+c}*MMIm^!qAit&v{9lj)t$SUfrs$5p=djP*$^MRv>uSN~4hOk9N+7(@Lg1b~Y-SrN9piq4kq5N9FS4(XZEw#=4@Sc|1 z^8~VnT59WRsXZNeCOm!^Jg$5bKJ!%H4aaENT?CSh7jS_v%Wr_=85#)ZoMWBSScuw> zHZb%RYaZ(v%ouX$GU0A!Dj%*Q>`m5)Yoh`dsVWbJfi?)Xf!- z6^D&pFT9Q{)j(RW#5%G;iF+O3Irt+F7{j;ez*8E%lTJU~I-M~IETIjUAFW&O)5`m0 z>x(X0a8avP(=}h3HDmpX?1c|{eH85|NeY6QE8ddR;37pa&ioe>eQ@L=LAWA{PIiZ*@-6}ePZ3ZHS5CG_S8Y) zpD4{&+NK1=#wl?O@7uSpNYM_+CnTyb>n+FK&+cAcqi;7V%~1MKiQ^g_$S}^RcE%ZA z-H$ilbneTSFI&DrgZd4q=M6ggs;oSvBBd=#3zR-p;^?IVo&&t%ZJtneXIK2gV2Egt|q;BsqQn522ex>j&>B?xJ=3_ICTuo$bz7Uu|FA zqlew2S1-F)@7}g%j@|qE>+S2icelHD>t=Vm;tKl;aUz$1vl=zB8-bgU2g7H@$4!wM zf;-V8b<{zhgUespcUcX7WKFN7ptq+ml^{FdEGHM#cMYi+G1_Ki2* zXy1D4t@drV-Nt$6%{Sk?ykEb51;VLgyLRm=R{j$BI%xl>^5Pbzw?e;3t#&N5mBeLr z;K4O@TX&F9psU@rtEOmo4Y}(>)3&#UTrUm7>jc7X0#m0pmgK8qRBIhys}^gch?~~n zk-MU$qUw*lz_1fXz(GteUJcIco(*V!v(=NB`7~mXZ^nZ!cwBW7&Lqo7`BCkrJ zW~lbdGX^vqy56C~$!M(pFI9?Un|8iV*~mAwj#;AZ61E!|MjYD$Z~q>CJRKh5W~lp~ z-LNfgCG)r`WCAz@6{<`a|>#n-Wx~gMGtD|Pq#gc?IrCqd7 zRC{u|Jq4V{K!8Vw--bVjmqa!KPSX-_6*$g|_vjb;KuhhFT52yvZYgr55%v%0+hvM% zwC@1(t4{=smKToEvbzOgNHSjFuc}u+xq8EfR>O-fvM%b-!RpXeh#FeTO&f5{8pm2^ z)-(9Ob0maCZk;yY=hp_$Gi(j8M?j9jI)c23@tSlUu{w29H(!jLs>TU5Y9Nz&GXg3(s@UJ^5T@M2tyb3EY?;efw?`N3x)ApFX4e^y>9aFQNEu z{`8{<5S~E*ZHyiY*wY8h&5i<$U%xR|d-k+@GB^31_bwJ7nV;xlkf|lt!?*^Q&m}Np z4Te93ChqZ(xW1`MtCi*|eX2B~M-SkS{tkN0C!6#i2~5>w6y0iCt5&`L)w}n@I)*w& zp8hXanxXV>=ZQ$1TgQu@U#WWTgDz5O-W0BY_XpjV`=MO9MoSwtx^m2wSKg(=$#m!k zI?YwurZivaBc-83Stmc$TD(|m=T5DmKG}tfim8NNK0y_%r;2t|g{ilbsnZVHvr=h- z^XL&B@VMx3*F)z;at*hxQl+hxnl+o#tb6zOyFZ{KnAgyu({-^DaOO)z5hrF3YcVw#?$}qpMfnTfIe#6)n1T%jXK&a5?7y$)u%;DyOr$2oG62`#wraR%D7Nfx~odfRJBS`picli z`WTrXI&^p&_(j$SaNW%}+c)2Rw|)10_u2P7_@MpZAO2wf;o*mEb)Ee%*AF~kKXA`I z_C0srY2PVMbL6Vho^}9VLs#8?yL~%w z0$y$yKJ<|N5O4*)cim;*1q{LS@a^Dtcy{a?u=|c}p}YR6)iH4%?)(y8`_1Eg^$*y3 z937?O_c~ZFknduT<;4Yw>cU!vP8^vuvJ2KbXqo;Ze($)$z5`gs8;^qq*@J*La0mW; ziwudp$TRsmN!t@2zR(eqAhk{{k&Mk99{qYzTkz%gsdt$%PdxF2{pUab*?#J&r#NqZ z^2sM>Kl?Yk7-$b1IM^OM zc!)h@NNCzVtZDlP4Y>ye!g~a!+xu93`gFEBcW!-3>(0Q9mEl*> z07@@l37=SMm!&?k?z!h4OMJH_`K%=x!_hhW*@p@44!$ZBzRC^atLL0!l>RSLDo`5! z9CP5G${+hId;!2$k;?jaYK02hD>QAopy}0DkGxt{y3fh>Q~h5Y&eq8nT>_S0*q{?x zC6aHPE9~_ohp{9-O4x46uO0gWKb?VTcPZm(Ad%?shg5j@N1+wo)lyPkRcxZABs>O( z##u|E#-OVbiDfa9ow>k2bQjS6?W(nFceMmQ{jL7}wOroK{CdQGl6W_WGXYqGK7+L0}Y75L8A*#)=Ap$SC9Mpd!kMBPuo!EFevg z-a-o@3F$qT^m0=ONl1_q2%XS-JKys^_nupldqeg6<~u(&=j7(*oW1wid$0AZ^{lnE zP}kQDc<0DCWF6xGTMagS{3qZ=>@xU6{AWV_O7gS&2=ptT&xi}aW&uW*NxU=m4h_Lx zwj8P7pY*LQM_!Yl!&LLQ@%ZEIuV~-CwGKZy60a_(R7rGo1sDZioK)4e4Q$h@?wR&y zmoC?I!9MiLE5FD9Wsx<3ci&HxY19>26^H5nywI04c!=D z0e#Oq;9W?vzlN?cNCNhK30USM&*aR{d`?|to&P6u86N_^2Kbf4v%p^s{_gCT7Wj-U-a&BADw(Tivtx*Rhy8_(K^QZ=u(3dY)1zV}2 zPb_=l3F8x4f#*G`KBOHt+{fBsTp5r}SPyOBRcK+^;J4)_&RKSt( zCKd1FNt0$x(j8_w-Gx?6oHcPG*#95P{_&4T);{vc9VhO%BPh=GKE!?1u!Uf_JMTPx z=cA9V(Q_RQo|~g*hN-9--;x7_?zozyH1b@2|g}@VeCHFJ{b`H6u~atn_{U7(HJ{&-r?OTE~tpR<&qx(GeeB zxgK%lPGR*DVf7dnR-Z6y!h~6~Vsw{sl>;i~Ph5Bg&x)C)M^9k9sw&-8rM9ZtX<>CZ zbKq$(A^u<1A#C@oF|0YCe{O#M_1EUtW5$?czW?6*{)ZpTAErz(r%aw~PM$Q$oHSvA zIbrl@bM(lO=EzT^m>_SEE7*j=NXQiULV2eFmKseRaeLbHsPw zncoHK%kksQ@uNnWqv&t?oxj1}#67qd_dKKBx=B{A;XZn*@&?E3|`^B*MJxKNP z`I)#(4UIqHA42EEmj_lrkNQFn|JMBWTXo5JP1Q*nZVb688gf5q7*5b|96ek{1HSvZ z@%rn}U-A6&54Cydp~d0>Ad@Vd7_I+Ts3cfFeajl(qsROn?b>CxYtm$sIAFeg1yJ4elHz0P&Ei>@-Jr=3*3w)P`Sfhbr+y!&^$mRGlCzc%9b7IQ&iMN4uZ^)| z#~ON1hHNN?-kTx5gQ0i@ixV%!(eK`B@?{6LUR7zmN_VeUk{4lNkzrwBr7DgvWMY`s zt5~g9g<7x3fWvy_y^qgJ^m(C|Uw%MXeWkE^+SBMks@B-BrMl&E`?g`~y|7RF_Kg)* zFBDeaEUXT9=@f_Jiwx;8R=Z_?Hl!o?wOex7&^tGz+ga_F@6U?k_q{FcKF{m4@96P; zl<#%mp7NUpp0D#={3;S9tt3jUF(_4Hj7!J!%94O%(7p@p#C9Ci7L#mE2K4_B@g^R< z@6ktl{jpcCw~V*m0-tLc|BCrF)towYra5yaYQS`J`gG@(xD zZ@w|U88^-tH))bFY04Bs3ydL|X8bT=f-zy#C}WgH)iAJo|NcgQS-^VYXK&NSXao0~ zcz3%d4bJte?DI2vpv1#<*d`@v9|WUI)V@NW(*Np?FX?CA0c)M_x+hWly}EM@?}+yR z)|9Ay9nJ&(cyK>HWq9#E)6##i*>$lit)CkoFG*HUA}2${Jm}D&eTS!?Zu&H~4)i5- ztZ%hYBfIMB2DX01JO1!P^Fy#9`Wd(nTRxmI{Ir4JbG?e%KL$7>M@XVVoQ7zW-E29c zVfDcWkTT3^1e&O?X z-<^9m!Pb6qfEX5`XRzj z>5{Q)gw>BX;12nB489lU5Ij*^H^!HO?Syy1+(g&ly)i#|p092^Zk#!e_c~0S!~A^n zO;h%Gvp2pha6i1s;6508aJkO@HIqG3SiMMCJyP}#a4h(1B1f4@n#zoc6Ne6cZD^l9 z^ZRt{m?5mbzM20Z_P_sW|D=li_TX=Sd;99!Z||Dlwd=ne|N7TR$HB0R686=NlA{WQ>SJ~o-LQ`+A7YU%uUTraoPjjHrVsX;nPkPeO?uQ zPZgh_21KhNR(l+|4#&cU3msp5^_4?*%RF&HepKAt0rAv4ZScAA&wqYQ{_XYhZ#(7R zcF4c&cw_i*$MBgm9WxUX9f{?td4|eD-E?&J=+T2J2My}Evu96HqaI-G31};H^_0*5 z{;h+{3m=+s+N)PxulL@oc<;OKvc6li$gwC>&ni}lam2(-aZH(l>+?0oYp*??@%ZC+ zmE3jL)iOGG);iA!SWIT|d(ve4b#bmtNZR(trL_@}CJ4mQ9F= za6}a7xuq(xj@Ve*^NB-#eTV$|neywexbw~{wkzU_+t`P$uRJEKzFt_}sWr**?|<`t z=?{m4x3TdYO0E9ZE*o@BS+7!~vQCA2?^8LTqG!eEnVK7_!oO9O?y6E-Rqd2m9h?qs z0Z+rR!uH2{0>%NKv;NGUeRB4qMf(>;M(&7=jolQRkgy>kDXAtYF|j5wK7K=dboA!v z@bJ3u1q%)?Kz~4Y5IXq+nUc-6*W`3uZGyCu;GbbC)->gtZu)ahG*9p@L#V^_cQx$fxi#7qM6z zU{pAe_)Fl_W8)3nhQ{j{_-_KY=XAc?wCO=%_0_`a`7aI`;t*DMOwp>DRH;`L=ZK4& z=nz(S2&)$gtFIGQKYDst9h;8cY;$B8G7bARvJo2wwvMS%falFSGA}%QcX&+9)|iBZ zjT(;|G#)o-JZ{u@+#0=P$?hc~AxA>!cm9UI1=cM?8QeG zFOJ$B6&1fNK0a|%Vq#KlQc^-~LPE@@n3yHomModGXU?4QN5_wsy7>9qSG@gpuQt7U zJ?zHnGFOieixx&)p^^|5_Ja-=R!Urpprn#iP$42so*cm3J@2A{e(mlhUPJc5N+wQd><7A!C#BO{IQ@Nh%xjPZ>S zi1Z&rdWNXV_Zr|_-$U=fTCeJ~UR7zmD$;u8)OzKNjC49noldpWvY%_cTCVkKz1FKE zr;XK3Vf7ut>Q%z(MZ)UNu}haampYeoTc>)@IqTC;oxuV{+O_O-~0OZeec@$-Wz#fM1dosiCc8bb3lgCv}zPtH!aWI~J!z6`2?( zTUh-eq5<#iIbcB30bq1lw#LqwVa!;x$XK*=sj)OZ-jGZ)B-4x}uA`$3tsF+!?AgX_ zjWpHG7>@s4ESA1qyBb}IaVO?o?EkE!BdV;We_wMbx_wV1dzh>db-+g!DyO}fh&0M^A$6|D; z`1o4&W3BpeqjZC<2{AFdVwNm9Bzb0rfCa&!;6&^)@bA!NdC&Nu8op<{6WtZHf2?=L zmq!c%as;~vK6320a=(n6Xil6s$DA{#F*y>pJuGbY-r2Lq9UC|9Gm*yzTsdGs&$c~# zKIq2kT4E=J#b_{V#$ zd^>I0$!Uug9acaZriWWdvCuoWk&hxbm(?*ab_H!FV=*gqYj*?j>MmZ zza8HJ92g?_~bc34<_wXk}&uzI{?Rh+Q89vBr@78e(n78f^TM%;{{ zL;ohMo+hlmLRfugGw=AY?|2qxRf2fC8xP)i);#;{ zz3cD2_xc^mC3V6l$EN2=uKQnp`N7Luw%pwEkw%{ z3^+dEtFN|y6%w*OL~kN})sL%I>69K)6B6=O%~xLys2MPT_O#oec=?@*m$&1Xd~Sc` zNn!O`Vf7MW^(YwW^URJm!{vSm^M9U3<2xw(JM)s$FO zQ?svTRe5S-r->cz$us?v)!Is!1@TCtPY!1dp4ps~eHA7j)Gc(Ohr_*#6 z7Mg`QIc5$&Q?j$o?EHK)zqr^e=JS*kGX?!?wtyD?6u6GWAP`%C%?!LA*uNgUj;Ghg zhHY!??|7H(+aDBG-z=y5m2ow~>NS$bHNxs!gw+oVtBbE_tvz-% z{T3qEv+cfQj?$ONG-MlgBQW~n#RnD(@g`(tRc93yl@{f%$j{GOnU$4M&7jQ4;5xr7 zKfkcBv@kPsRVLqEy!hZ^{+4_A%E{pNBfB3w6>apw`nK|{DfP}@q&hw(b?;TVc0Q6T zd%P(uv|n;-eL+D%Sy^RSZtl9=Wy>}$OHJLJnwq*PH9b8qJv2Ks^qZn@zIiL}t+$?Z zKKW#;)r#%k=Z^QWpKMpj=dARNzeAryzalP^wH|#H-BT*W+^B<5QR$o0)ALv5=P%c> zQgx`7ePESFU6slTT^H)J^yTU45kE#mOxrST+Q=(Lj(n%hJMZ*-RyJPwSe4Y9i2?(=;XhEq7@Ukvdc^{URbUe#&6s*`N5(|WZ>>(vRL^~&2M zpO@(~7ns{{Kc0U2fUtUFSkkYocB{^Y;%W`)SAMzS?RKBzI&HVy+ZT;r+23#MY~R89 zj^4AX*h-@0DT$JS5+!3KN+Kmn8Xto`H)+%sqdDyqR_`LL-lun;K7;QcJb2W#qejiC zoHHlJ855J4otasxF@=m=r#@aM8M(T+xVWOaq9SigULKeoJKEPGHoSY~yYKe6vxhin z9Utl2_r|`12mf;Lx8LshcIM3WGb1C*WnU6n-4ZbbD349d(Yq__fWq}d z$(iU(Oq}c#R`2w|2QMm1&A@@}2aXugbi~xD#?&QCj3vp*Msi-Bk(ZrqWM^d=S)B9p zjeO0d(y}t6tf0UsNKZG?!Sb?z&BD))k6p9sAMohOJtwIUIF2^J%SL|(r=!nGJp3JB zx>gnB(8Hmj*d#MoXJ$IfoRYOGBx_en)>do9TP0arrTUfU7nc+l=Tzn7q-;z{ ziP#qr0q;RHt?vZGJ{FzfU#zF-?btyX2R_p?ST{CqyOZzFDXiX3Sp8{X%l`e__y6p( zrk_okv}00e=-SZaq<6l-gEHe_WZDm$#y%M5 z0&pMx5bV#qXLuCEJp{JP(2?#VK%d%jg!W3scpfPRa~OIxyf$&x8sr%d_s$}hisyY1U=Ki^%s#qb|VH`TO=ah5xs zadFPLi4&c|>dwK>KVKoNzExP=wljF^+35UJf8M43d|dt6uiuOPKKiKrN8`pd9XEge zw)qJOYZG|q$h_hzO}Zb8i%V43<-&87!s@FfpSKCCpA3rCsRR69^D4Ne#4+M8U{1kp zKsUyx0v6B6Ff-8Sva-yq^mH>F-7PuUOyXp2HdYnFlg6H&j$qm@j}W_Fbo7zvqN24$SzEKR1VcjM6Tu@Qu7wyE^he@eJkRlK zjO*UuHF(#IQ#jYytH6lZpHfqgq*hn2sm?f%k)dU20q+QI6YD4Z9M)k9+l-?uFd z`wjae{$nrz7ziwdPqVaiV`=`5{QPh;JRH6#HbqU(m*7{?9^yeg&$Az)+h4g$Sbe#$ zdYqdBA0n)-F`sNGmwD;S*@ z9uppp?ebl%i-PLi1=ZUMsyB+&?e~ixm~jN(4txzpjE+7OotasiS*G=FjdYDI8mqhH z8$2NH?qSvCsOmvEr0ae1N!7_8wJ|Xv8GVA#0zV=(TUrWQY{f%=u+jsT92lgMvF?I;- z5sWMB6Y#TndyFmCYcP6Rnwb`(#SA;^{Q2g5*V{Gc&YNe>TeQd&Z^4Yj&z_rW=JI{k zd%lZoKrUk|1Fz#>CqGF;pK#zh`mg`~^*@*2xv;t^tZoXco5Jd*u=*hvRzD`JuC*II z3i*$2btf)GP0lZ^XOSUg{rgV@5@*cq#4%oyHg`0zKMee=y{gw@*# ztKXvEBN!7B>V(zngw^YW)%OXjn~h*~Q@Xz?-QSe%Z%X$!U07Wc8NC=?5c{i4PYtoU z-R7U-Ed9oieq%_#vHa%JZw&Q~A^pbKwQHBLapOiqdZ?j(vbgZdF=P4QWkY8pJ5QDL z9?o{X^4!BCQ2bx67pt4X>ZY)|DXeY^tDC~=rUU*nJQ3DZY0fFeI-GxbXe@^)uuk(_`LVS0dSUhZgwos~n=igOe0cle)2BC`9us4T&t%A_Z-@(KEYxh7r;!uF zIaCTY*gPf0NCB&_T4k(~1x%>lNaXjUM;oID4>kt(RsevkU=NbFTda?Udmq(%!T6cL z2NJby;b=?LzAEuB1dNTM6uT=nHfME?WNo=*?Rv@D9g?;CBx?^#*0Smym8?A?S$jaT zcBf?RM#1fXUe8i85(K7B%TRm;s7j3`zqQBS!Pe~`eTe|5j!s?asDq3X|D)gPVIpX$yb@X^MOZ9jI=qUuHI>4oWz6%L1}3Gq=9 zdJ#t=BEoCK!y_^yBBC?`;)K=Hgw;!h)weZ*)%CV7#>PdTqTj%C!Pd{*0&|1S@voq- zg$p#G!|^-b34Q_2tkdYgtli-Dk`l8dHPuW7=fj%>x1lehe-p3L(9U8Ht{`X0GHqwj z`lMvpAz}4Hl4XbVe%4x8eV?%UAz}6Z5mrAYtZoXc8_n!H*~7s8`Har)KZc7zUIX-X zcr9>R7&C?23;9>5%P@U3%EHgA!*|I7O`Y{HyZAlV{?L7v8=wh#Bl8gc(RgG0_^8XH zqOifFq;yKraEs==eEIFmcj#I)!tX8JOZh{zHv*iCo(w)9W`(#Wbb0v42?<9M%F5Q3W!7b8N-bQ( zItI5#!|g@%BH|i7>yGyU^{z~XshO9OhsnhiH($aZh0XwW$j;uJjo&-!SX2~q8hZfi zCVV6fxxbJ@((^Lxs(YSbe_(2d6Z1F3;G%E9y~Y>G7=|aDm$xggthTI7?#OufV(@CP zcMy-sGst)B_YBYd>tDE@hrfcZ)))#N5Y z9xxU?7{ji*{RsVBKewFJoMAC4;{<1)l4kFKE(p;c(8Zx zoxKMSJ~SAd&YF{yv_Gl1cx~}ItbWmNSYNKv{YF&>Mvj=P< zpUgqcPr35awIr9PrPZZ@N#TIA))7ZXzBzPDuMN@NnW~ycH#$SCZoiJeXK$OmE5zA+ zX5ZcaZ{ee3|G^G{?u=amY{D81)@FUi_6k-3qhV8nKZSjkk-@x$w}>2I>@$ZMlh|hQ zvm+DuTW~Pnh4+SS3;hdD1o+n5uE2Hl{@%at=lc2i#_IGXc5G~E*pBE+)|>?kj;Sv< zrpTmUnzli<`+ZB6$bB>eo0_`qYiyv*Sqj(K_V}Gd=gjbZ;o<2U($h;zSCpovZnfkj z_iWsLWcQQZpQku0r?{2#UHf(^?D2KWmMlH}sC1jP;vQvZ@62XR;~m0Zq@qKI?jQQ` z$19X4D&|v7sS)tXdoJnO^S;9S?z?V>B01|TO58m+F4$KM7XHMj>6v%5^V&!-~rnHt@7tv!i9tNY5M6m;U9K#+Sse%MvtRBWb20O2Qou!%fz412q=H@jUU&$-@9#?K^Cdk4NR?0garA4t@W!ETYVBPFGzqC~QGy=3j))0VZ1 z7av-jETCRoTv?pCAu|(f$2v+MlY360_GyXQdx(wq@G5=lWmi_81)LYhG+u-)+X-7f zC2ZN-gDoE%8ycDuDhg)IYGLy|`0R+6V%(rJvPP4GRPnKT&12E6PRiE0OXE&vp5pxc z+WeT9qcQL(&?T_TkdK8u%Ha4l%$@44sBhhM?JGy%6WDSj@?>OW_7?fhwJyl)n|4fV zg(XKM4=g!C-RjAa1Hww8D9_H<_>R9Xwe5fUvqLtZoXco2SO= zzVpxBpZBRhpL1h%sh=a~&V68RYFcV)cx8C_oVq!4z$Nekz(1pFN00t4^SkfH%^f#R zRsKRy{Z%dL4{2$;QD*i49C-DMavv(QhsD38Z}nD2fW6^I;zwDw%v=UWr~lC>_&pji zv7=yc&YJz7!FeZc9={BBH2xOc4qgX;pm+D`wY!%hIy>ET)6tv!b1>V3%X?-&PxiRD z`{@M zyP=KD`?$bY?lBb17oD+BvruwWF7_A?e(oWSfjmCS72`Vu8$7`MOYCy+?cjRgGbIOw zOdwN}u1!jkX=|CLObEJ3NJx*6Wy|hc2IpQ&;dFDTRz2-K6o_*l>{*Ci=7C%f?Z`8U ze~I`(izA>lb)VL1atv8>6a5bEq;k2?k9J$U_qIRro_VJ>_9SKn+ZuWx*vR47pXTs;u-zc9q$ADuE>)Po?_m!?*Tj;cvXzI)YM(6&_{Jv1oG24m&)b=ohb&^=(TDi%ZJPsQ#ZqrP z_YQR7dLCR&_9%sOf^7qw#N10xJ}NH%+RDtmnVFhC3$YEUE8oEvh|h}g!X6fGHOd8V zd>#WH1{Z^m6~^$2+!6hfR931SQc=3sGcJ}-Lx0El_gYtOSLvdA_YofxonA|~Y$i`H z&dIquC%ra3ecq;d^S&+HYo*O{tXKv~y_U)|aZSR8o& zHv0bLZLe?J&+gj3ulJtJLHyh7m4odS`ww;r<{5%gV)I&K&;fOuO5B%(MJzI+Op+|mgrgD_B5=c_jlfU zpRdo(Csq&Cm)MTx&p$CgEp203QF&1j+{TcTAtC6f=u&WbSbK=khC4*znzfGK@f|Sx zd?j*A-kzLXCT%e-ZF>Xz5%xP`#(trR?%y|df1cvZ{%Yc}D9rC!YaXCm3HR=hJ+`=b zYcVlvU|ilUIShE`efsS0^U_NzwEt2}U-D~O`*tYK{jS2h?z&=!e4re>GW+m(eQ*|) zPUz3%J(fx1A5slIM7F>Q%GoNet!x^5rOR0I1>FiWhH|{9Ti&$d=$XH)OK{V~1IHG9 zL^{km=~LO7yNe%QyqGlx{D3@sOj!Lk%dh?uVRcPpZB^CCJhnUGJe;-9jkd|U&C2nP z&m3H;cw8$^9qzkqlZI@QhVYi5KC|+{lQ;c3*-v~Ay^k|puk4r|?`?cus?XZ|lVEk} z6$ZKD#6?u%sDB7PZm~Zu7pc>EYNwQ?AIreg~&;kBwrcH90Z36mmQIPXti`e$4w5$+|G07 z%50{*VIO|@!w+2*2bdT=r+Chs(OX83#t#C=4Br9k8yJ-{_Bolq2Z627jU;mBr^q;1 zQBzTooxMjzlW?8pG;I?URx;Of3JUhQ@b`7q?%GU!AyM zVy%_Y;azgZk!uc(MI-Aq^1^Eby@#iP{Bq#>v@|0Pyk1gbl;D%sjGL^?h942Tj&EJ8 zl|MDeGw1W7@&DrAMt8^Wg#QU$jdm++U$Pe8z&6R+6K5%F_ej>RF3j1GlM`_~A_D(9 zeUI{n52^tt)vlYj8w0NgZfyCW)?@lnNy3K+eoNPSVs5JN?@Fybd+~MXbEU0++Zs2l z&8*Q{pJXoRuh%?Cp-rDyem!B~Lt3NOOSTji))q!bACJZ^gKR;MK#y+JM{CzxXJSbk zlOt;+N2KPvW>P$-KzH#7kBPh zUDdMsQ`7C!@4uh;eNt-XtNs1= zH7%|Dt--LmGMfCF{qB`jRBE^Jip{^p23AyLD&E9&pwofDvAg4k(rkYnUk!O8@H@hp z$KQZ&1)b0K$;ZVVjw>nIU6PWrC1s9+hewR48u7*(rEfg>pWQf5Av(uEUaECtlm;sy=BK{ z`U^v4n@W{Rsfxn7Zxwvj9u&ejr+c`o=ZmM62B?n*PajK4s{XOM`mokWKm4qiDl7M{ z>?7PF;NiYEIVp%g!7hi7mUjv@CSz4bhG2Q}OKwinv}rF*OHS^POk5widM#fB<1ql| zKG^fXtHi2;p)=D3}8LJXJ=G*w{njimxus*zV${ zvsN-6sQ(HEdO6%|HBSw+XavGr)L5|3wd0*WQj^ zT(8@GZ*J|W*<6QGO5KC4E=9b-A{BBED7^lRip4q9-`MAib^RyZv!hCn9uM}=bbME? zv2W(hdthE#+C6D`RkC9!f6Dk{2~5i zx7*v^`u94YSl#YRc++-YVy{R_+L~0NfW7z~@$uLO@tG4F#rlm66z&UiRozHeKEyTg zO~g>4-{N}_K^ze_K% zI4Ht>HHsx&w(Ov6z-9<`ciM&C%zN+H@p#7$9oBZ}+&Qar*RD}rvGuVRE&1PnrK7|9*8%RBB^&u)+)-DdMM83kMtoXCVvmy(rItM@DLoV`~nAy}(@>XWL~9 zFB0a8=o%3L-p4;UfBrM`Lo!1`zFqh2x9=T$?>%riJ{DpaiN`~qWSx`AyDRgbb&xd< z{~@h9WKaL65S1NLP6FJ2y&-DB+AK)ibWn3ZGltFI~*B(gA(Oj@7O zPWZPG>VdNdc#?9Imh>e^x9f3?HHhvc9m|C}--WUN_ZtRb#l znH(43kALG0;|;#A3=)4)RJiQUK8MfCmZ-fN?mIjp?6dfvkOxUJO3Ec5S-btLWG(r5 zys~z`WbNwW%q^LjzOkLy21Ft5NX(JkSpVav58BmLT?4KMjF+{_k)T7%>O3tmljZ)H z9xMH*M3{EF#*c+_mA~5J)-rBbpAl!;gF-aY$F1=Mc9(zJ^0SMZaYT5%Mse)9xm$DL zY)Ru92POn3@~-jY`P-h|6?ONiEk}qqW*p(4YE+KMM`YP{;TI!El%vwBBXvcNkWUr) zC%f}*aZM^To)rYSK&FM+lP6D|%$c!NSy@#HUOj8Sy4!anXqG9OLFlaBv&1uBuwYuc za;>Z$Idbr^!Gn?e`1B-dpJlu}vF(W`o^n3*)U(mgKHDRtM-Ne|o)Sg6oy?H_`CHvx zBun`f?8hMH)l=*_h+PV7p6$*nM1Pa_ow)@*!dHq93cftNQAIM}hy5Q;GP;E;)_woG zUwyUVt2uM3=OiaDPZlvEKPqZjl$PCzT5><=-8-uH0}o_9aKjC2Zn*HmWA3E+#o4Ib56ezym!-;p+B#zVsDk;3ZT`gGJco2l$p zDOX8ViB!>R{F~r@>xO%;dY|3k%4c4}Q^)^n@zgay@r{6`N^46?ciaAG_2Ws%S`z7vNbivulGKJrv)yyzt|U79d*GOI^6On9LWK7jt}E6{_JHuk|BOqe?~BTpj7%%GE+^Rmosl(L`g(MqUJ)TelC%(tyaFc6Rgk3 z4t$4jKV*=!;&X}7rH!}CVg$d1H5^+o@<%?0gT${($4^pF9b*!oFnPHB^#*rE)yDy4 zT>!tjLEfW!SRGp^V^rXBom1`PVVnni1%8@IJXn z(c8$W608r2Te0=M^M%!e_vK#oWn~eugxKofyJH)~hD$6=djXF-1*8=2c=L_chS@jX z$Tgqcr%j~6@vKkep1>DR9Jj)EvI`5>7ej54=|8$ zwzb!l@}6Q##-?0VwYn-X@kAmzGdinw!g@&-rcT7cu_iTZwy#->7He9_yxmf;=nm}s zf*66M6AvdQl2>$>_+ju`ESV))X8H9blSL7_&&qwL9O#~P ziCn4T1SwSUerxWEx3f(;S*i4_H1T=C=wK4^MpjjAs$#!_^U8tOL{@kBVRd|KvNHb8 zHIEI=V=EK6=CN@NpE1Xo>q4H2{W!}*`>a^G;I+4lg=Rg0sUoW3S zyM1Pi-z|NAkSXN?L-QO*O4A?87@-r<+N!}P8&9??XWlB z{DbzCH(tQ+sGSe*##=|sIT{T9NLjY-6cgn>Wp8`}UpxMDS%M}K!(LQm6p?pM86u+a zx#NG=P7BY&H*Ua#cfVkK>iB`MRp9qR@5HYXbv!DnsD_-jTh3b6;s-&9eIzi<1R+ATcp1 zQNuh{)cxYh%FV*+=DA_@-O6vLLuo~_`jse?=yDAI*P^rLR=$0 z6#VJnbaXnfIb2cfBjku9Hw0(B!5euGUAi3U(!2MX-tWDat39-m6n7h?=!ekIWqOBm z#kGJ_qhav+V~<5Yre))HLD`G#VP&7S-PtwR7oOMQ+MS;NQ&d`8KjOy|R3G<<#uj5O zE$x0`^`64&V}|z8H#({`Q`w}FqhcRgvR;-+@j?1SA}(M&fL#aA*9TqO5x9(l_oN9tOT0xZKXIPUjBaymfF88QFIUpWvdiFen4|Wx*-9NtYO%#Y+Q@2&Tk#_t*WOrz8`w8 zZJUEHM;-2TQqr-cii&j=S$ndwY>qm=;<=UdSxCLQ};N zh>JTBS5~&ZEN5p<4mvz>Pw-~3p~ABQWW&P<*q(aNuzyqvo{NsEXgh<`F6w?>L4$JD zRTo{=s?|lx>vvJte*G@&H+1NQLnlr8`J|;w4=gQIpj-Mz<*QfXI_3uB3%i!Z*_V&% zf8_1A>lEm$sUo{!YuOE-dJ6t6+#o@Wk4Ka%2F@{d?9`B{Q?c>KNp~yN+OShPt%cEL ze3J?4Vyk`ZT}Yv=>@kQ=!rZ{8fIK07U2{s*j-pH@PhdRy>J8viatL_pr=f7Vy04AZ zf2L<%cG(4&Nt3<~9Rb@sTt6@`JPnF!bT6N=W@3Y4u7KSe=4JDaMX!AG{mibZ>oa0E z;GVEnlNXT~5$r$2rk9qQrSPT5!w7$hafkebPr!Q4m;@KwqlA7SCV=>J;+1pFeu^^wHQ@o_S{RGi}@EwG{b~v$I2whKAxdVtxwlz9iSp z-|kZIE$f${+hu}xzr4@_Qx{&y_2qH~HrG7RmOyRg@_C$K67r)9GEa#;92;BqBeHCF z8oC){5}8YW7dT4(d3k*MwEnf-ulgLU-mzmxYd`LxdgIc$_bbM4#fr5n7zgO2>_see z^c=C7zIl%|eJ)bC`)@UcE^QdI;`2=RHSNjrvV9$TK^_FU6<*ypYOq4#mR>(ze^R;LYG`>hxN+O79(*(&UI zOCDJ5)_Aen9faR;?(MGMnXaGpxL@Jx5?r|r`xQp4%T&k(aX@9AN|_4W%~dU0wAfjH z&wKZ6t*P=;V)Z>Ptbi>mT{i7pVXjreC;Ramp^Iug5ES~YwT~k*61xI(PyAK6XFFOv z7VK#9)ohk-U2;cB2{;ZsGqYgk%+G5+|NOOMuG~E2%ORf{z8|=n2wjv5bYv~VRz7ri zhU7!Cd{5G4^0&!pm%lY19y}aH_&oUc=t=MTX1{}ASe;yuJ4-7nZmGbBxnRKq3%>d0 z?r#PTY%%bK7k>4EtXvP=e!FoyHZ)}s{w?!RTG%hJwJA%^<=Q>q25|h7Pa03cktdFQ z?p$LovFqfTBW_(g4#<9Q$bN71(dyZeIc?XtT56sfi z9Xho2)2eE{<5lu8ZxlW|EWl*pbnq{@{14JhWT&#m3wo6Vf~8w&PFQwUeAwW1VkywC zWKZ2D?qX3 zmd(3X@t?Jd|J*fjAY3}^IpA~N6M3^(tKj-ycJ#8#uH1g*l}$D_X@b6_DmPry-S1M; zycy$>amo19jv&*?lUQDElq1jK6|NUzBLq-gF#E zDtB2|{3nb_<(cD7!vUp3x9%28I4T5nRv`O@>+{^cluv3PxP zam?|U80@a-ti*I;dy?AIO0Eizo{v2V9m&Sa#271Dd3wA6dYOQ_;CedT0C=d(Z}2kf z7xrFkpPq^bs-~wgfqu!J>)zaNrKPbNOz80R%PC`%?5YQNmi>|al~bMV?*}>~ z`@o*Wagi&PoT*xw$ApHS2+hjckd?bOH+RWlao@!^!Dop59bO!^_e)`P8G^RV-DB<^%^z}DJChh00@`BZH&W!*A=f&ZNO0ZyhZ@bUxoA+q1M5BV;7 z4t)TYqMzXo;6FaoJn-}-I)&YrtX=dad!v>rA6(qdxH#tLP(v|1A|-2^pT}6Yq#|D9 zYiIOtzhsD36tKJ?Ub|+?pc;8R`yXwBa zxm~O*E%N+HH@`$W_$7Um#YOMtSCSX|gw@IM85Q-jsBz;i9{29Mm%OVCFwZCxOlxI? z`MsUv<%Dee`!Mk9Xf$R_uM`~}pBj5@;9tU*M!uEAqlt-?KUP*^ACQ=wZRI4A4bI}X zgTc}D(EHS!fCaq^e4(*q`Tb<0JFXlRM>T(s$$xlc{rdG)Rpw8F)i0G_y;<|-cieHo z9gHP>b#P+g*5cQA@kJsF49#Oh^VoO}-T#qCj7P*`=%7q#cVSx=>i+%N_}MQK*1JTy z=Oyx8T_Qj7C9*YMqCT+l^$5rRTD}Zkc*9ZP>d4bsuPlxcdc%K%U)|jhtKC{V1GZaw z$gf#X8ss{0|FH(p*|b}W<^=)!(fuJ9uUWG{Hq&ePt6YeWJ}yo}2bGm7`&HJcnC^Y@!?$WZom zgntH3lfHfeyd~d_mWs0I)zQ%tHcgnIbW?-T8?n8EIU3~4ao?1DJrZNL5ch*lXT@|X z_$6~!W@d5mhGO>pB=-sa8|DR|ko_xLH%dh$9<(Gf+ zGF*3MKKL{9&b~|iGrE_u65a@=M~4w(ZOUImui6O6Uh5~=iMb_H6Oi6YE?++Ok7k4CX#fl$S z$kmkw?x0_ZHGt!QFFM$sKtbn)u;rfZ+qa*PE%m7Km}m>~aTcEz4Fx|4I30h78XmBS z+oBMFtF5tvJxKV<<}MzQy+})cWm?*SG~(~@_rl?WM}h6#t8ch_RS!UpptIZYxwafh zIGT`9x%wRB2>1oAC3p;%ykPY*v#d<&R*Z(!Bx2Li zf#u?O7|t0O9lRH~Gd-g#;DuJH3!2N&5C4r^3eq+|Wv@YOOyov{4+6GAXF#vRU*peT z@PC8obk|$j^4oRqzNovF3btzcceLjJ3cW{%bnFPN9a`49soUCXSytX<)!p~d zEt}Q}tCtF^C%CY>-0@Sw!jcq8akI(;Do=)y(hs}Z8Xa)q;&kX2VSiKki^`QM8&$GY zLc&5qhJ^{MM+>Xx39Hu%t7}|Xedhb@K-d0FG>FN=Z_51ST@Z5}e>^^(v2?ILekLyH z6!;mtB=d~<2F~+v(zOBh24Ux$g`H*Q_@ZCGSNnbO#lv6Bne(SP?1Q^rJPVod=6iC$ zfknV1(yanDgU=Wz%ms8Ra)E#Y!I-Isv`@Ua8^<)iq<&5@WmB>2WY}7fLYpx6#X{pl?z_@E%+EfCGHg<>ZK^GLJk z3#S+RQG^KZ1qJI1ayJSeiWdMrWW7XJWRH6Klr<{oys)e5X^j0r!!tZ(Y`j2fqslDd+EYz!&=w0`+~4Kg95xGLy1aL4{7P!-@3gGm&~kapmUoxmeRrqzojP@`>e{t?a`*0iX7}kM zH^Hk4Wqm@agYVidJ7m!IXkD>vtk{F4J6W+-`dceUsA68V9f9@~uXSP{A^$np6(2L4 zZXBIvC*Kf z;6KHdMILqR>5L6{N{qW8Qc{((TWYvdr5qMI>n8Ow3`EdS; zV}eKEpNH4Jum72~{W>KAzxUq4dtZ1V>;*8n!b(5Kmio@}ciwqD>-E?BF6rC%g?TT$ zfKDqq+MROQ1kLyFt-tp<^gDI~)(h%}9*50=ww$UD$zKCDXPv>O?855CSz~p(FVQV{ zKK4QU-RbF@(sS43=CY5D!o|LQTf}5Ax2Tf$$o+aPesued1Us{?vA#(SdmW97;!EB=}_lC=&lB=!RA2HrmNzjMIcE-jDB%cra@TEklktG5(ZzgbxQ4<4+3 zon-r;CEHs{wzrgQZ`oQKZdy5B@DQTcU*^Mo1*rHM+VN@+`VhwOPxnn+dl=YcaU)S0KVUj4UW zb@&^s`&PSgQa5b&PtxwjusZMUy6b*0i=i!&C}X)~e*H*vEL; zq2Poen*@Iw;6-bk|CCsLv*eL%&{nJoHfii)wqA(MI@ny3T|w!RZngN#*t5y`Eqhp< zaEw!g(IqFAEFoqXT?8Mppmi(aHiJ!EJGT><3q4bm!B5%yB7JXq`f>qTe69E<;CMI0 z6%1mI#<4o_T-Y>~aZsF7L-%PUZ@cS;p8tti5ksSmSSn9z=YfITgS_zY;B#_}9A$8v zkH4L`cIY-8)eNsX6v>(-h0=*S~N4t3r?oN{b8_@s58 zMhkkDVsR{ckj-5b4;YT|7VVFfFERx&cjOZ!eJ$?uzOm*c!mJ$PscBk_QSs zI7

9yy|TV2ewP908x4t{lPUfNux?qvo--{waRKR1TFx)z#INm50v@R@ZiE`S1YH z4VW*)ttxeN8%qanv?J{o7WnLo)fEl&@+Y4dpRk`ndb**#jqs}*@~azuesy$6xPgKF zK8EsTDQ-=N(hqdOzp_y8FHQEkRr^#Wn_Vqx_) z!s`3a9;-7Sm>1wD<_R2SnF%9`Hx?Jyodtd-&IBF}d?7f)7AO6vIO)~mq$}sa_2M$I z|AtaLy!6pW9Y2~nwb@j1Vy}{Y!rJ$Sc_H4Z<#&KnD8;QopSnM=?E>VFwC4gkRrapz z?2XC?Veu5Pt%>^}3QBjSN&Bm1H`Cwy4m0oleLoF+jh_rZ7o1;wM9fzkQ&Y02VC558(*`2+WNaf7(FqJ z#5`jACGOs~J$U1ZHx(6ZRouPC6C5XO57_14&tRwavzrELk56mrUk3WR?`QSDU;k(L zvG6>}*Wzn)iH+SKTcV9FmFhGN?mO*g9K(GFGFhS6Nbv3^L^|E0wPWgSMYVB>|yf_(`a8Fpdf*oou7ZpIodd8>)c@gwiyNiZ()-D1zy-MkzCb<(MHU-nVDOj!LEVf7BG30FrY$p({n!QAf56{g>wDjZ*Oy(k^D;0vwpP(@{@HJ8zka=9d-Zyz{F!IER(I{% zIlpt~cG>OP@g2Q}KQ_b?4ex2Ui@IYs0B6IC1f#-{gd2d(!R|xmN}xXEJMiql>~I)5 zcQ&;DC|KPX!uQYAuU@||dH&+X$JLjs3$(>r>ddQW&it&2_8xDdy%3so>2hn6TW`G> zzu#$=Ynq5N-bA@inmqU1peBO`jcqb^Y~&S@ktIsZvuxQe`F2d%Xw5IM4Pw6`2LW+K zuKw~)CTv?8;sJ8IY}Q+9rqZ!nH(~W|!s-tRtKZ?l>URsPKPs%=O<29#Gs*@Ls%6c3@0Ba(qtRdweNt7{^E`Ui2~TlVeSxpSw^-McsM{@QC7yapCmR`$=> z^GO-c#^HxkrgF_=L-W|sJT@kb8fA=PZz!-k+*+~Ld!pxq3E(c`pKZ{0^*$ih0J{i& z3_+}}-MU5VO=PL8RVh_TQi<%w8mKPRzPABny*l(%>y=Vp&H8V`>a62hu5T3O_CCg& z)$D9Fb)8?cf0A}L#OkgQ$y`I`w`p^rP1mj!UH|cqIBTyHQ8d2|RmoS$Q(3AqMFoEf znEmO+Pe0wdpmpmjw)w;ld#8$F@pO?#!t|Ej24`4)Cz*XqwM~wLykuZoFfLdZ`H2tL zGgKOTzXCLSB-bo%r0PR_u4UuGRxOztbgrrYEqfTbxWsw2u{u2F{4M$U#Ca0a#$I9I zDEuJkl8sGXnHHOM6qrJP8EOt4icd)4%Wh7O*@zF&ZfWCKT^5NP^uQ4#j1jNDZYZYK zcnrOYOak^Df}Qye{vSn{bY-r7{k0*!s<9|P-w;RN5ZBHS*UtEg7;9pX;D)+l)7*Dd zM`EkMW`XSjofTUK7!Camp8vVZT3IMB#_o=uhzuZ)GO?GbsRvRkR##NSAB&I2Pbjnd z5d4Pt5Ao%A-ih7##>UG*jThNFwf%6ssI8MNF9KY4FtGHiM#qfq?-@_%U&4Hrt%p54 z#9?L(?v)K-g_0E1=V!y^BVUi7EYr<=u@HLYeptV+S#nU?KP*>}8jkU{d8T}@sU zVv23Pmqcq5!ExmzYS&I-0(VzPciSPo`H=W*I~Dt|OYb%)uTJl*=?-D_5@Gdtw_iO} zSY3RDTU0u!Jgp*{Pq>z)jMX}1zA1zcua9*+s&bdgRVo`*vO+^b)pw!7>Pv;ybA{D6 zoIO^zbLiCfA!G}vZF&5`rahzI4yWZL4 z&O6(*xkCGy8QRaxu=X<}e**cE$(yWg+ANH3<3nOxY<$=tFJ`dnc^VV!3$D*uJHZlm z8^8r{PF%U|cI1&?kXQov{KzIPudhC;jcDXrxkHpJ%f@c|rt$faTN}GF7yy}*k+D0Y zT!9quMv-gq>9MEC)%$mB59ex^YpiviE(me~_nlZw@|EHLwc8#Rc0#cX>vM9~=jMhT z4+~RwjD!0QuK?~lc4uO8ticoH-T^=MsVw}LOOFe;Z)ws5d=9@59C$I~Tzah)+o`q2 zARdpHB!F-P<_e*VVswB3WIwNC=}B*qp#du##7Nqp+EoO|$}FBk3f;^SS< zV9mzo$GR@H?vXC>UAo+#cmMru*SBqZ`+?hUZ?UySi>8~JHpQRb5T|nwpBL+Mjg1|B z2-$)?54;WU0DBm`b8ze8#YYxLMeUA?*9K{ciJR1iwd%uK_2H(Nu(0i6vuE#}J#O5w zap*MIDZolztZtk!R)-H%zc1}|H}21Iag*+uH0eL>wD)+so;}+udRb&L%!_`-(hKyN zefItRrTQEJ*{xl-ZvERSzNwvZpWLO~C)elY<>qe5WuH@G70?&`V>9SO?-4w=%a1}1 z%~U$7^m$WIeP~Z%_4_TXUM9}mBhr`039G;RuD)Ppg89A92URLn;#D~0nzMX`nqIA? z7P>#~@rf~X+u6_Hi>-+xu+3$}Yy>#@vd13QX#MR7GS*kKMZ0c0vE0x~ot>>3K z|NH}~4?J*l<;^$$W}kcCR-fr=jp?7#ukOS1^4MaCjRbcQpRU0Yu$%@~N5_IQgP(_( zwo_q`pBk$Z&*idb8nS2la@8B3W6T%qX2kMgOT%{#-p7}YPrabPCDYDP zFp`rWeTz7x27F=NF6er&Q&?R&vDnAcvVUR+eMa6W;*%QVEY=@Kj4k{lT9Yh3F1%b> zGU4g|SXEV(l5#l3H-|5B9{-)o2f-WBp?Y4UoF~5Vxt1I`85dWvwxU9=uyo7DsNg%x z=K{})JPV9n*PjO-+rGbJ?rD9sunl%&aQ~u3iw)phkA$)Ngu8njBPche2R>OS%6>e8>1RPN%!eUKYn zP&V3*SC_gx%k`_?I(1NKrm|5bOXcgYSx-g7cg3q$39Fw)ygGw{xxk#j*U7u^@W>B{ zM_y4;am>cgimN>pekQKb_g>gjEl&D&-Ancd!Jn|p6<5(pae9Iq@|Crcuk7+sV5Gvr zHHF)4J;-a;}QDMat&Xnb@-n|0pRNi-I53*CA@ej(IqIg$R z@vf$Fl9@yCEk-G6W>L}lq8x3Uja?F+f`;LHFaGw$7azFofd?Lb_(tVm|CRPLGu-=` z9gQp~SXYp}DLb3*`*G*>PW>AdNZv(?Sw_17FO@yzf=FuK5PEjq)9s_g@&#TO-?RP&d+z|m&w1lQoQ?WitGgJ zKc_9fN}SFrXGTVCMqJ$9IBYh^WBjT3zTtG^QziZaE>mDl%kD^1TRxfBk-G(Z7?^*m z2q)~*R$XmYbH=~S=ltI0RR{Lj+xKjECKsqEZ_1f>%Plg$-rQ_Mvu0Nvyy_}AA09n{ z!4~jnI10&WfgO){eQXSppl=A(eTp3n8wU8;;n?gbC|FfcCj0kFc|NLz(dfr2l@;n& zY_;;(q%B#pddZY2Ta~}`@Rx7DeTn+-YWdZ#kzf4}!s@?Hs$5lB8K(srd#KEXgVD3d zW8)d8o;o+|OP6ncPg3bErKOP%Mn)DTgcwxI>_lF zl_wpUiEW#lFZS=q1oL~911jYzF)AFyuyqgZ-o0&p+qPG3lONZ9g?3-*XW}-cepx;= zVhqXgsc@bY;;IxL7BBbD4}$Rjv2s^oOT)jRzd!AQPY|9AIG?<9;C{`yBWg=k)w=VC z)o&J7*U`5}-@fEX{PN4@UrwER!Bli0{1$n6MqYNdkLtH9z z7jBU5+KG4;KYcvteRt@-PThBj?)#4H*E(=mFIRCj+iG{;zB6^-RNZ%t?t8+`HFDSb zEZQ9it7A{m==>w`p6DvH<^A{fy+3Ny>QNyfB_YYl&SWj;`BBcOsHsljbmy>P1Dpc} zyx@G{g-*^+oo-us+ikz!F5A5C;PzJWr}e9!;{Ezo$?y9AL#*x{Z-&O3As&p;nY?kf zztB6D8(tCHK(0vQD%p1(zPmqGH$zW_)$LeViPGK@sUO1aOgf_dn{^~59X$hC>-Q#9 zS?|x-u$gc8NYMx2;2{^B&P~pox||$1vGBz4=@3KfXG5`@t=k1%54H%aOAi6B$`s@B z(TUfhaHf`44nn`$_|`1IHTmb@;ljt2%uq<`cw(Q{%jZb$e_x&$`Kx5Dy{Uc<0NcKU zuLn88UP6NTDL)dQo1oo3@cq~GOL}`NK%MH>pK&go#oBXDSkR7#JS3lQW##(wiq#cP z-jEw}s><5$EXWz3IQdP9v4RgWa-=abB*X|o?%-P|b{#tt`QaJU$RF*1`4)Q~;EN}( z3Vu(yu>;l9Pr3s{KOP%uLL2L|xhL^Xi0{u4dXNqdM z5G-Ny$jMO+_ZRt|oV-u_QLL;;+3nt=>}Owo`Nnl`yz%6{Pd@qFb8Vg*IPhxiZEk#{ z=zQ&Gwq;q-YVFT{I6B()VGH{AgXaJz$)k4%u7>9wdfuz2F{l0m))wr6*gDB+<-6xP z>k)pWOcL-_!GGym^Zd>+U1wunpY%JMzFoq zS!+G@ZLFG}wlus5^k)sfRsR!H+40OjATNjm#=d|q1_p%tUMirTCW8_23)tP^g4x^# z^mo79GxcwMIzMY|wK&DXg%&=SUTNh<78ll#Z^_cz;q1x%VsT@|Ke2dRS_dqf6Y*D) zK__MJ+9Q5St=5Qj;=OFr+}b5RPI2*(bH?h|Yk$(agd5PI`}ON98&oId#cw_-JiH`4 zQyj2L<(u5B*sZ;a|2wStd{l8^N9ET)Ecv)k+{f*TbEy%hrL3${GHpvnWaRNkY|!j` zjXvB{&Puo(;92-7!3HpK8uFgpz<1Fvc~5ZWWQ|rllUZ)_(D1i|iHU{7ckI32>AVov zccj)|K<*^Ho?iwXmjQ3*KMIMXB3CK)1?)hq3D_yH;gLrmJ$*+y@fB-?&$ntHn7xwy zhs2bk|LDiVD*NPL-KIQ9)w0{>YVD1Vc1BN|wtCtZU)=IV|NeLPA2Q@#VfD7c>NlP? zR=4{Syy4%MJJXB)QdG3$NyTsK5d8el6DoJAT&iNISeWz_XTJ9{l^aw#scud zLwY)U5-DrC>;bNrPOuSk5Uk{SU+&ITcLvYxvPza-vbK=Dg)A;%VOrwjWTGk}$BU*w zriNeSllq(tFl2(+?k?pzi>wf)DKo*s>M6qN6~gL!8)9|*1>``%c8z}rKM#42wC&`w z8p&oWRvo>~j;lITpN*PojT&MimAA{|w^3}EmfGa1s#U`3#}XJb^gfmnm<;4+^y@3# zh5S|6TG4%3yWw`x&!wd`rH*Um4{7I`$M|)%bPS(9z4`R$=nJ9~5{!h12qR+QLSx~) zdB!~SBhKhWU~_OhSe-o5PN(6--_Gwxk2Xe=f0I4&$j6CJB<65~{3Z4ry6m8^dX=zx zx(lm|9z7;3EK+ZxRK*b%rgbJv<0MvCy-;i6W?^+ZmyBn+sk4IW=BL1No_p98s(1eB zu=)XE^_9ZvX%<$;4rV#6m)p$_oAv3ZVZvmw!s>;>>YLp*07i!UIKy^Z`@^aKeEsUm zG;tyM1(<8H06)r}mv8{FnT3QL7EZ58&R?FNAH6j?dRpDIX~XM=4ho=PM5QaNn(gYgf=cj{k4_)iq9RTZ~biT#P;{aUxO8?4&~T@So(4!su91c+xR(ST3#tvY|=VBiS*mHq;Q=eTR%OPtfdGpA9f}SXyQ0cl-E2X^0 z#hr}9SHhgPbtLp(*WKJ5+E{-oSD)=y|0%FKUS%rr7rt~BHh zGBS}#aJ<0m;HEFWFqGHb_z33x0R7`~CMD_k-KHzoh(?r{aLQdxPyZ zwXgMU@~S@Y0C8K1Dn%--RTLTd%)9S)6;^*lSpBcU>TAndN=NIgK95r0ca=}AYuAqY zW;2yNDitbeDp6gD^HPX|?Aw9!bKB4NI|mpH_4hp^Rz<317 +xXee?cugl1;Wqd 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

E?8FK>Jxt{Ymp%5F!xha5Ov75Q`I6?% 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

*pfcJs@h-e2JHUn+%D0j}-qA9wGO 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 Pfdc~v1`Z7TpJCu{Q6_T8 literal 0 HcmV?d00001 diff --git a/apps/r3f-hello-world/assets/manifest.json b/apps/r3f-hello-world/assets/manifest.json new file mode 100644 index 00000000..3284f896 --- /dev/null +++ b/apps/r3f-hello-world/assets/manifest.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 0, + "harfBuzzVersion": "14.2.0", + "assets": [ + { + "asset": "inter-latin.font.glb", + "bytes": 2356064, + "sha256": "fc41c275008a3ad10fc3bd2c4b429eb3ce41274016f726c80fbf556510bab31f", + "source": "apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf", + "unicodes": "U+0020-007E", + "outputs": [ + { + "role": "font", + "bytes": 2356064, + "sha256": "fc41c275008a3ad10fc3bd2c4b429eb3ce41274016f726c80fbf556510bab31f" + } + ] + }, + { + "asset": "font-awesome-world.font.glb", + "bytes": 184948, + "sha256": "d439d85fba5852b47f3638b36a33a81535f5620ca8d3fd01edddba6cd114bea5", + "source": "apps/benchmarks/fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf", + "unicodes": "U+E47B,U+F0AC,U+F57C,U+F57D,U+F57E,U+F7A2", + "outputs": [ + { + "role": "font", + "bytes": 184948, + "sha256": "d439d85fba5852b47f3638b36a33a81535f5620ca8d3fd01edddba6cd114bea5" + } + ] + } + ] +} diff --git a/apps/r3f-hello-world/index.html b/apps/r3f-hello-world/index.html new file mode 100644 index 00000000..bf0484f6 --- /dev/null +++ b/apps/r3f-hello-world/index.html @@ -0,0 +1,13 @@ + + + + + + + @pmndrs/text · R3F hello world + + +

+ + + diff --git a/apps/r3f-hello-world/package.json b/apps/r3f-hello-world/package.json new file mode 100644 index 00000000..c36dfa83 --- /dev/null +++ b/apps/r3f-hello-world/package.json @@ -0,0 +1,37 @@ +{ + "name": "@pmndrs/text-r3f-hello-world", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "assets:generate": "node ./scripts/generate-fonts.mts", + "assets:check": "node ./scripts/generate-fonts.mts --check", + "build": "vite build", + "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm assets:check && pnpm build", + "dev": "vite", + "format:check": "oxfmt --check .", + "lint": "oxlint --deny-warnings .", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@pmndrs/text": "workspace:*", + "@react-three/fiber": "10.0.0-alpha.2", + "react": "19.2.8", + "react-dom": "19.2.8", + "three": "0.185.1" + }, + "devDependencies": { + "@babel/core": "8.0.1", + "@rolldown/plugin-babel": "0.2.3", + "@types/node": "24.13.3", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@types/three": "0.185.1", + "@vitejs/plugin-react": "6.0.1", + "babel-plugin-react-compiler": "1.0.0", + "oxfmt": "0.35.0", + "oxlint": "1.75.0", + "typescript": "7.0.2", + "vite": "8.1.5" + } +} diff --git a/apps/r3f-hello-world/scripts/generate-fonts.mts b/apps/r3f-hello-world/scripts/generate-fonts.mts new file mode 100644 index 00000000..b8e11b1b --- /dev/null +++ b/apps/r3f-hello-world/scripts/generate-fonts.mts @@ -0,0 +1,104 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import { bitmapBaker } from '@pmndrs/text/bakers/bitmap'; +import { msdfBaker } from '@pmndrs/text/bakers/msdf'; +import { slugBaker } from '@pmndrs/text/bakers/slug'; +import { bakeFont } from '@pmndrs/text/bake'; + +const run = promisify(execFile); +const ROOT = resolve(import.meta.dirname, '../../..'); +const ASSETS = resolve(import.meta.dirname, '../assets'); +const HARFBUZZ_VERSION = '14.2.0'; +const BASIC_LATIN = 'U+0020-007E'; +const WORLD_ICONS = ['U+E47B', 'U+F0AC', 'U+F57C', 'U+F57D', 'U+F57E', 'U+F7A2']; +const check = process.argv.includes('--check'); + +const sources = [ + { + input: resolve(ROOT, 'apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf'), + name: 'inter-latin', + unicodes: BASIC_LATIN, + }, + { + input: resolve(ROOT, 'apps/benchmarks/fixtures/fonts/font-awesome-free-6.7.2/fa-solid-900.ttf'), + name: 'font-awesome-world', + unicodes: WORLD_ICONS.join(','), + }, +] as const; + +const temporaryDirectory = await mkdtemp(join(tmpdir(), 'pmndrs-text-r3f-example-')); +try { + await assertHarfBuzzVersion(); + const generatedAssets = check ? join(temporaryDirectory, 'assets') : ASSETS; + await mkdir(generatedAssets, { recursive: true }); + const manifest = []; + for (const source of sources) { + const subset = join(temporaryDirectory, `${source.name}.ttf`); + await run('hb-subset', [source.input, `--unicodes=${source.unicodes}`, `--output-file=${subset}`]); + const asset = `${source.name}.font.glb`; + const output = resolve(generatedAssets, asset); + const report = await bakeFont({ + input: subset, + output, + font: { fontFaceIndex: 0 }, + rasters: [ + { + baker: bitmapBaker, + packaging: { artifact: 'embedded', pages: 'embedded' }, + options: { strikes: [32] }, + }, + { + baker: msdfBaker, + packaging: { artifact: 'embedded', pages: 'embedded' }, + }, + { + baker: slugBaker, + packaging: { artifact: 'embedded', pages: 'embedded' }, + }, + ], + }); + const bytes = await readFile(output); + manifest.push({ + asset, + bytes: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + source: source.input.slice(ROOT.length + 1), + unicodes: source.unicodes, + outputs: report.execution.outputs.map(({ role, bytes: outputBytes, sha256 }) => ({ + role, + bytes: outputBytes, + sha256, + })), + }); + if (check && !(await readFile(resolve(ASSETS, asset))).equals(bytes)) { + throw new Error(`${asset} is not byte-identical to a fresh authenticated subset bake`); + } + } + const manifestText = `${JSON.stringify( + { schemaVersion: 0, harfBuzzVersion: HARFBUZZ_VERSION, assets: manifest }, + undefined, + 2, + )}\n`; + if (check) { + if ((await readFile(resolve(ASSETS, 'manifest.json'), 'utf8')) !== manifestText) { + throw new Error('R3F example font manifest is stale'); + } + } else { + await writeFile(resolve(ASSETS, 'manifest.json'), manifestText); + } +} finally { + await rm(temporaryDirectory, { force: true, recursive: true }); +} + +async function assertHarfBuzzVersion(): Promise { + const { stdout } = await run('hb-subset', ['--version']); + const version = stdout.trim().match(/\d+\.\d+\.\d+$/u)?.[0]; + if (version !== HARFBUZZ_VERSION) { + throw new Error(`R3F example assets require hb-subset ${HARFBUZZ_VERSION}; received ${String(version)}`); + } +} diff --git a/apps/r3f-hello-world/src/app.tsx b/apps/r3f-hello-world/src/app.tsx new file mode 100644 index 00000000..027a0ddd --- /dev/null +++ b/apps/r3f-hello-world/src/app.tsx @@ -0,0 +1,22 @@ +import { Canvas } from '@react-three/fiber/webgpu'; +import { Suspense, useState } from 'react'; + +import { TechniqueScene, type Technique } from './technique-scene'; + +export function App() { + const [technique, setTechnique] = useState('msdf'); + + return ( + WebGPU or WebGL2 is required.} + flat + orthographic + > + + + + + + ); +} diff --git a/apps/r3f-hello-world/src/main.tsx b/apps/r3f-hello-world/src/main.tsx new file mode 100644 index 00000000..bc5968d1 --- /dev/null +++ b/apps/r3f-hello-world/src/main.tsx @@ -0,0 +1,23 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import shaperWasmUrl from '@pmndrs/text/text-shaper.wasm?url'; + +import { App } from './app'; +import './styles.css'; + +const root = document.querySelector('#root'); +if (root === null) throw new Error('R3F hello-world root is missing'); + +const shaperPreload = document.createElement('link'); +shaperPreload.rel = 'preload'; +shaperPreload.as = 'fetch'; +shaperPreload.crossOrigin = 'anonymous'; +shaperPreload.href = shaperWasmUrl; +document.head.append(shaperPreload); + +createRoot(root).render( + + + , +); diff --git a/apps/r3f-hello-world/src/styles.css b/apps/r3f-hello-world/src/styles.css new file mode 100644 index 00000000..704f5c62 --- /dev/null +++ b/apps/r3f-hello-world/src/styles.css @@ -0,0 +1,29 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, system-ui, sans-serif; + background: #07090f; +} + +html, +body, +#root { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; +} + +canvas { + display: block; + width: 100%; + height: 100%; + touch-action: none; +} + +.fallback { + display: grid; + width: 100%; + height: 100%; + place-items: center; + color: #d7deef; +} diff --git a/apps/r3f-hello-world/src/technique-scene.tsx b/apps/r3f-hello-world/src/technique-scene.tsx new file mode 100644 index 00000000..ec4294f2 --- /dev/null +++ b/apps/r3f-hello-world/src/technique-scene.tsx @@ -0,0 +1,152 @@ +import { createFontStack, type FontSelection, type LoadedFont } from '@pmndrs/text'; +import { Text, useFont } from '@pmndrs/text/r3f'; +import { bitmap } from '@pmndrs/text/three/bitmap'; +import { msdf } from '@pmndrs/text/three/msdf'; +import { slug } from '@pmndrs/text/three/slug'; +import { useThree, type ThreeEvent } from '@react-three/fiber/webgpu'; +import { useMemo } from 'react'; + +import iconFontUrl from '../assets/font-awesome-world.font.glb?url'; +import latinFontUrl from '../assets/inter-latin.font.glb?url'; + +export type Technique = 'bitmap' | 'msdf' | 'slug'; + +interface TechniqueSceneProps { + readonly onTechniqueChange: (technique: Technique) => void; + readonly technique: Technique; +} + +const WORLD_ICON = '\uf0ac'; +const BitmapText = Text; +const MsdfText = Text; +const SlugText = Text; + +const bitmapLatinRequest = { + input: { baked: latinFontUrl }, + raster: { technique: bitmap, options: { strikes: [32] } }, +} as const; +const bitmapIconRequest = { + input: { baked: iconFontUrl }, + raster: { technique: bitmap, options: { strikes: [32] } }, +} as const; +const msdfLatinRequest = { + input: { baked: latinFontUrl }, + raster: { technique: msdf }, +} as const; +const msdfIconRequest = { + input: { baked: iconFontUrl }, + raster: { technique: msdf }, +} as const; +const slugLatinRequest = { + input: { baked: latinFontUrl }, + raster: { technique: slug }, +} as const; +const slugIconRequest = { + input: { baked: iconFontUrl }, + raster: { technique: slug }, +} as const; + +export function TechniqueScene({ onTechniqueChange, technique }: TechniqueSceneProps) { + const viewport = useThree((state) => state.viewport); + const buttonFont = useFont(msdfLatinRequest); + + return ( + + + + + ); +} + +function TechniqueCopy({ technique }: { readonly technique: Technique }) { + switch (technique) { + case 'bitmap': + return ; + case 'msdf': + return ; + case 'slug': + return ; + } +} + +function BitmapCopy() { + const latin = useFont(bitmapLatinRequest); + const icons = useFont(bitmapIconRequest); + const font = useMemo(() => createFontStack(latin, icons), [icons, latin]); + return ; +} + +function MsdfCopy() { + const latin = useFont(msdfLatinRequest); + const icons = useFont(msdfIconRequest); + const font = useMemo(() => createFontStack(latin, icons), [icons, latin]); + return ; +} + +function SlugCopy() { + const latin = useFont(slugLatinRequest); + const icons = useFont(slugIconRequest); + const font = useMemo(() => createFontStack(latin, icons), [icons, latin]); + return ; +} + +function Copy({ + TextComponent, + font, +}: { + readonly TextComponent: typeof Text; + readonly font: FontSelection; +}) { + return ( + + Hello world {WORLD_ICON} + + ); +} + +function TechniqueButtons({ + font, + onTechniqueChange, + selected, +}: { + readonly font: LoadedFont; + readonly onTechniqueChange: (technique: Technique) => void; + readonly selected: Technique; +}) { + return ( + + {(['bitmap', 'msdf', 'slug'] as const).map((technique, index) => ( + + ) => { + event.stopPropagation(); + onTechniqueChange(technique); + }} + onPointerEnter={() => { + document.body.style.cursor = 'pointer'; + }} + onPointerLeave={() => { + document.body.style.cursor = 'default'; + }} + position={[48, 0, -1]} + > + + + + + {technique.toUpperCase()} + + + ))} + + ); +} diff --git a/apps/r3f-hello-world/tsconfig.json b/apps/r3f-hello-world/tsconfig.json new file mode 100644 index 00000000..486ab780 --- /dev/null +++ b/apps/r3f-hello-world/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": false, + "declaration": false, + "declarationMap": false, + "isolatedDeclarations": false, + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2025"], + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/r3f-hello-world/vite.config.ts b/apps/r3f-hello-world/vite.config.ts new file mode 100644 index 00000000..bf1b0298 --- /dev/null +++ b/apps/r3f-hello-world/vite.config.ts @@ -0,0 +1,40 @@ +import babel from '@rolldown/plugin-babel'; +import react, { reactCompilerPreset } from '@vitejs/plugin-react'; +import { readFile } from 'node:fs/promises'; +import { defineConfig } from 'vite'; + +const CROSS_ORIGIN_ISOLATION_HEADERS = { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin', +} as const; +const FONT_LICENSES = [ + { + name: 'Inter 4.1', + url: new URL('../benchmarks/fixtures/fonts/inter-v4.1/LICENSE.txt', import.meta.url), + }, + { + name: 'Font Awesome Free 6.7.2', + url: new URL('../benchmarks/fixtures/fonts/font-awesome-free-6.7.2/LICENSE.txt', import.meta.url), + }, +] as const; + +export default defineConfig({ + plugins: [ + react(), + babel({ presets: [reactCompilerPreset()] }), + { + name: 'font-notices', + async generateBundle() { + const notices = await Promise.all( + FONT_LICENSES.map( + async ({ name, url }) => `${name}\n${'='.repeat(name.length)}\n\n${await readFile(url, 'utf8')}`, + ), + ); + this.emitFile({ type: 'asset', fileName: 'font-notices.txt', source: notices.join('\n\n') }); + }, + }, + ], + build: { target: 'es2022' }, + preview: { headers: CROSS_ORIGIN_ISOLATION_HEADERS }, + server: { headers: CROSS_ORIGIN_ISOLATION_HEADERS }, +}); diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 17f4b35a..ad1f678a 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:c86eb603cccf0cc45344709437c37a6dde1636261ae69d32d0c75fe8fa1e1dec' +source_digest: 'sha256:0f98ea232e8b9e340430af5cfbe24caf5cddf2f76c1d7e3d2822bc9e21b82e0b' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/font-baker.md b/docs/packages/font-baker.md index f61b26c3..727b65a6 100644 --- a/docs/packages/font-baker.md +++ b/docs/packages/font-baker.md @@ -5,7 +5,7 @@ description: Implements the internal portable Rust/Wasm shaping-resource bake co resource: ../../packages/font-baker workspace_package: '@pmndrs/text-font-baker' documentation_type: reference -source_digest: 'sha256:80fb2bd14e5b3ad0f8f9e87bd455057998c0a1c7f9e5acda51b45815857d480c' +source_digest: 'sha256:d85b56b06a26945e220248f487e87465027376929238e1a8206a78c5880d1700' tags: [package, rust, wasm, baking, internal] sources: - id: manifest diff --git a/docs/packages/index.md b/docs/packages/index.md index afe49be6..fa62704a 100644 --- a/docs/packages/index.md +++ b/docs/packages/index.md @@ -3,5 +3,6 @@ - [`@pmndrs/text`](text.md) — public loading, baking, HarfRust shaping, paragraph layout, static discovery, and portable bitmap artifact core. - [`@pmndrs/text-font-baker`](font-baker.md) — internal portable Rust/Wasm bake core. - [`@pmndrs/text-benchmarks`](benchmarks.md) — Figma-backed benchmark and product-verification application. +- [`@pmndrs/text-r3f-hello-world`](r3f-hello-world.md) — minimal public R3F Bitmap, MSDF, Slug, and fallback example. Each package concept carries a deterministic `source_digest`. Repository validation fails when package source changes without a corresponding concept review and digest refresh. diff --git a/docs/packages/r3f-hello-world.md b/docs/packages/r3f-hello-world.md new file mode 100644 index 00000000..903fd3b9 --- /dev/null +++ b/docs/packages/r3f-hello-world.md @@ -0,0 +1,55 @@ +--- +type: Workspace Package +title: '@pmndrs/text-r3f-hello-world' +description: Demonstrates the public React Three Fiber API with Bitmap, MSDF, Slug, and font-stack fallback. +resource: ../../apps/r3f-hello-world +workspace_package: '@pmndrs/text-r3f-hello-world' +documentation_type: reference +source_digest: 'sha256:44849706fcd065b4c335e90265bc73fe89c8ef0f96407d93a2d2ddb88773b35c' +tags: [package, example, react, react-three-fiber, vite] +sources: + - id: manifest + resource: ../../apps/r3f-hello-world/package.json + title: Example application manifest + - id: scene + resource: ../../apps/r3f-hello-world/src/technique-scene.tsx + title: Public R3F technique and fallback example + - id: asset-generator + resource: ../../apps/r3f-hello-world/scripts/generate-fonts.mts + title: Reproducible subset and multi-technique bake + - id: asset-manifest + resource: ../../apps/r3f-hello-world/assets/manifest.json + title: Authenticated checked-in example assets +generated: + by: openai-codex/gpt-5.6 + at: '2026-08-09T18:38:19Z' +--- + +# Package reference: `@pmndrs/text-r3f-hello-world` + +This private Vite application is the minimal product-shaped React Three Fiber example. One full-page canvas renders +`Hello world` through the public `@pmndrs/text/r3f` `Text` component and resolves a Font Awesome globe through an ordered +font stack. In-canvas MSDF controls replace the rendered text component between Bitmap, MSDF, and Slug; the example does +not retain a second renderer path or manually pack glyph data. + +The checked-in assets are deliberately bounded at source before baking: + +- Inter contains Basic Latin `U+0020–U+007E`. +- Font Awesome contains six globe and earth PUA scalars, including the displayed `U+F0AC` glyph. + +Each GLB embeds Bitmap, MSDF, and Slug raster resources for its subset. The manifest authenticates the exact artifacts, +and `assets:check` uses pinned HarfBuzz 14.2.0 to subset and rebake both fonts in temporary storage before requiring +byte-identical output. Vite emits the public shaper Wasm URL and a combined Inter/Font Awesome notice file. Three, React, +and React Three Fiber remain ordinary workspace peers rather than part of the core package-size graph. + +## Commands + +```sh +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world dev +mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world check +``` + +The check runs TypeScript 7 isolated typechecking, Oxlint with warnings denied, Oxfmt, deterministic asset rebaking, and a +production Vite build. Live acceptance additionally clicks all three in-canvas controls on a real WebGPU canvas and +requires thirteen rendered glyphs, zero missing glyphs, and two Rust-planned meshes: one for Latin and one for the icon +fallback resource. diff --git a/docs/packages/text.md b/docs/packages/text.md index 532817b0..59deabf8 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:13d1410aa9a8d392875c94b0d5360e7ab1c7769e6cfbee8d45a998410b6b154f' +source_digest: 'sha256:fa847f49ec81019df13d7d0bae626a7f4753d73d6860227c099c1ed99e4c0385' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eff28ad0..9a7070e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,61 @@ importers: specifier: 0.1.17 version: 0.1.17(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + apps/r3f-hello-world: + dependencies: + '@pmndrs/text': + specifier: workspace:* + version: link:../../packages/text + '@react-three/fiber': + specifier: 10.0.0-alpha.2 + version: 10.0.0-alpha.2(patch_hash=0f6d2085306bbd109ac98a3051bebfeff94f5c97b04d32917f436bb4a9498ef8)(@types/react@19.2.14)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(three@0.185.1) + react: + specifier: 19.2.8 + version: 19.2.8 + react-dom: + specifier: 19.2.8 + version: 19.2.8(react@19.2.8) + three: + specifier: 0.185.1 + version: 0.185.1 + devDependencies: + '@babel/core': + specifier: 8.0.1 + version: 8.0.1 + '@rolldown/plugin-babel': + specifier: 0.2.3 + version: 0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + '@types/node': + specifier: 24.13.3 + version: 24.13.3 + '@types/react': + specifier: 19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@types/three': + specifier: 0.185.1 + version: 0.185.1(patch_hash=a5d0add8a1d5e65aab9c242df680720a8962e6d8970c0e1a2c69bc66983f9010) + '@vitejs/plugin-react': + specifier: 6.0.1 + version: 6.0.1(@rolldown/plugin-babel@0.2.3(@babel/core@8.0.1)(@babel/runtime@7.29.7)(rolldown@1.1.5)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 + oxfmt: + specifier: 0.35.0 + version: 0.35.0 + oxlint: + specifier: 1.75.0 + version: 1.75.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.1.5 + version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + packages/font-baker: dependencies: ajv: From ad0795a8f4394c345c176804d586f1f2c344f6c3 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 14:58:35 -0400 Subject: [PATCH 097/128] chore(size): restore complete baker reports --- apps/benchmarks/size-entries/bitmap-baker.ts | 2 +- apps/benchmarks/size-entries/mtsdf-baker.ts | 2 +- apps/benchmarks/size-entries/slug-baker.ts | 2 +- .../src/generated/package-sizes.json | 222 +++++++++--------- docs/log.md | 8 + docs/packages/text.md | 11 +- 6 files changed, 130 insertions(+), 117 deletions(-) diff --git a/apps/benchmarks/size-entries/bitmap-baker.ts b/apps/benchmarks/size-entries/bitmap-baker.ts index 01995897..b6c384a8 100644 --- a/apps/benchmarks/size-entries/bitmap-baker.ts +++ b/apps/benchmarks/size-entries/bitmap-baker.ts @@ -1,6 +1,6 @@ export { + bitmapBakerAbi, createBitmapBaker, createBitmapBakerFromInstance, bitmapBakerFromCore, - readBitmapBakerAbi, } from '@pmndrs/text/bakers/bitmap'; diff --git a/apps/benchmarks/size-entries/mtsdf-baker.ts b/apps/benchmarks/size-entries/mtsdf-baker.ts index 19b7442a..c9af6245 100644 --- a/apps/benchmarks/size-entries/mtsdf-baker.ts +++ b/apps/benchmarks/size-entries/mtsdf-baker.ts @@ -1,6 +1,6 @@ export { createMsdfBaker, createMsdfBakerFromInstance, + msdfBakerAbi, msdfBakerFromCore, - readMsdfBakerAbi, } from '@pmndrs/text/bakers/msdf'; diff --git a/apps/benchmarks/size-entries/slug-baker.ts b/apps/benchmarks/size-entries/slug-baker.ts index 542cd12a..cf983517 100644 --- a/apps/benchmarks/size-entries/slug-baker.ts +++ b/apps/benchmarks/size-entries/slug-baker.ts @@ -1 +1 @@ -export { createSlugBaker, createSlugBakerFromInstance, readSlugBakerAbi, slugBaker } from '@pmndrs/text/bakers/slug'; +export { createSlugBaker, createSlugBakerFromInstance, slugBaker, slugBakerAbi } from '@pmndrs/text/bakers/slug'; diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 82b0242a..36730eac 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,55 +10,55 @@ "label": "Renderer-neutral core JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "b1b6828d3006773a5e6d3604860e79b7fcad6772081499bc679383bc25fe26b4", - "rawBytes": 98060, - "minifiedBytes": 70089, - "gzipBytes": 18840, - "brotliBytes": 16532 + "sha256": "fb01be01980997372c14749801f751b6e59590210a22593b61eac0b7a9c7e43b", + "rawBytes": 98156, + "minifiedBytes": 70185, + "gzipBytes": 18847, + "brotliBytes": 16527 }, { "id": "text-shaper-wasm", "label": "Text engine Wasm", "status": "measured", "format": "wasm", - "sha256": "97d37df44b1a76c36eb301925183cc9646625d42216647dec4536ef01ca085c9", - "rawBytes": 1113113, - "minifiedBytes": 1113113, - "gzipBytes": 422035, - "brotliBytes": 333171 + "sha256": "7f31107c429517908fdab679a28f2234818ecac1cfb2353e98ed0e07a893f1d6", + "rawBytes": 1126383, + "minifiedBytes": 1126383, + "gzipBytes": 428274, + "brotliBytes": 337459 }, { "id": "renderer-neutral-core-total", "label": "Renderer-neutral core total (JS + Wasm)", "status": "measured", "format": "aggregate", - "sha256": "d2fdf4e60388b0ca670c70342d72c17d85f61a0f06cb9f9e6ba63e06cefeb89e", - "rawBytes": 1211173, - "minifiedBytes": 1183202, - "gzipBytes": 440875, - "brotliBytes": 349703 + "sha256": "37fdecb656bd7faf8f317bd96b8ca6bf4fe2ec31c156d034adce52a57b8874b7", + "rawBytes": 1224539, + "minifiedBytes": 1196568, + "gzipBytes": 447121, + "brotliBytes": 353986 }, { "id": "three-runtime-js", "label": "Complete Three adapter JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "f09fad5d5fe7629dbc7ef879c9229302e70e3aa43e57abf694fe4d588eb18d31", - "rawBytes": 341448, - "minifiedBytes": 224927, - "gzipBytes": 57828, - "brotliBytes": 48726 + "sha256": "d04f4b48331c2c96c8d1e419f56cfda2ed1b2aba41fbbf8443706d65a290f6b3", + "rawBytes": 340067, + "minifiedBytes": 224436, + "gzipBytes": 57590, + "brotliBytes": 48471 }, { "id": "three-renderer-total", "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)", "status": "measured", "format": "aggregate", - "sha256": "5ace817d61ab3b90e7ccd1bd025e2eab288d1acca8c7a393c6c7a470ddb54bf7", - "rawBytes": 1454561, - "minifiedBytes": 1338040, - "gzipBytes": 479863, - "brotliBytes": 381897 + "sha256": "4b14680e26d2453d265c84995ecf9c3a019616f5ed06d72427154b6b55c553ef", + "rawBytes": 1466450, + "minifiedBytes": 1350819, + "gzipBytes": 485864, + "brotliBytes": 385930 }, { "id": "font-inter-bitmap-16-32", @@ -131,66 +131,66 @@ "label": "Three + engine + Inter Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "6ef02616975dfc861bde87d59f54ce34b639a4e28a5b1215adc1a204b692ee3e", - "rawBytes": 4604209, - "minifiedBytes": 4487688, - "gzipBytes": 1038171, - "brotliBytes": 802487 + "sha256": "6bc20871902e1aa07ba9def761beacb06ff9ee8b3204759894be373ddd0366ce", + "rawBytes": 4616098, + "minifiedBytes": 4500467, + "gzipBytes": 1044172, + "brotliBytes": 806520 }, { "id": "delivery-three-inter-mtsdf", "label": "Three + engine + Inter MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "50892699abb2c95bf691b633564b797dd2f255fbcddf14ccc81d6e37f11f2802", - "rawBytes": 40802273, - "minifiedBytes": 40685752, - "gzipBytes": 7278275, - "brotliBytes": 3622269 + "sha256": "b266591a454b9b69794bf60e2e9c62ef17b0ba4b61ed7f8c76a259646ca30cd7", + "rawBytes": 40814162, + "minifiedBytes": 40698531, + "gzipBytes": 7284276, + "brotliBytes": 3626302 }, { "id": "delivery-three-inter-slug", "label": "Three + engine + Inter Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "b37dc9de30811fca3f18cba6834bea6c75814405c89d6372fffa0c6eba010cb6", - "rawBytes": 4899477, - "minifiedBytes": 4782956, - "gzipBytes": 1098350, - "brotliBytes": 791932 + "sha256": "e1f7ea599e497bb36fe82d9922f4eff257942afd8b2eee0dc46b43afad5dcb16", + "rawBytes": 4911366, + "minifiedBytes": 4795735, + "gzipBytes": 1104351, + "brotliBytes": 795965 }, { "id": "delivery-three-icons-bitmap", "label": "Three + engine + Font Awesome Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "371206907c05d52db0a8fe3157edf276217bc10c9e74311843507062c6e57e60", - "rawBytes": 3836293, - "minifiedBytes": 3719772, - "gzipBytes": 929910, - "brotliBytes": 737446 + "sha256": "d2eaf07d4ed7a489803d6f092004bd7aa6e817099d36629da70954919bdfe3ce", + "rawBytes": 3848182, + "minifiedBytes": 3732551, + "gzipBytes": 935911, + "brotliBytes": 741479 }, { "id": "delivery-three-icons-mtsdf", "label": "Three + engine + Font Awesome MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "1993fd4f13d8fa9fc1199d5bd446f5757db4694303e93085de85f8a42bc79804", - "rawBytes": 34035461, - "minifiedBytes": 33918940, - "gzipBytes": 7707687, - "brotliBytes": 3706800 + "sha256": "0b655715eaf050247809441da3b7bee444f2c78952661a81046b097b839d54ee", + "rawBytes": 34047350, + "minifiedBytes": 33931719, + "gzipBytes": 7713688, + "brotliBytes": 3710833 }, { "id": "delivery-three-icons-slug", "label": "Three + engine + Font Awesome Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "7919cf075ff6239b6a982f8e537763b02ac1a8401feb77d8ae097f192a85e178", - "rawBytes": 4399973, - "minifiedBytes": 4283452, - "gzipBytes": 1137875, - "brotliBytes": 866895 + "sha256": "b3d7564d5b35e5d9548c9f2eee43ae23213dd59b2615a7c6413569cd5c8ab625", + "rawBytes": 4411862, + "minifiedBytes": 4296231, + "gzipBytes": 1143876, + "brotliBytes": 870928 }, { "id": "font-validator-js", @@ -198,7 +198,7 @@ "status": "measured", "format": "javascript", "sha256": "acdb803764e9ab7c5b7f4d070a0065d9354d4206b0cc5a52ceb1eb6acb306552", - "rawBytes": 740357, + "rawBytes": 740645, "minifiedBytes": 584479, "gzipBytes": 137637, "brotliBytes": 112898 @@ -208,55 +208,55 @@ "label": "Runtime baker host JS", "status": "measured", "format": "javascript", - "sha256": "7063bed30a6b2bd08863bd05e411241f08245988e4fe36ce0c1032bf390641d0", - "rawBytes": 11395, - "minifiedBytes": 9482, - "gzipBytes": 3820, - "brotliBytes": 3456 + "sha256": "1c2517a9ac99ebe7c73791cdf28761693602f34e0f4e90232dc2ccf746351f1e", + "rawBytes": 11437, + "minifiedBytes": 9524, + "gzipBytes": 3826, + "brotliBytes": 3435 }, { "id": "runtime-baker-worker-js", "label": "Runtime baker Worker JS", "status": "measured", "format": "javascript", - "sha256": "5a418bcb13438cacc81e69ae9550cb03a4a2d467ffb381d07f021675e122f2ec", - "rawBytes": 12854, - "minifiedBytes": 8888, - "gzipBytes": 2997, - "brotliBytes": 2667 + "sha256": "31f464e98aade52c1c0814a77b563d018364269769db4220f0e21a805f980bb3", + "rawBytes": 12777, + "minifiedBytes": 8880, + "gzipBytes": 2982, + "brotliBytes": 2644 }, { "id": "bitmap-runtime-js", "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "4e92a363a41952e152366c1443093c59522bbe50e6d2794c49b8bb69e2accc67", - "rawBytes": 330985, - "minifiedBytes": 218147, - "gzipBytes": 55848, - "brotliBytes": 47080 + "sha256": "c1d113645cea47473d6f3c6351263ea4547a1bdbbbd23f5154dfe44908bd1fe0", + "rawBytes": 330120, + "minifiedBytes": 217861, + "gzipBytes": 55713, + "brotliBytes": 46918 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "905e8d39ef96ed4f95a91e7169be7df84dd291b7e22849a2e84c533fb54bc9b4", - "rawBytes": 330981, - "minifiedBytes": 218211, - "gzipBytes": 55841, - "brotliBytes": 47117 + "sha256": "9a40a98b32dbff7512311811836c1de50e49404a461a5cbbfeae80d8fc55ca70", + "rawBytes": 330116, + "minifiedBytes": 217932, + "gzipBytes": 55705, + "brotliBytes": 46922 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "bdc057fb140338e6fb848d19cf8487771529a07f7fb541638c1a9becc1035eac", - "rawBytes": 330983, - "minifiedBytes": 218139, - "gzipBytes": 55850, - "brotliBytes": 47027 + "sha256": "86c04a6e441fef872b724433163352c01a53130d507ab22be61a0303b7d43da8", + "rawBytes": 330118, + "minifiedBytes": 217854, + "gzipBytes": 55684, + "brotliBytes": 46900 }, { "id": "bitmap-baker-wasm", @@ -274,22 +274,22 @@ "label": "Bitmap fixed baker host JS", "status": "measured", "format": "javascript", - "sha256": "5c2be1d0f852ded18c1ea3d3f5e4488c3fa7713747a5d5420439e374c03e949a", - "rawBytes": 23080, - "minifiedBytes": 15597, - "gzipBytes": 4780, - "brotliBytes": 4240 + "sha256": "1cf2e408a937594f0a460ff48bd72751ccf229b072e07e6556973493502b1531", + "rawBytes": 23026, + "minifiedBytes": 15585, + "gzipBytes": 4773, + "brotliBytes": 4233 }, { "id": "mtsdf-generator-js", "label": "MSDF generator host JS", "status": "measured", "format": "javascript", - "sha256": "c2b14eae4a888665b1565c86c2daf1fef2850fb5f99ba80b5885b4317dba4356", - "rawBytes": 11531, - "minifiedBytes": 8460, - "gzipBytes": 2653, - "brotliBytes": 2360 + "sha256": "e750b13e84c567ab5fb2c875b66432e5a48fa48a8310ec760817616ba5b8b060", + "rawBytes": 11374, + "minifiedBytes": 8418, + "gzipBytes": 2657, + "brotliBytes": 2357 }, { "id": "mtsdf-generator-wasm", @@ -318,11 +318,11 @@ "label": "MSDF fixed baker host JS", "status": "measured", "format": "javascript", - "sha256": "a10639994ebe2d90064d71cd51e8a01c124ba6779c7a288e8d5cd4f0427d98a9", - "rawBytes": 26894, - "minifiedBytes": 19088, - "gzipBytes": 5527, - "brotliBytes": 4903 + "sha256": "b12564a24fab591641209abd8d98a141e05b7fb9dbd1723caa0fec4517bbf9b4", + "rawBytes": 26861, + "minifiedBytes": 19076, + "gzipBytes": 5522, + "brotliBytes": 4901 }, { "id": "slug-baker-wasm", @@ -340,22 +340,22 @@ "label": "Slug fixed baker host JS", "status": "measured", "format": "javascript", - "sha256": "97414de8d9d2bf608d20b22fb4b1e18b03ced1c7d798cf7b237ca603f9231bb7", - "rawBytes": 18691, - "minifiedBytes": 12889, - "gzipBytes": 4124, - "brotliBytes": 3674 + "sha256": "f8f145266df728d067218477258eb9f19dba063f1fe22064d529099f77de731e", + "rawBytes": 18641, + "minifiedBytes": 12877, + "gzipBytes": 4116, + "brotliBytes": 3667 }, { "id": "portable-baker-js", "label": "Portable baker JS", "status": "measured", "format": "javascript", - "sha256": "27e96b24ffcc374bc0cbcc1102bbc72a3c2d04fc294cd8cf8c7ccce8b88946a5", - "rawBytes": 8994, - "minifiedBytes": 6071, - "gzipBytes": 2170, - "brotliBytes": 1934 + "sha256": "ac0207d7b5092cb2ded432094535f5f44a65884ae0c9ef8bb397adb793b99a09", + "rawBytes": 8877, + "minifiedBytes": 6017, + "gzipBytes": 2157, + "brotliBytes": 1913 }, { "id": "portable-baker-wasm", @@ -373,11 +373,11 @@ "label": "Unicode 17 analysis JS", "status": "measured", "format": "javascript", - "sha256": "7d9c59e19c774fe61109c88315feb1e6614957f7db0f212462b2206fb6386a62", - "rawBytes": 167712, - "minifiedBytes": 141103, - "gzipBytes": 42399, - "brotliBytes": 31307 + "sha256": "7b4320ddbb5d713a92337daa13f762ef9f56ba3e2bb0ffd3ef2354a702d8a1d7", + "rawBytes": 167796, + "minifiedBytes": 141127, + "gzipBytes": 42406, + "brotliBytes": 31287 } ] } diff --git a/docs/log.md b/docs/log.md index ce8011a2..525430f9 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Regenerated package-size truth after isolating baker build variants** — The renderer-neutral browser core plus the + sole published SIMD shaper measures 1,224,539 raw / 447,121 gzip / 353,986 Brotli bytes; the Three adapter plus that + core measures 1,466,450 / 485,864 / 385,930 bytes. Optional Three, React, and React Three Fiber peers remain excluded. + Browser-core JavaScript stays effectively flat against the preceding record and the Three adapter shrinks, while the + shaper accounts for the net compressed growth. The complete MTSDF baker is again 552,025 raw / 215,030 gzip / 168,758 + Brotli bytes after separating its 60,993-byte kernel-only Cargo target, and build-time ABI guards now prevent a partial + test module from being published as a baker. + - **Prevented test-only Wasm variants from entering published baker artifacts** — Distributable MTSDF and Slug artifact-baker builds and the optional SIMD compatibility switch now use feature-specific Cargo target directories; the MTSDF kernel test uses a separate target. The package build rejects any optimized baker missing an export declared diff --git a/docs/packages/text.md b/docs/packages/text.md index 59deabf8..e4887ff8 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -195,16 +195,21 @@ centered glyph row, content height, and complete layout hash exactly; no runtime ## Current size and performance evidence -The latest checked package-size record before final cleanup reports: +The latest checked package-size record after the baker ABI cleanup reports: | Graph | Raw | gzip | Brotli | | --------------------------------------- | ----------: | --------: | --------: | -| Core JavaScript plus shaper Wasm | 1,211,173 B | 440,875 B | 349,703 B | -| Three adapter plus core and shaper Wasm | 1,454,561 B | 479,863 B | 381,897 B | +| Core JavaScript plus shaper Wasm | 1,224,539 B | 447,121 B | 353,986 B | +| Three adapter plus core and shaper Wasm | 1,466,450 B | 485,864 B | 385,930 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. +Relative to the preceding checked record, browser-core JavaScript is effectively flat (+96 raw, +7 gzip, -5 Brotli), +the Three adapter shrinks by 1,381 raw / 238 gzip / 255 Brotli bytes, and the shaper Wasm grows by 13,270 raw / 6,239 +gzip / 4,288 Brotli bytes. The corrected complete MTSDF baker is 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; +the earlier 52 KiB observation was a kernel-only test artifact that reused the distributable Cargo target directory. + The public Three benchmark now supports an outside-only mode that leaves the internal phase collector disabled and wraps one `updateMatrixWorld()` call with a host timer. An eight-warmup/31-sample run over 25,515 positioned glyphs measured 17.68/6.13/5.66/16.37 ms median and 18.11/6.32/6.53/16.57 ms p95 for cold/font-size/width/text updates. Those values cover From 95c3b33c161cd05d159949de00cfa00ef58c2405 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 15:11:42 -0400 Subject: [PATCH 098/128] feat(text): add narrow paragraph edits --- docs/log.md | 6 + docs/packages/benchmarks.md | 2 +- docs/packages/text.md | 2 +- docs/planning/three-api.md | 15 +++ packages/text/src/three/text.ts | 108 +++++++++++++++++- .../text/tests/integration/three-v1.test.mjs | 89 +++++++++++++++ .../text/tests/types/three-v1-api.test.ts | 3 + 7 files changed, 217 insertions(+), 8 deletions(-) diff --git a/docs/log.md b/docs/log.md index 525430f9..a6db895a 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Added narrow paragraph editing without exposing engine complexity** — Three `Text` now provides `insertText`, + `deleteText`, and `replaceText` over DOM-compatible UTF-16 offsets; direct `text` assignment derives the smallest + scalar-aligned replacement. Multiple edits queue into the same next-frame Rust transaction, surrogate-pair splits fail + synchronously, and rich-text spans shift with explicit boundary semantics. A wire-level integration regression inspects + the serialized request rather than inferring narrowness from final pixels. + - **Regenerated package-size truth after isolating baker build variants** — The renderer-neutral browser core plus the sole published SIMD shaper measures 1,224,539 raw / 447,121 gzip / 353,986 Brotli bytes; the Three adapter plus that core measures 1,466,450 / 485,864 / 385,930 bytes. Optional Three, React, and React Three Fiber peers remain excluded. diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index ad1f678a..78a3fd9c 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:0f98ea232e8b9e340430af5cfbe24caf5cddf2f76c1d7e3d2822bc9e21b82e0b' +source_digest: 'sha256:8f0e2494abe2325efb08911365a2cdde50e324cd452a6743078ea4fcd417105f' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/text.md b/docs/packages/text.md index e4887ff8..7ab5e1ce 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:fa847f49ec81019df13d7d0bae626a7f4753d73d6860227c099c1ed99e4c0385' +source_digest: 'sha256:447e243be2b0b3a8430d1f6a731075f3d7b01500de3623774e2fec93991b56cd' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/three-api.md b/docs/planning/three-api.md index af1e50ea..52ac1c3d 100644 --- a/docs/planning/three-api.md +++ b/docs/planning/three-api.md @@ -143,6 +143,21 @@ One group traversal performs at most one mutating `text_update` transaction for layout query with pending mutations may perform that synchronization earlier; the following traversal observes the committed revision and does not repeat the semantic work. +For editor-style changes, use UTF-16 ranges instead of rebuilding or diffing the paragraph in application code: + +```ts +label.insertText(cursor, 'a'); +label.deleteText(selectionStart, selectionEnd); +label.replaceText(selectionStart, selectionEnd, pastedText); +``` + +These operations update the ordinary `text` value and queue narrow replacements for the same next-frame transaction. +Multiple operations before traversal remain one Wasm call. Direct `label.text = next` remains the simple declarative API; +the adapter derives its smallest common-prefix/common-suffix replacement without allocating a second scan buffer. +Offsets match JavaScript and DOM selection APIs and cannot split a surrogate pair. Existing spans shift with edits; +inserted text inherits a span only when inserted strictly inside it, so span-boundary affinity does not become hidden +mutable state. + Errors are retained on `text.error` or `group.error` and forwarded to `onError`. They do not escape Three.js scene traversal. `retry()` reapplies a retained publication after a renderer-side failure. diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index c4061a1d..e37f86af 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -105,6 +105,12 @@ interface DesiredTextState { readonly material?: ThreeTextMaterial; } +type PendingTextMutation = Readonly<{ + start: number; + deleteCount: number; + insert: string; +}>; + export class Text extends THREE.Object3D { readonly #runtime: TextRuntime; #desired: DesiredTextState; @@ -115,6 +121,7 @@ export class Text extends THREE.Object3D { #desiredRevision = 0; #appliedRevision = -1; #semanticChanges = ALL_SEMANTIC_CHANGES; + readonly #textMutations: PendingTextMutation[] = []; #disposed = false; #error: unknown; onError: ((error: unknown) => void) | undefined; @@ -200,15 +207,35 @@ export class Text extends THREE.Object3D { const changes = classifySemanticChanges(normalizedUpdate); if (changes === 0) return; const next = normalizeDesired({ ...this.#desired, ...normalizedUpdate } as TextProperties); + const textMutation = minimalTextMutation(this.#desired.text, next.text); const fonts = selectedFonts(next); acquireFonts(fonts, this.#runtime); releaseFonts(this.#leasedFonts); this.#leasedFonts = fonts; this.#desired = next; + if (textMutation !== undefined) this.#textMutations.push(textMutation); this.#desiredRevision += 1; this.#semanticChanges |= changes; } + insertText(offset: number, value: string): void { + this.replaceText(offset, offset, value); + } + + deleteText(start: number, end: number): void { + this.replaceText(start, end, ''); + } + + replaceText(start: number, end: number, value: string): void { + this.#assertActive(); + assertTextRange(this.#desired.text, start, end); + if (typeof value !== 'string') throw new TypeError('replacement text must be a string'); + if (start === end && value.length === 0) return; + const text = this.#desired.text.slice(0, start) + value + this.#desired.text.slice(end); + const spans = editSpans(this.#desired.spans, start, end, value.length); + this.set({ text, spans } as TextUpdate); + } + setSpan(index: number, span: TextSpan): void { const spans = [...this.spans]; if (!Number.isSafeInteger(index) || index < 0 || index >= spans.length) @@ -308,9 +335,13 @@ export class Text extends THREE.Object3D { semanticChanges(): number { return this.#semanticChanges; } + textMutations(): readonly PendingTextMutation[] { + return this.#textMutations; + } markApplied(): void { this.#appliedRevision = this.#desiredRevision; this.#semanticChanges = 0; + this.#textMutations.length = 0; } bind(binding: ThreeTextBatchBinding, group: TextGroup | undefined): void { if (this.#binding !== binding) this.#unbind(); @@ -600,12 +631,8 @@ class ThreeTextBatchBinding { const properties = text.coreProperties(); const content = properties.text as string; if (semanticChanges & TEXT_CHANGE) { - textMutations.push({ - paragraphId: paragraph.id, - start: 0, - deleteCount: paragraph.textLength, - insert: content, - }); + const pending = paragraph.created ? [{ start: 0, deleteCount: 0, insert: content }] : text.textMutations(); + for (const mutation of pending) textMutations.push({ paragraphId: paragraph.id, ...mutation }); } if (semanticChanges & STYLE_CHANGE) { const leases: ThreeTextEngineStackLease[] = []; @@ -1072,6 +1099,75 @@ function replacedContent(update: TextUpdat return { ...update, spans: [] } as TextUpdate; } +function minimalTextMutation(previous: string, next: string): PendingTextMutation | undefined { + if (previous === next) return undefined; + const shared = Math.min(previous.length, next.length); + let start = 0; + while (start < shared) { + const previousCodePoint = previous.codePointAt(start)!; + if (previousCodePoint !== next.codePointAt(start)) break; + start += previousCodePoint > 0xffff ? 2 : 1; + } + let previousEnd = previous.length; + let nextEnd = next.length; + while (previousEnd > start && nextEnd > start) { + const previousStart = previousScalarStart(previous, previousEnd); + const nextStart = previousScalarStart(next, nextEnd); + if (previous.codePointAt(previousStart) !== next.codePointAt(nextStart)) break; + previousEnd = previousStart; + nextEnd = nextStart; + } + return { + start, + deleteCount: previousEnd - start, + insert: next.slice(start, nextEnd), + }; +} + +function previousScalarStart(value: string, end: number): number { + const last = end - 1; + const unit = value.charCodeAt(last); + const previous = value.charCodeAt(last - 1); + return unit >= 0xdc00 && unit <= 0xdfff && last > 0 && previous >= 0xd800 && previous <= 0xdbff ? last - 1 : last; +} + +function assertTextRange(text: string, start: number, end: number): void { + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || end > text.length) { + throw new RangeError('text edit range is outside the text'); + } + if (splitsSurrogatePair(text, start) || splitsSurrogatePair(text, end)) { + throw new RangeError('text edit range must not split a Unicode scalar'); + } +} + +function splitsSurrogatePair(text: string, offset: number): boolean { + if (offset <= 0 || offset >= text.length) return false; + const previous = text.charCodeAt(offset - 1); + const next = text.charCodeAt(offset); + return previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff; +} + +function editSpans( + spans: readonly TextSpan[], + start: number, + end: number, + insertLength: number, +): readonly TextSpan[] { + const delta = insertLength - (end - start); + return spans.flatMap((span) => { + if (span.end <= start) return [span]; + if (span.start >= end) return [{ ...span, start: span.start + delta, end: span.end + delta }]; + const retainsLeft = span.start < start; + const retainsRight = span.end > end; + if (retainsLeft && retainsRight) return [{ ...span, end: span.end + delta }]; + if (retainsLeft) return [{ ...span, end: start }]; + if (retainsRight) { + return [{ ...span, start: start + insertLength, end: span.end + delta }]; + } + return []; + }); +} + function classifySemanticChanges(update: TextUpdate): number { let changes = 0; if (Object.hasOwn(update, 'text')) changes |= TEXT_CHANGE | STYLE_CHANGE | GEOMETRY_CHANGE; diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index dd066239..85966c9a 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -27,6 +27,34 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr input: { baked: dataUrl(await readFile(fontUrl)) }, raster: { technique: bitmap, options: { strikes: [16] } }, }); + const editedSpans = new Text({ + font, + text: 'ABCD', + spans: [ + { start: 0, end: 2, paint: { color: '#ff0000' } }, + { start: 2, end: 4, paint: { color: '#00ff00' } }, + ], + }); + editedSpans.insertText(1, 'X'); + editedSpans.insertText(3, 'Y'); + assert.deepEqual( + editedSpans.spans.map(({ start, end }) => ({ start, end })), + [ + { start: 0, end: 3 }, + { start: 4, end: 6 }, + ], + 'insertion inside a span extends it while insertion at a boundary stays outside', + ); + editedSpans.deleteText(1, 4); + assert.equal(editedSpans.text, 'ACD'); + assert.deepEqual( + editedSpans.spans.map(({ start, end }) => ({ start, end })), + [ + { start: 0, end: 1 }, + { start: 1, end: 3 }, + ], + ); + editedSpans.dispose(); const scene = new THREE.Scene(); const group = new TextGroup({ renderOrder: 12 }); @@ -166,6 +194,43 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn scene.updateMatrixWorld(); assert.equal(instrumented.crossings, 0, 'an empty update and cached measurement must not cross into Rust'); + left.deleteText(1, 2); + scene.updateMatrixWorld(); + assert.deepEqual(instrumented.latestTextMutations(), [{ start: 1, deleteCount: 1, insert: '' }]); + assert.equal(left.text, 'A'); + + left.insertText(1, 'B'); + scene.updateMatrixWorld(); + assert.deepEqual(instrumented.latestTextMutations(), [{ start: 1, deleteCount: 0, insert: 'B' }]); + assert.equal(left.text, 'AB'); + + left.text = 'AY'; + scene.updateMatrixWorld(); + assert.deepEqual( + instrumented.latestTextMutations(), + [{ start: 1, deleteCount: 1, insert: 'Y' }], + 'declarative assignment must serialize its smallest scalar-aligned replacement', + ); + + left.replaceText(1, 2, 'Z'); + left.deleteText(0, 1); + left.insertText(0, 'A'); + scene.updateMatrixWorld(); + assert.deepEqual(instrumented.latestTextMutations(), [ + { start: 1, deleteCount: 1, insert: 'Z' }, + { start: 0, deleteCount: 1, insert: '' }, + { start: 0, deleteCount: 0, insert: 'A' }, + ]); + assert.equal(left.text, 'AZ'); + + left.replaceText(0, 2, '🌍'); + assert.throws(() => left.insertText(1, '!'), /must not split a Unicode scalar/u); + scene.updateMatrixWorld(); + assert.equal(left.text, '🌍'); + left.text = 'AB'; + scene.updateMatrixWorld(); + + instrumented.reset(); left.contentBox = { width: { mode: 'exact', size: 100 }, wrap: 'word' }; const resizedMeasurement = left.measureLayout(); assert.ok(resizedMeasurement, 'a pending mutation must produce its requested measurement'); @@ -232,6 +297,7 @@ async function createInstrumentedRuntime(registry) { const abi = JSON.parse(await readFile(new URL('../../dist/text-shaper-abi-v0.json', import.meta.url), 'utf8')); const originalInstantiate = WebAssembly.instantiate; let crossings = 0; + let latestRequest; WebAssembly.instantiate = async (source, imports) => { const instance = await originalInstantiate(source, imports); const exports = { ...instance.exports }; @@ -239,6 +305,8 @@ async function createInstrumentedRuntime(registry) { assert.equal(typeof update, 'function', 'instrumented shaper must export text_update'); exports[abi.functions.textUpdate] = (...arguments_) => { crossings += 1; + const [, pointer, length] = arguments_; + latestRequest = new Uint8Array(exports.memory.buffer, pointer, length).slice(); return update(...arguments_); }; return { exports }; @@ -253,6 +321,27 @@ async function createInstrumentedRuntime(registry) { reset() { crossings = 0; }, + latestTextMutations() { + assert.ok(latestRequest, 'a text update request must have been captured'); + const request = abi.layouts.engineUpdateRequest; + const mutation = abi.layouts.engineTextMutation; + const view = new DataView(latestRequest.buffer, latestRequest.byteOffset, latestRequest.byteLength); + const offset = view.getUint32(request.textMutationsOffset, true); + const count = view.getUint32(request.textMutationCount, true); + return Array.from({ length: count }, (_recordValue, index) => { + const record = offset + index * mutation.size; + const insertOffset = view.getUint32(record + mutation.insertOffset, true); + const insertCount = view.getUint32(record + mutation.insertCount, true); + const insert = String.fromCharCode( + ...Array.from({ length: insertCount }, (_unitValue, unit) => view.getUint16(insertOffset + unit * 2, true)), + ); + return { + start: view.getUint32(record + mutation.textStart, true), + deleteCount: view.getUint32(record + mutation.deleteCount, true), + insert, + }; + }); + }, }; } finally { WebAssembly.instantiate = originalInstantiate; diff --git a/packages/text/tests/types/three-v1-api.test.ts b/packages/text/tests/types/three-v1-api.test.ts index cd361f57..b5880137 100644 --- a/packages/text/tests/types/three-v1-api.test.ts +++ b/packages/text/tests/types/three-v1-api.test.ts @@ -12,6 +12,9 @@ const labels = new TextGroup({ compositing: 'independent' }); const compositing: 'ordered' | 'independent' = labels.compositing; labels.add(label); label.text = 'Updated'; +label.insertText(7, '!'); +label.deleteText(7, 8); +label.replaceText(0, 7, 'Replaced'); label.setCapacity({ size: 64, policy: 'grow' }); const measurement = label.measureLayout(); void measurement?.contentWidth; From 2e265f60bbf57b5716a8f3b65bb47cf2cdc2c456 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 15:42:21 -0400 Subject: [PATCH 099/128] perf(text): retain unaffected shaping runs --- docs/log.md | 8 + docs/packages/text.md | 2 +- docs/planning/rust-layout-engine.md | 10 + .../rust/shaper/src/engine/shaping_state.rs | 128 ++++++++ packages/text/rust/shaper/src/engine/state.rs | 306 +++++++++++++++++- 5 files changed, 452 insertions(+), 2 deletions(-) diff --git a/docs/log.md b/docs/log.md index a6db895a..2200ebb7 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Bounded localized reshaping to one stable shaping run** — A retained UTF-16 edit whose style, script, bidi level, + direction, and fallback topology remain stable now copies unchanged shaped runs and reshapes only the affected run; + hard breaks, style boundaries, script changes, and bidi changes are therefore explicit correctness boundaries rather + than heuristic byte windows. On the production optimized SIMD Wasm and the unchanged 22,000-glyph Bitmap fixture, + complete Rust `text_update` plus render-plan publication fell from 16.223 ms to 9.372 ms median over 31 measured edits. + This checkpoint is not a budget claim: cluster construction, composition, positioning, and plan gathering still scan + globally, p95 remains 9.723 ms, and the strict eight-warmup lane still detects later Wasm memory growth. + - **Added narrow paragraph editing without exposing engine complexity** — Three `Text` now provides `insertText`, `deleteText`, and `replaceText` over DOM-compatible UTF-16 offsets; direct `text` assignment derives the smallest scalar-aligned replacement. Multiple edits queue into the same next-frame Rust transaction, surrogate-pair splits fail diff --git a/docs/packages/text.md b/docs/packages/text.md index 7ab5e1ce..4c8e15f2 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:447e243be2b0b3a8430d1f6a731075f3d7b01500de3623774e2fec93991b56cd' +source_digest: 'sha256:656e1d9065bce2408626c0e9fe768706f9ee60a73a46ee2e560311a57264b1d8' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 2d5f8af6..4f0918f6 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1017,6 +1017,16 @@ p95 ≤ 1.0 ms for localized edits and retained constraint changes that converge lowering and CPU-side upload submission are reported separately and must also fit the application's total 4 ms UI budget; neither target is justified by a best-case median. +The first retained-edit checkpoint deliberately uses shaping-run boundaries as its conservative invalidation unit. If a +localized edit preserves the complete run topology and the affected run's font fallback, Rust copies every other shaped +run and reshapes the affected run from its original independent context. This is broader than an unsafe-concat window, +but it is exact for the same reason the cold path shapes those runs independently. On the unchanged 22,000-glyph Bitmap +fixture, production optimized SIMD Wasm improved from 16.223 ms to 9.372 ms median (31 measured updates after 40 warmups), +with 9.723 ms p95 and about 1 KiB of command-buffer writes. This isolates a roughly 6.85 ms shaping win. It also proves +that shaping alone cannot meet the contract: cluster rebuilding, line composition, positioning, and plan gathering +remain global, and the ordinary eight-warmup lane still catches a later 1,114,112-byte memory growth. Both are open +failures, not reasons to weaken the gates. + The benchmark reports phases and retained-output costs separately: - Unicode/style invalidation, shaping, composition, positioning, semantic geometry, plan compilation; diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs index 4a25877f..096a8a8b 100644 --- a/packages/text/rust/shaper/src/engine/shaping_state.rs +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -366,6 +366,92 @@ impl ShapeArena { ); Ok((glyph_start, run.glyph_count)) } + + pub(crate) fn append_text_range_from( + &mut self, + source: &Self, + run_index: usize, + source_run: u32, + text_start: u32, + text_end: u32, + text_delta: i64, + ) -> Result<(), EngineError> { + if text_start > text_end { + return Err(EngineError::InvalidRequest); + } + if text_start == text_end { + return Ok(()); + } + let run = *source + .runs + .get(run_index) + .ok_or(EngineError::InvalidRequest)?; + let run_start = + usize::try_from(run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let run_end = run_start + .checked_add(usize::try_from(run.glyph_count).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + let mut selected_start = None; + let mut selected_end = 0usize; + let mut selection_finished = false; + for glyph in run_start..run_end { + let cluster = *source + .clusters + .get(glyph) + .ok_or(EngineError::InvalidRequest)?; + if cluster >= text_start && cluster < text_end { + if selection_finished { + return Err(EngineError::InvalidRequest); + } + selected_start.get_or_insert(glyph); + selected_end = glyph + 1; + } else if selected_start.is_some() { + selection_finished = true; + } + } + let (selected_start, selected_end) = + selected_start.map_or((run_start, run_start), |start| (start, selected_end)); + let glyph_start = + u32::try_from(self.glyph_ids.len()).map_err(|_| EngineError::ResultTooLarge)?; + let glyph_count = u32::try_from(selected_end - selected_start) + .map_err(|_| EngineError::ResultTooLarge)?; + self.reserve( + self.glyph_ids + .len() + .saturating_add(selected_end - selected_start), + )?; + self.runs.push(ShapedRun { + source_run, + text_start: shifted_offset(text_start, text_delta)?, + text_end: shifted_offset(text_end, text_delta)?, + glyph_start, + glyph_count, + ..run + }); + self.glyph_ids + .extend_from_slice(&source.glyph_ids[selected_start..selected_end]); + for &cluster in &source.clusters[selected_start..selected_end] { + self.clusters.push(shifted_offset(cluster, text_delta)?); + } + self.x_advances + .extend_from_slice(&source.x_advances[selected_start..selected_end]); + self.y_advances + .extend_from_slice(&source.y_advances[selected_start..selected_end]); + self.x_offsets + .extend_from_slice(&source.x_offsets[selected_start..selected_end]); + self.y_offsets + .extend_from_slice(&source.y_offsets[selected_start..selected_end]); + self.glyph_flags + .extend_from_slice(&source.glyph_flags[selected_start..selected_end]); + Ok(()) + } +} + +fn shifted_offset(value: u32, delta: i64) -> Result { + let shifted = i64::from(value) + .checked_add(delta) + .ok_or(EngineError::ResultTooLarge)?; + u32::try_from(shifted).map_err(|_| EngineError::ResultTooLarge) } impl BoundaryShapeArena { @@ -456,4 +542,46 @@ mod tests { vec![(0, 3, 0, 0), (4, 7, 0, 2)] ); } + + #[test] + fn copies_and_rebases_one_contiguous_text_range_in_shaping_order() { + let source = ShapeArena { + runs: vec![ShapedRun { + source_run: 7, + binding_handle: 11, + font_handle: 13, + text_start: 0, + text_end: 4, + glyph_start: 0, + glyph_count: 4, + }], + glyph_ids: vec![30, 20, 10, 0], + clusters: vec![3, 2, 1, 0], + x_advances: vec![3, 2, 1, 0], + y_advances: vec![0; 4], + x_offsets: vec![0; 4], + y_offsets: vec![0; 4], + glyph_flags: vec![0, 2, 0, 0], + }; + let mut destination = ShapeArena::default(); + destination + .append_text_range_from(&source, 0, 5, 1, 3, 2) + .unwrap(); + assert_eq!(destination.glyph_ids, [20, 10]); + assert_eq!(destination.clusters, [4, 3]); + assert_eq!(destination.x_advances, [2, 1]); + assert_eq!(destination.glyph_flags, [2, 0]); + assert_eq!( + destination.runs, + [ShapedRun { + source_run: 5, + binding_handle: 11, + font_handle: 13, + text_start: 3, + text_end: 5, + glyph_start: 0, + glyph_count: 2, + }] + ); + } } diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 622c6fd9..de10780a 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -81,6 +81,13 @@ struct ClusterRecord { missing: bool, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct TextEdit { + old_start: usize, + old_end: usize, + new_end: usize, +} + #[derive(Clone, Copy)] struct BoundaryCandidate { source_run: usize, @@ -139,6 +146,7 @@ struct ParagraphState { next_text_unit_id: u32, pending_next_text_unit_id: u32, text_prepared: bool, + text_edit: Option, styles: StyleArena, pending_styles: StyleArena, resolved_styles: ResolvedStyleArena, @@ -1189,6 +1197,7 @@ impl ParagraphState { self.next_text_unit_id = 0; self.pending_next_text_unit_id = 0; self.text_prepared = false; + self.text_edit = None; self.styles.clear(); self.pending_styles.clear(); self.resolved_styles.clear(); @@ -1347,7 +1356,7 @@ impl ParagraphState { self.intrinsic_flow_layout_scratch.reserve(capacity, 1)?; self.boundary_shape.reserve(capacity.min(64))?; self.pending_boundary_shape.reserve(capacity.min(64))?; - self.boundary_shape_scratch.reserve(8)?; + self.boundary_shape_scratch.reserve(glyph_capacity)?; self.ellipsis_shape_scratch.reserve(4)?; if self.ellipsis_text_scratch.capacity() == 0 { self.ellipsis_text_scratch @@ -1413,6 +1422,7 @@ impl ParagraphState { self.abort_text(); return Err(EngineError::InvalidRequest); } + self.text_edit = changed_identity_range(&self.text_unit_ids, &self.pending_text_unit_ids); self.text_prepared = true; Ok(()) } @@ -1422,6 +1432,7 @@ impl ParagraphState { self.pending_text_unit_ids.clear(); self.pending_next_text_unit_id = 0; self.text_prepared = false; + self.text_edit = None; } fn prepare_styles( @@ -1620,6 +1631,10 @@ impl ParagraphState { if !self.shaping_runs_prepared { return Ok(()); } + if self.try_prepare_incremental_shape(shaper)? { + self.shape_prepared = true; + return Ok(()); + } let text = if self.text_prepared { self.pending_text.as_slice() } else { @@ -1783,6 +1798,141 @@ impl ParagraphState { Err(EngineError::InvalidRequest) } + fn try_prepare_incremental_shape( + &mut self, + shaper: &mut ShaperRegistry, + ) -> Result { + let Some(edit) = self.text_edit else { + return Ok(false); + }; + let old_runs = self.shaping_runs.runs(); + let new_runs = self.pending_shaping_runs.runs(); + if old_runs.len() != new_runs.len() || old_runs.is_empty() { + return Ok(false); + } + let Some(old_run_index) = containing_run(old_runs, edit.old_start, edit.old_end) else { + return Ok(false); + }; + let Some(new_run_index) = containing_run(new_runs, edit.old_start, edit.new_end) else { + return Ok(false); + }; + if old_run_index != new_run_index + || !same_edit_run_topology(old_runs, new_runs, edit, old_run_index)? + { + return Ok(false); + } + let old_run = old_runs[old_run_index]; + let new_run = new_runs[new_run_index]; + let affected_source_run = + u32::try_from(old_run_index).map_err(|_| EngineError::ResultTooLarge)?; + let mut affected_fallbacks = self + .fallback_spans + .iter() + .copied() + .filter(|span| span.source_run == affected_source_run); + let Some(fallback) = affected_fallbacks.next() else { + return Ok(false); + }; + if affected_fallbacks.next().is_some() + || fallback.font_index != 0 + || fallback.text_start != old_run.text_start + || fallback.text_end != old_run.text_end + { + return Ok(false); + } + if self + .shape + .runs + .iter() + .filter(|run| run.source_run == affected_source_run) + .count() + != 1 + { + return Ok(false); + } + let delta = edit_delta(edit)?; + let styles = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; + self.boundary_shape_scratch.clear(); + let scratch = &mut self.boundary_shape_scratch; + shaper + .with_shaped_run( + fallback.font_handle, + &self.pending_text, + ShapeRunRef { + text_start: new_run.text_start, + text_end: new_run.text_end, + script: new_run.script, + language: styles.resolved_language(new_run.style), + features: styles.resolved_features(new_run.style), + direction: new_run.direction, + cluster_level: 0, + flags: 0x40, + }, + |shaped| { + scratch.append( + old_run_index, + fallback.font_handle, + fallback.binding_handle, + new_run.text_start, + new_run.text_end, + shaped, + ) + }, + ) + .map_err(shaper_error)?; + if self.boundary_shape_scratch.glyph_ids.contains(&0) { + self.boundary_shape_scratch.clear(); + return Ok(false); + } + for (shape_run_index, shaped_run) in self.shape.runs.iter().copied().enumerate() { + if shaped_run.source_run == affected_source_run { + self.pending_shape.append_text_range_from( + &self.boundary_shape_scratch, + 0, + affected_source_run, + new_run.text_start, + new_run.text_end, + 0, + )?; + continue; + } + let run_delta = if shaped_run.text_start >= old_run.text_end { + delta + } else { + 0 + }; + self.pending_shape.append_text_range_from( + &self.shape, + shape_run_index, + shaped_run.source_run, + shaped_run.text_start, + shaped_run.text_end, + run_delta, + )?; + } + for span in self.fallback_spans.iter().copied() { + let (text_start, text_end) = if span.source_run == affected_source_run { + (new_run.text_start, new_run.text_end) + } else { + ( + map_old_offset(span.text_start, edit)?, + map_old_offset(span.text_end, edit)?, + ) + }; + self.pending_fallback_spans.push(FallbackSpan { + text_start, + text_end, + ..span + }); + } + self.boundary_shape_scratch.clear(); + Ok(true) + } + fn abort_shape(&mut self) { self.pending_shape.clear(); self.pending_fallback_spans.clear(); @@ -2369,6 +2519,31 @@ fn apply_text_mutation( Ok(()) } +fn changed_identity_range(previous: &[u32], next: &[u32]) -> Option { + let shared = previous.len().min(next.len()); + let mut start = 0usize; + while start < shared && previous[start] == next[start] { + start += 1; + } + if start == previous.len() && start == next.len() { + return None; + } + let mut previous_end = previous.len(); + let mut next_end = next.len(); + while previous_end > start + && next_end > start + && previous[previous_end - 1] == next[next_end - 1] + { + previous_end -= 1; + next_end -= 1; + } + Some(TextEdit { + old_start: start, + old_end: previous_end, + new_end: next_end, + }) +} + fn apply_text_identity_mutation( identities: &mut Vec, next_identity: &mut u32, @@ -2426,6 +2601,87 @@ fn unicode_error(error: UnicodeError) -> EngineError { } } +fn same_shaping_properties(left: ShapingRun, right: ShapingRun) -> bool { + left.script == right.script + && left.direction == right.direction + && left.bidi_level == right.bidi_level + && left.style == right.style +} + +fn containing_run(runs: &[ShapingRun], start: usize, end: usize) -> Option { + let start = u32::try_from(start).ok()?; + let end = u32::try_from(end).ok()?; + runs.iter().position(|run| { + run.text_start <= start + && end <= run.text_end + && (start < end || (run.text_start < start && start < run.text_end)) + }) +} + +fn edit_delta(edit: TextEdit) -> Result { + i64::try_from(edit.new_end) + .and_then(|new_end| i64::try_from(edit.old_end).map(|old_end| new_end - old_end)) + .map_err(|_| EngineError::ResultTooLarge) +} + +fn map_old_offset(offset: u32, edit: TextEdit) -> Result { + let old_start = u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?; + let old_end = u32::try_from(edit.old_end).map_err(|_| EngineError::ResultTooLarge)?; + if offset <= old_start { + Ok(offset) + } else if offset >= old_end { + shifted_text_offset(offset, edit_delta(edit)?) + } else { + Err(EngineError::InvalidRequest) + } +} + +fn same_edit_run_topology( + old_runs: &[ShapingRun], + new_runs: &[ShapingRun], + edit: TextEdit, + affected: usize, +) -> Result { + for (index, (&old, &new)) in old_runs.iter().zip(new_runs).enumerate() { + if !same_shaping_properties(old, new) { + return Ok(false); + } + let expected_start = map_old_offset(old.text_start, edit); + let expected_end = map_old_offset(old.text_end, edit); + if index == affected { + let delta = edit_delta(edit)?; + let old_start = + u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?; + let old_end = u32::try_from(edit.old_end).map_err(|_| EngineError::ResultTooLarge)?; + let start = if old.text_start <= old_start { + old.text_start + } else { + shifted_text_offset(old.text_start, delta)? + }; + let end = if old.text_end >= old_end { + shifted_text_offset(old.text_end, delta)? + } else { + old.text_end + }; + if new.text_start != start || new.text_end != end { + return Ok(false); + } + } else if expected_start.ok() != Some(new.text_start) + || expected_end.ok() != Some(new.text_end) + { + return Ok(false); + } + } + Ok(true) +} + +fn shifted_text_offset(value: u32, delta: i64) -> Result { + let shifted = i64::from(value) + .checked_add(delta) + .ok_or(EngineError::ResultTooLarge)?; + u32::try_from(shifted).map_err(|_| EngineError::ResultTooLarge) +} + fn bidi_error(error: BidiError) -> EngineError { match error { BidiError::InvalidDirection => EngineError::InvalidRequest, @@ -2829,7 +3085,55 @@ fn gather_error(error: GatherError) -> EngineError { #[cfg(test)] mod tests { + use crate::engine::style_state::ResolvedStyle; + use super::*; + + #[test] + fn retained_edit_range_and_run_topology_track_insertions_without_crossing_run_boundaries() { + let edit = changed_identity_range(&[1, 2, 3, 4], &[1, 5, 6, 2, 3, 4]).unwrap(); + assert_eq!( + edit, + TextEdit { + old_start: 1, + old_end: 1, + new_end: 3, + } + ); + let style = ResolvedStyle::test_typography(16.0, 0.0, 0.0); + let old = [ + ShapingRun { + text_start: 0, + text_end: 4, + script: 1, + direction: 0, + bidi_level: 0, + style, + }, + ShapingRun { + text_start: 5, + text_end: 9, + script: 1, + direction: 0, + bidi_level: 0, + style, + }, + ]; + let new = [ + ShapingRun { + text_end: 6, + ..old[0] + }, + ShapingRun { + text_start: 7, + text_end: 11, + ..old[1] + }, + ]; + assert_eq!(containing_run(&old, edit.old_start, edit.old_end), Some(0)); + assert_eq!(containing_run(&new, edit.old_start, edit.new_end), Some(0)); + assert!(same_edit_run_topology(&old, &new, edit, 0).unwrap()); + } use crate::{ abi_contract::{ self as abi, ENGINE_TEXT_MUTATION_DELETE_COUNT, ENGINE_TEXT_MUTATION_ENCODING, From 65081e2975f2715f4e8b137abe516a83f5b3c7a2 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 15:50:07 -0400 Subject: [PATCH 100/128] perf(text): avoid warm shape plan allocations --- docs/log.md | 6 ++++ docs/packages/text.md | 2 +- packages/text/rust/shaper/src/lib.rs | 49 ++++++++++++++++++++-------- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/docs/log.md b/docs/log.md index 2200ebb7..a687a995 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Made warm HarfRust plan lookup allocation-free without claiming a latency win** — Cached shaping plans now compare + borrowed language and feature fields; owned cache keys are created only on a genuine miss. The optimized SIMD Wasm + shrank from 1,131,513 to 1,131,457 raw bytes. The 22,000-glyph localized-edit median remained effectively unchanged + at 9.375 ms versus 9.372 ms, and the strict lane still observed the same later 1,114,112-byte memory claim, so neither + issue is attributed to this lookup. + - **Bounded localized reshaping to one stable shaping run** — A retained UTF-16 edit whose style, script, bidi level, direction, and fallback topology remain stable now copies unchanged shaped runs and reshapes only the affected run; hard breaks, style boundaries, script changes, and bidi changes are therefore explicit correctness boundaries rather diff --git a/docs/packages/text.md b/docs/packages/text.md index 4c8e15f2..1f9c22a9 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:656e1d9065bce2408626c0e9fe768706f9ee60a73a46ee2e560311a57264b1d8' +source_digest: 'sha256:da09f36ee1bb41137ceba02e0cc10ea2b9265fd2437f77574fabb86f774d848d' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/packages/text/rust/shaper/src/lib.rs b/packages/text/rust/shaper/src/lib.rs index 919364b2..31fd492f 100644 --- a/packages/text/rust/shaper/src/lib.rs +++ b/packages/text/rust/shaper/src/lib.rs @@ -375,20 +375,11 @@ fn shape_segment_inner( .map(|value| parse_language(value).ok_or(STATUS_INVALID_REQUEST)) .transpose()?; shape_features(run, range, features)?; - let key = PlanKey { - direction: run.direction, - script: run.script, - language: language.as_ref().map(|value| value.as_bytes().to_vec()), - features: features - .iter() - .map(|feature| PlanFeatureKey { - tag: u32::from_be_bytes(feature.tag.to_be_bytes()), - value: feature.value, - global: feature.start == 0 && feature.end == u32::MAX, - }) - .collect(), - }; - if let Some(index) = font.plans.iter().position(|cached| cached.key == key) { + if let Some(index) = font + .plans + .iter() + .position(|cached| plan_key_matches(&cached.key, run, language.as_ref(), features)) + { let cached = font.plans.remove(index); font.plans.push(cached); } else { @@ -404,6 +395,19 @@ fn shape_segment_inner( if font.plans.len() == MAX_CACHED_PLANS_PER_FONT { font.plans.remove(0); } + let key = PlanKey { + direction: run.direction, + script: run.script, + language: language.as_ref().map(|value| value.as_bytes().to_vec()), + features: features + .iter() + .map(|feature| PlanFeatureKey { + tag: u32::from_be_bytes(feature.tag.to_be_bytes()), + value: feature.value, + global: feature.start == 0 && feature.end == u32::MAX, + }) + .collect(), + }; font.plans.push(CachedPlan { key, plan }); } @@ -440,6 +444,23 @@ fn shape_segment_inner( Ok(()) } +fn plan_key_matches( + key: &PlanKey, + run: ShapeRunRef<'_>, + language: Option<&Language>, + features: &[Feature], +) -> bool { + key.direction == run.direction + && key.script == run.script + && key.language.as_deref() == language.map(Language::as_bytes) + && key.features.len() == features.len() + && key.features.iter().zip(features).all(|(cached, feature)| { + cached.tag == u32::from_be_bytes(feature.tag.to_be_bytes()) + && cached.value == feature.value + && cached.global == (feature.start == 0 && feature.end == u32::MAX) + }) +} + fn shape_features( run: ShapeRunRef<'_>, range: ShapeRangeRef, From 20c9dce749ce07d3843016daf535aef80ba6dadd Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 16:06:20 -0400 Subject: [PATCH 101/128] perf(text): stop layout at converged lines --- docs/log.md | 10 + docs/packages/text.md | 2 +- docs/planning/rust-layout-engine.md | 17 + .../shaper/src/engine/flow_composition.rs | 334 ++++++++++++++++++ .../rust/shaper/src/engine/positioning.rs | 107 ++++++ packages/text/rust/shaper/src/engine/state.rs | 51 ++- 6 files changed, 516 insertions(+), 5 deletions(-) diff --git a/docs/log.md b/docs/log.md index a687a995..28038bfd 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-09 +- **Stopped layout and positioning after a proven line-state convergence** — A same-length localized edit now + recomposes only its affected line when geometry, metrics, safety limits, and overflow behavior are compatible, then + retains the exact prefix/suffix lines and positioned glyphs only after cluster cursor, metrics, fragment slots, text + boundaries, stable identities, and hard-break state match. Nonconvergence discards the partial result and exercises + the full path. A 101-update production optimized SIMD Wasm run on the unchanged 22,000-glyph Bitmap case improved + from the preceding 9.372 ms checkpoint to 7.668 ms median (18.2%), with 9.620 ms p95 and five roughly 1.2 KiB + patches. Optimized Wasm grows 6,937 raw bytes to 1,138,394. The 80.38 MiB high-water mark and remaining broad + cluster/revision/plan scans keep this outside the target; the planned semantic 64-cluster edit slack is still not + implemented by the current flat A/B arenas. + - **Made warm HarfRust plan lookup allocation-free without claiming a latency win** — Cached shaping plans now compare borrowed language and feature fields; owned cache keys are created only on a genuine miss. The optimized SIMD Wasm shrank from 1,131,513 to 1,131,457 raw bytes. The 22,000-glyph localized-edit median remained effectively unchanged diff --git a/docs/packages/text.md b/docs/packages/text.md index 1f9c22a9..26054539 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:da09f36ee1bb41137ceba02e0cc10ea2b9265fd2437f77574fabb86f774d848d' +source_digest: 'sha256:91b1c80182f7908648184349da261b263c7b9d48cd195a9821c1e8cee97a0e16' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 4f0918f6..67849564 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1027,6 +1027,23 @@ that shaping alone cannot meet the contract: cluster rebuilding, line compositio remain global, and the ordinary eight-warmup lane still catches a later 1,114,112-byte memory growth. Both are open failures, not reasons to weaken the gates. +The next checkpoint adds an exact one-line convergence proof for same-length localized edits. With geometry, font +metrics, limits, and ellipsis behavior unchanged, Rust recomposes the line containing the edit and compares its ending +cluster cursor, metrics, slots, fragments, hard-break state, and stable boundary identities with the retained line. A +match permits the prefix and suffix lines—and their positioned glyph records—to be retained; any mismatch discards the +partial result and runs the complete composer. A 101-update production run after 40 warmups improves the same workload +from the preceding 9.372 ms checkpoint to 7.668 ms median, an 18.2% end-to-end reduction, with 9.620 ms p95 and five +roughly 1.2 KiB command-buffer patches. The optimized Wasm grows from 1,131,457 to 1,138,394 raw bytes (+6,937). This +remains above budget. Cluster reconstruction, revision assignment, and plan gathering remain broad, and the retained +high-water mark is still 80.38 MiB. + +The renderer's 25% instance slack is not the edit-storage design. Editing requires the selected ABI-private +64-cluster semantic chunks to reserve a small bounded gap so insert, delete, and replacement operations move only the +affected chunk before summaries and downstream line state resume. The current production text, shape, cluster, and +position arenas still use flat transactional A/B vectors, so they copy or scan far more state than a localized edit +requires. That implementation gap must close before this stack can claim the retained-storage decision or the edit +latency target. + The benchmark reports phases and retained-output costs separately: - Unicode/style invalidation, shaping, composition, positioning, semantic geometry, plan compilation; diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index abb431c5..b9864661 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -72,6 +72,7 @@ pub(crate) struct FlowLayoutArena { pub lines: Vec, pub fragments: Vec, pub(crate) ellipsis_threads: Vec, + pub(crate) recomposed_lines: Option<(usize, usize)>, } impl FlowLayoutArena { @@ -208,6 +209,155 @@ impl FlowLayoutArena { Ok(()) } + #[allow(clippy::too_many_arguments)] + pub(crate) fn rebuild_one_line_if_state_converges( + &mut self, + previous: &Self, + geometry: &FlowGeometryArena, + previous_clusters: &ClusterArena, + clusters: &ClusterArena, + styles: &[StyleSegment], + slots: &mut InlineSlotArena, + edit_offset: u32, + max_lines: usize, + max_slots_per_band: usize, + metrics_for: impl Fn(u32) -> Option + Copy, + first_font_for_stack: impl Fn(u32) -> Option + Copy, + ) -> Result { + self.clear(); + if !previous.ellipsis_threads.is_empty() + || previous.lines.is_empty() + || previous.lines.len() > max_lines + || previous.lines.iter().any(|line| { + usize::try_from(line.fragment_count) + .map_or(true, |count| count > max_slots_per_band) + }) + || previous_clusters.starts.len() != clusters.starts.len() + { + return Ok(false); + } + let Some(line_index) = previous.lines.iter().position(|line| { + line_fragments(previous, *line).is_ok_and(|fragments| { + fragments.iter().any(|fragment| { + fragment.line.text_start <= edit_offset + && edit_offset + < fragment + .line + .text_end + .max(fragment.line.text_start.saturating_add(1)) + }) + }) + }) else { + return Ok(false); + }; + let old_line = previous.lines[line_index]; + let old_fragments = line_fragments(previous, old_line)?; + let Some(first_fragment) = old_fragments.first() else { + return Ok(false); + }; + let Some(last_fragment) = old_fragments.last() else { + return Ok(false); + }; + let cluster_start = usize::try_from(first_fragment.line.cluster_start) + .map_err(|_| EngineError::InvalidRequest)?; + let old_cluster_end = usize::try_from(last_fragment.line.cluster_end) + .map_err(|_| EngineError::InvalidRequest)?; + if previous_clusters.stable_ids.get(cluster_start) != clusters.stable_ids.get(cluster_start) + || (old_cluster_end < clusters.stable_ids.len() + && previous_clusters.stable_ids.get(old_cluster_end) + != clusters.stable_ids.get(old_cluster_end)) + { + return Ok(false); + } + let Some(region_index) = geometry + .regions + .iter() + .position(|region| region.record.id == old_line.region_id) + else { + return Ok(false); + }; + let region = geometry + .regions + .get(region_index) + .ok_or(EngineError::InvalidRequest)?; + for prefix in 0..line_index { + self.append_retained_line(previous, prefix)?; + } + let mut cursor = LineCursor::at_cluster(cluster_start); + let composed_start = self.fragments.len(); + let Some(height) = self.compose_band( + geometry, + region_index, + old_line.flow_thread_id, + old_line.region_id, + old_line.transform_index, + old_line.clip_id, + clusters, + styles, + slots, + &mut cursor, + old_line.block_start, + f64::from(region.record.block_end), + LineExtents { + above: old_line.baseline, + below: old_line.height - old_line.baseline, + }, + wrapping_for_flow_thread(geometry, old_line.flow_thread_id)?, + old_line.align, + max_slots_per_band, + metrics_for, + first_font_for_stack, + )? + else { + self.clear(); + return Ok(false); + }; + let new_line = *self.lines.last().ok_or(EngineError::InvalidRequest)?; + let new_fragments = self + .fragments + .get(composed_start..) + .ok_or(EngineError::InvalidRequest)?; + let converged = cursor.cluster() == old_cluster_end + && height == old_line.height + && new_line.baseline == old_line.baseline + && new_fragments.len() == old_fragments.len() + && new_fragments.iter().zip(old_fragments).all(|(new, old)| { + new.slot_start == old.slot_start + && new.slot_end == old.slot_end + && new.line.cluster_start == old.line.cluster_start + && new.line.cluster_end == old.line.cluster_end + && new.line.text_start == old.line.text_start + && new.line.text_end == old.line.text_end + && new.line.hard_break == old.line.hard_break + }); + if !converged { + self.clear(); + return Ok(false); + } + for suffix in line_index + 1..previous.lines.len() { + self.append_retained_line(previous, suffix)?; + } + self.recomposed_lines = Some((line_index, line_index + 1)); + Ok(true) + } + + fn append_retained_line( + &mut self, + source: &Self, + line_index: usize, + ) -> Result<(), EngineError> { + let mut line = *source + .lines + .get(line_index) + .ok_or(EngineError::InvalidRequest)?; + let fragments = line_fragments(source, line)?; + line.fragment_start = + u32::try_from(self.fragments.len()).map_err(|_| EngineError::ResultTooLarge)?; + self.fragments.extend_from_slice(fragments); + self.lines.push(line); + Ok(()) + } + #[allow(clippy::too_many_arguments)] fn compose_band( &mut self, @@ -314,6 +464,11 @@ impl FlowLayoutArena { self.lines.clear(); self.fragments.clear(); self.ellipsis_threads.clear(); + self.recomposed_lines = None; + } + + pub(crate) fn recomposed_line_range(&self) -> Option<(usize, usize)> { + self.recomposed_lines } pub(crate) fn ellipsis_threads(&self) -> &[u32] { @@ -404,6 +559,28 @@ impl FlowLayoutArena { } } +fn wrapping_for_flow_thread( + geometry: &FlowGeometryArena, + flow_thread_id: u32, +) -> Result { + geometry + .constraints + .iter() + .find(|constraint| constraint.flow_thread_id == flow_thread_id) + .map(|constraint| constraint.wrap) + .ok_or(EngineError::InvalidRequest) +} + +fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { + let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; + let end = start + .checked_add(usize::from(line.fragment_count)) + .ok_or(EngineError::InvalidRequest)?; + flow.fragments + .get(start..end) + .ok_or(EngineError::InvalidRequest) +} + fn cluster_text_end(clusters: &ClusterArena, cluster_end: usize) -> u32 { clusters .starts @@ -719,6 +896,163 @@ mod tests { assert_eq!(layout.fragments.last().unwrap().line.cluster_end, 4); } + #[test] + fn localized_edit_recomposes_one_line_and_reuses_converged_prefix_and_suffix() { + let make_clusters = |advances: Vec| ClusterArena { + starts: vec![0, 1, 2, 3, 4, 5], + ends: vec![1, 2, 3, 4, 5, 6], + advances, + flags: vec![CLUSTER_SAFE_BEFORE; 6], + style_indexes: vec![0; 6], + source_runs: vec![0; 6], + font_handles: vec![1; 6], + stable_ids: vec![1, 2, 3, 4, 5, 6], + index_at: vec![0, 1, 2, 3, 4, 5, 6], + ..ClusterArena::default() + }; + let previous_clusters = make_clusters(vec![2.0; 6]); + let changed_clusters = make_clusters(vec![2.0, 2.0, 1.0, 2.0, 2.0, 2.0]); + let styles = [StyleSegment { + text_start: 0, + text_end: 6, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut narrow_region = region(); + narrow_region.inline_end = 4.0; + narrow_region.clip_inline_end = 4.0; + narrow_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: narrow_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = FlowLayoutArena::default(); + previous + .build( + &geometry, + &previous_clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap(); + let retained_prefix = previous.fragments[0]; + let retained_suffix = previous.fragments[2]; + let mut changed = FlowLayoutArena::default(); + assert!( + changed + .rebuild_one_line_if_state_converges( + &previous, + &geometry, + &previous_clusters, + &changed_clusters, + &styles, + &mut InlineSlotArena::default(), + 2, + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap() + ); + assert_eq!(changed.lines.len(), 3); + assert_eq!(changed.fragments[0], retained_prefix); + assert_eq!(changed.fragments[1].line.advance, 3.0); + assert_eq!(changed.fragments[2], retained_suffix); + } + + #[test] + fn localized_edit_clears_partial_layout_when_line_state_does_not_converge() { + let make_clusters = |advances: Vec| ClusterArena { + starts: vec![0, 1, 2, 3, 4, 5], + ends: vec![1, 2, 3, 4, 5, 6], + advances, + flags: vec![CLUSTER_SAFE_BEFORE; 6], + style_indexes: vec![0; 6], + source_runs: vec![0; 6], + font_handles: vec![1; 6], + stable_ids: vec![1, 2, 3, 4, 5, 6], + index_at: vec![0, 1, 2, 3, 4, 5, 6], + ..ClusterArena::default() + }; + let previous_clusters = make_clusters(vec![2.0; 6]); + let changed_clusters = make_clusters(vec![2.0, 2.0, 3.0, 2.0, 2.0, 2.0]); + let styles = [StyleSegment { + text_start: 0, + text_end: 6, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut narrow_region = region(); + narrow_region.inline_end = 4.0; + narrow_region.clip_inline_end = 4.0; + narrow_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: narrow_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = FlowLayoutArena::default(); + previous + .build( + &geometry, + &previous_clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap(); + let mut changed = FlowLayoutArena::default(); + assert!( + !changed + .rebuild_one_line_if_state_converges( + &previous, + &geometry, + &previous_clusters, + &changed_clusters, + &styles, + &mut InlineSlotArena::default(), + 2, + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap() + ); + assert!(changed.lines.is_empty()); + assert!(changed.fragments.is_empty()); + assert_eq!(changed.recomposed_line_range(), None); + } + #[test] fn ellipsis_truncation_reuses_the_final_slot_and_removes_only_required_clusters() { let clusters = ClusterArena { diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index df185813..c96ec2c1 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -49,6 +49,8 @@ pub(crate) struct SemanticGlyph { #[derive(Default)] pub(crate) struct PositionedGlyphArena { glyphs: Vec, + line_glyph_starts: Vec, + line_glyph_counts: Vec, semantic_glyphs: Vec, semantic_line_glyph_starts: Vec, semantic_line_glyph_counts: Vec, @@ -96,14 +98,28 @@ impl PositionedGlyphArena { ) -> Result<(), EngineError> { self.clear(); self.reserve(shape.glyph_ids.len())?; + reserve(&mut self.line_glyph_starts, flow.lines.len())?; + reserve(&mut self.line_glyph_counts, flow.lines.len())?; reserve(&mut self.semantic_line_glyph_starts, flow.lines.len())?; reserve(&mut self.semantic_line_glyph_counts, flow.lines.len())?; reserve(&mut self.semantic_line_inline_extents, flow.lines.len())?; let visually_ltr = is_trivially_ltr(bidi, runs); for (line_index, line) in flow.lines.iter().copied().enumerate() { + if flow + .recomposed_line_range() + .is_some_and(|(start, end)| line_index < start || line_index >= end) + { + self.append_retained_line(previous, line_index)?; + continue; + } + let line_glyph_start = self.glyphs.len(); let semantic_line_start = self.semantic_glyphs.len(); let fragments = line_fragments(flow, line)?; if fragments.is_empty() { + self.line_glyph_starts.push( + u32::try_from(line_glyph_start).map_err(|_| EngineError::ResultTooLarge)?, + ); + self.line_glyph_counts.push(0); self.semantic_line_glyph_starts.push( u32::try_from(semantic_line_start).map_err(|_| EngineError::ResultTooLarge)?, ); @@ -160,12 +176,103 @@ impl PositionedGlyphArena { ); self.semantic_line_inline_extents .push((inline_end - inline_start).max(0.0)); + self.line_glyph_starts + .push(u32::try_from(line_glyph_start).map_err(|_| EngineError::ResultTooLarge)?); + self.line_glyph_counts.push( + u32::try_from(self.glyphs.len().saturating_sub(line_glyph_start)) + .map_err(|_| EngineError::ResultTooLarge)?, + ); } self.assign_content_revisions(previous, identity_index, next_content_revision) } + fn append_retained_line( + &mut self, + previous: &Self, + line_index: usize, + ) -> Result<(), EngineError> { + let glyph_start = usize::try_from( + *previous + .line_glyph_starts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_count = usize::try_from( + *previous + .line_glyph_counts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let glyph_end = glyph_start + .checked_add(glyph_count) + .ok_or(EngineError::InvalidRequest)?; + let semantic_start = usize::try_from( + *previous + .semantic_line_glyph_starts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let semantic_count = usize::try_from( + *previous + .semantic_line_glyph_counts + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let semantic_end = semantic_start + .checked_add(semantic_count) + .ok_or(EngineError::InvalidRequest)?; + self.line_glyph_starts + .push(u32::try_from(self.glyphs.len()).map_err(|_| EngineError::ResultTooLarge)?); + self.line_glyph_counts + .push(u32::try_from(glyph_count).map_err(|_| EngineError::ResultTooLarge)?); + self.glyphs.extend_from_slice( + previous + .glyphs + .get(glyph_start..glyph_end) + .ok_or(EngineError::InvalidRequest)?, + ); + for (target, source) in self.semantic_f32.iter_mut().zip(&previous.semantic_f32) { + target.extend_from_slice( + source + .get(glyph_start..glyph_end) + .ok_or(EngineError::InvalidRequest)?, + ); + } + for (target, source) in self.semantic_u32.iter_mut().zip(&previous.semantic_u32) { + target.extend_from_slice( + source + .get(glyph_start..glyph_end) + .ok_or(EngineError::InvalidRequest)?, + ); + } + self.semantic_line_glyph_starts.push( + u32::try_from(self.semantic_glyphs.len()).map_err(|_| EngineError::ResultTooLarge)?, + ); + self.semantic_line_glyph_counts + .push(u32::try_from(semantic_count).map_err(|_| EngineError::ResultTooLarge)?); + self.semantic_line_inline_extents.push( + *previous + .semantic_line_inline_extents + .get(line_index) + .ok_or(EngineError::InvalidRequest)?, + ); + self.semantic_glyphs.extend_from_slice( + previous + .semantic_glyphs + .get(semantic_start..semantic_end) + .ok_or(EngineError::InvalidRequest)?, + ); + Ok(()) + } + pub(crate) fn clear(&mut self) { self.glyphs.clear(); + self.line_glyph_starts.clear(); + self.line_glyph_counts.clear(); self.semantic_glyphs.clear(); self.semantic_line_glyph_starts.clear(); self.semantic_line_glyph_counts.clear(); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index de10780a..cacbb92d 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -12,8 +12,8 @@ use super::{ flow_geometry::FlowGeometryArena, font_binding::FontRenderBinding, frame::{ - CommittedUpdate, OVERFLOW_CLIP, OVERFLOW_VISIBLE, PreparedUpdate, SessionRevision, - UpdateRequest, + CommittedUpdate, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, PreparedUpdate, + SessionRevision, UpdateRequest, }, identity_index::IdentityIndex, policy::{ALLOCATION_ORDERED_DIRECT, CapabilitySetId, ValidatedPolicy}, @@ -2109,13 +2109,56 @@ impl ParagraphState { } else { &self.geometry }; + let max_slots_per_band = + usize::try_from(max_slots_per_band).map_err(|_| EngineError::ResultTooLarge)?; + let max_lines = usize::try_from(max_lines).map_err(|_| EngineError::ResultTooLarge)?; + if !self.geometry_prepared + && !self.style_invalidation.metrics + && self.boundary_shape.records.is_empty() + && geometry + .constraints + .iter() + .all(|constraint| constraint.overflow != OVERFLOW_ELLIPSIS) + && let Some(edit) = self.text_edit + && edit.old_end.saturating_sub(edit.old_start) + == edit.new_end.saturating_sub(edit.old_start) + && self + .pending_flow_layout + .rebuild_one_line_if_state_converges( + &self.flow_layout, + geometry, + &self.clusters, + clusters, + styles, + &mut self.flow_slot_scratch, + u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?, + max_lines, + max_slots_per_band, + |handle| shaper.font_metrics(handle), + |stack_handle| { + font_stacks + .binary_search_by_key(&stack_handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks[index].fonts.first().copied()) + .and_then(|handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| binding.shaping_handle) + }) + }, + )? + { + self.flow_layout_prepared = true; + return Ok(()); + } self.pending_flow_layout.build( geometry, clusters, styles, &mut self.flow_slot_scratch, - usize::try_from(max_lines).map_err(|_| EngineError::ResultTooLarge)?, - usize::try_from(max_slots_per_band).map_err(|_| EngineError::ResultTooLarge)?, + max_lines, + max_slots_per_band, |handle| shaper.font_metrics(handle), |stack_handle| { font_stacks From 9eda67e84beaf90fdefb8267e59a39b239ed7889 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 16:13:39 -0400 Subject: [PATCH 102/128] perf(text): retain synchronized edit buffers --- docs/log.md | 6 ++ docs/packages/text.md | 2 +- docs/planning/rust-layout-engine.md | 6 ++ packages/text/rust/shaper/src/engine/state.rs | 68 ++++++++++++++----- 4 files changed, 65 insertions(+), 17 deletions(-) diff --git a/docs/log.md b/docs/log.md index 28038bfd..6bb3f9ad 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Kept transactional text buffers synchronized across equal-length edits** — The retired UTF-16 and stable-identity + buffers now copy only the proven changed range after commit or restore on abort, so the next replacement does not + begin by cloning the paragraph. A 101-update rerun measured 7.633 ms median / 9.358 ms p95 against the preceding + 7.668 / 9.620 ms, while optimized Wasm shrank 520 raw bytes to 1,137,874. This removes redundant work but is not a + latency claim; inserts and deletes still await the bounded semantic chunk gaps. + - **Stopped layout and positioning after a proven line-state convergence** — A same-length localized edit now recomposes only its affected line when geometry, metrics, safety limits, and overflow behavior are compatible, then retains the exact prefix/suffix lines and positioned glyphs only after cluster cursor, metrics, fragment slots, text diff --git a/docs/packages/text.md b/docs/packages/text.md index 26054539..7e74b7f6 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:91b1c80182f7908648184349da261b263c7b9d48cd195a9821c1e8cee97a0e16' +source_digest: 'sha256:34c1b39f582fc78049b94923791ef51fa7a4cac69552cfbaedbff2881a306d22' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 67849564..4e7102b3 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1037,6 +1037,12 @@ roughly 1.2 KiB command-buffer patches. The optimized Wasm grows from 1,131,457 remains above budget. Cluster reconstruction, revision assignment, and plan gathering remain broad, and the retained high-water mark is still 80.38 MiB. +The existing transaction's two flat UTF-16/identity buffers now remain synchronized after an equal-length commit or +abort, copying only the proven changed identity range instead of cloning the paragraph before its next edit. A +101-update rerun measured 7.633 ms median / 9.358 ms p95, effectively flat against 7.668 / 9.620 ms, while optimized +Wasm shrank 520 raw bytes to 1,137,874. This cleanup removes known redundant copying without attributing the remaining +latency to memcpy. Length-changing edits still require the chunk-local gap storage below. + The renderer's 25% instance slack is not the edit-storage design. Editing requires the selected ABI-private 64-cluster semantic chunks to reserve a small bounded gap so insert, delete, and replacement operations move only the affected chunk before summaries and downstream line state resume. The current production text, shape, cluster, and diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index cacbb92d..28bb82f3 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -143,6 +143,7 @@ struct ParagraphState { pending_text: Vec, text_unit_ids: Vec, pending_text_unit_ids: Vec, + pending_text_mirrors_committed: bool, next_text_unit_id: u32, pending_next_text_unit_id: u32, text_prepared: bool, @@ -1194,6 +1195,7 @@ impl ParagraphState { self.pending_text.clear(); self.text_unit_ids.clear(); self.pending_text_unit_ids.clear(); + self.pending_text_mirrors_committed = true; self.next_text_unit_id = 0; self.pending_next_text_unit_id = 0; self.text_prepared = false; @@ -1383,20 +1385,27 @@ impl ParagraphState { if mutations.len() == 0 { return Ok(()); } - if self.pending_text.try_reserve(self.text.len()).is_err() { - return Err(EngineError::ResultTooLarge); - } - if self - .pending_text_unit_ids - .try_reserve(self.text_unit_ids.len()) - .is_err() - { - return Err(EngineError::ResultTooLarge); + if !self.pending_text_mirrors_committed { + if self.pending_text.try_reserve(self.text.len()).is_err() { + return Err(EngineError::ResultTooLarge); + } + if self + .pending_text_unit_ids + .try_reserve(self.text_unit_ids.len()) + .is_err() + { + return Err(EngineError::ResultTooLarge); + } + self.pending_text.clear(); + self.pending_text.extend_from_slice(&self.text); + self.pending_text_unit_ids.clear(); + self.pending_text_unit_ids + .extend_from_slice(&self.text_unit_ids); + self.pending_text_mirrors_committed = true; } - self.pending_text.extend_from_slice(&self.text); - self.pending_text_unit_ids - .extend_from_slice(&self.text_unit_ids); self.pending_next_text_unit_id = self.next_text_unit_id.max(1); + self.text_prepared = true; + self.pending_text_mirrors_committed = false; for index in 0..mutations.len() { let Some(mutation) = mutations.get(index) else { self.abort_text(); @@ -1423,13 +1432,22 @@ impl ParagraphState { return Err(EngineError::InvalidRequest); } self.text_edit = changed_identity_range(&self.text_unit_ids, &self.pending_text_unit_ids); - self.text_prepared = true; Ok(()) } fn abort_text(&mut self) { - self.pending_text.clear(); - self.pending_text_unit_ids.clear(); + if self.text_prepared { + self.pending_text.clear(); + self.pending_text.extend_from_slice(&self.text); + self.pending_text_unit_ids.clear(); + self.pending_text_unit_ids + .extend_from_slice(&self.text_unit_ids); + self.pending_text_mirrors_committed = true; + } + self.clear_text_preparation(); + } + + fn clear_text_preparation(&mut self) { self.pending_next_text_unit_id = 0; self.text_prepared = false; self.text_edit = None; @@ -1513,11 +1531,26 @@ impl ParagraphState { fn commit_text(&mut self) { if self.text_prepared { + let retains_mirror = self.pending_text.len() == self.text.len(); + let edit = self.text_edit; core::mem::swap(&mut self.text, &mut self.pending_text); core::mem::swap(&mut self.text_unit_ids, &mut self.pending_text_unit_ids); self.next_text_unit_id = self.pending_next_text_unit_id; + if retains_mirror { + if let Some(edit) = edit { + self.pending_text[edit.old_start..edit.new_end] + .copy_from_slice(&self.text[edit.old_start..edit.new_end]); + self.pending_text_unit_ids[edit.old_start..edit.new_end] + .copy_from_slice(&self.text_unit_ids[edit.old_start..edit.new_end]); + } + self.pending_text_mirrors_committed = true; + } else { + self.pending_text.clear(); + self.pending_text_unit_ids.clear(); + self.pending_text_mirrors_committed = false; + } } - self.abort_text(); + self.clear_text_preparation(); } fn prepare_unicode(&mut self) -> Result<(), EngineError> { @@ -3551,6 +3584,9 @@ mod tests { let session = engine.sessions.get(&4).unwrap(); let paragraph = session.first_paragraph_state().unwrap(); assert_eq!(paragraph.text_unit_ids, [8, 5, 6, 3, 4, 7]); + assert!(paragraph.pending_text_mirrors_committed); + assert_eq!(paragraph.pending_text, paragraph.text); + assert_eq!(paragraph.pending_text_unit_ids, paragraph.text_unit_ids); assert_eq!( [paragraph.pending_text.capacity(), paragraph.text.capacity(),], settled_capacities From 402ae442726b5570d900322795f7a26d2d9c9a51 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 16:23:15 -0400 Subject: [PATCH 103/128] perf(text): narrow revision assignment --- docs/log.md | 7 + docs/packages/text.md | 2 +- docs/planning/rust-layout-engine.md | 6 + .../rust/shaper/src/engine/positioning.rs | 151 +++++++++++++++++- 4 files changed, 164 insertions(+), 2 deletions(-) diff --git a/docs/log.md b/docs/log.md index 6bb3f9ad..81812432 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-09 +- **Narrowed content-revision work to the recomposed line** — The line-convergence proof now carries exact old/new + glyph spans into positioning. Retained prefix and suffix records preserve their revisions and publish zero semantic + change masks; only the changed span compares fields or looks up stable identities. A focused test proves revisions + `[10,20,30]` become `[10,40,30]` for a middle-span change. The 101-update optimized benchmark improves from 7.633 + to 6.894 ms median (9.7%); p95 is effectively flat at 9.358→9.314 ms, so no tail improvement is claimed. Optimized + Wasm grows 1,693 raw bytes to 1,139,567. + - **Kept transactional text buffers synchronized across equal-length edits** — The retired UTF-16 and stable-identity buffers now copy only the proven changed range after commit or restore on abort, so the next replacement does not begin by cloning the paragraph. A 101-update rerun measured 7.633 ms median / 9.358 ms p95 against the preceding diff --git a/docs/packages/text.md b/docs/packages/text.md index 7e74b7f6..cb011c80 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:34c1b39f582fc78049b94923791ef51fa7a4cac69552cfbaedbff2881a306d22' +source_digest: 'sha256:328d24c65703db7d3aa96860a405455e3953bde03bfa3965e9f38cf18bf0a604' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 4e7102b3..abcf78f8 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1043,6 +1043,12 @@ abort, copying only the proven changed identity range instead of cloning the par Wasm shrank 520 raw bytes to 1,137,874. This cleanup removes known redundant copying without attributing the remaining latency to memcpy. Length-changing edits still require the chunk-local gap storage below. +Positioning now carries the exact previous/new glyph span for the line accepted by the convergence proof. Retained +prefix and suffix glyphs keep their content revisions and zero change masks; only the recomposed span performs semantic +field comparison and identity lookup. On the same 101-update optimized workload this narrows 7.633 ms median / 9.358 ms +p95 to 6.894 / 9.314 ms. The 9.7% median reduction admits the range optimization, while the effectively unchanged p95 +does not support a tail-latency claim. Optimized Wasm grows 1,693 raw bytes to 1,139,567. + The renderer's 25% instance slack is not the edit-storage design. Editing requires the selected ABI-private 64-cluster semantic chunks to reserve a small bounded gap so insert, delete, and replacement operations move only the affected chunk before summaries and downstream line state resume. The current production text, shape, cluster, and diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index c96ec2c1..f78b40d3 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -61,6 +61,15 @@ pub(crate) struct PositionedGlyphArena { visual_clusters: Vec, visual_levels: Vec, line_levels: Vec, + recomposed_glyphs: Option, +} + +#[derive(Clone, Copy)] +struct RecomposedGlyphRange { + previous_start: usize, + previous_end: usize, + next_start: usize, + next_end: usize, } impl PositionedGlyphArena { @@ -183,6 +192,21 @@ impl PositionedGlyphArena { .map_err(|_| EngineError::ResultTooLarge)?, ); } + self.recomposed_glyphs = flow + .recomposed_line_range() + .map(|(start, end)| { + Ok(RecomposedGlyphRange { + previous_start: line_span_start(&previous.line_glyph_starts, start)?, + previous_end: line_span_end( + &previous.line_glyph_starts, + &previous.line_glyph_counts, + end, + )?, + next_start: line_span_start(&self.line_glyph_starts, start)?, + next_end: line_span_end(&self.line_glyph_starts, &self.line_glyph_counts, end)?, + }) + }) + .transpose()?; self.assign_content_revisions(previous, identity_index, next_content_revision) } @@ -287,6 +311,7 @@ impl PositionedGlyphArena { self.visual_clusters.clear(); self.visual_levels.clear(); self.line_levels.clear(); + self.recomposed_glyphs = None; } pub(crate) fn glyphs(&self) -> &[LayoutGlyph] { @@ -799,6 +824,53 @@ impl PositionedGlyphArena { index: &mut IdentityIndex, next_revision: &mut u32, ) -> Result<(), EngineError> { + self.semantic_change_masks.resize(self.glyphs.len(), 0); + if let Some(range) = self.recomposed_glyphs { + *next_revision = (*next_revision).max(1); + let previous_glyphs = previous + .glyphs + .get(range.previous_start..range.previous_end) + .ok_or(EngineError::InvalidRequest)?; + let next_glyphs = self + .glyphs + .get(range.next_start..range.next_end) + .ok_or(EngineError::InvalidRequest)?; + if previous_glyphs.len() == next_glyphs.len() + && previous_glyphs + .iter() + .zip(next_glyphs) + .all(|(old, next)| old.stable_id == next.stable_id) + { + for offset in 0..next_glyphs.len() { + self.assign_content_revision( + range.next_start + offset, + previous, + Some(range.previous_start + offset), + next_revision, + )?; + } + return Ok(()); + } + index + .prepare(previous_glyphs.len()) + .map_err(identity_index_error)?; + for (offset, glyph) in previous_glyphs.iter().enumerate() { + index + .insert( + glyph.stable_id, + u32::try_from(range.previous_start + offset) + .map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + for slot in range.next_start..range.next_end { + let previous_slot = index + .get(self.glyphs[slot].stable_id) + .and_then(|value| usize::try_from(value).ok()); + self.assign_content_revision(slot, previous, previous_slot, next_revision)?; + } + return Ok(()); + } if self.glyphs.len() == previous.glyphs.len() && self .glyphs @@ -857,7 +929,10 @@ impl PositionedGlyphArena { return Err(EngineError::ResultTooLarge); } self.glyphs[slot].content_revision = revision; - self.semantic_change_masks.push(change_mask); + *self + .semantic_change_masks + .get_mut(slot) + .ok_or(EngineError::InvalidRequest)? = change_mask; Ok(()) } @@ -892,6 +967,25 @@ impl PositionedGlyphArena { } } +fn line_span_start(starts: &[u32], line: usize) -> Result { + starts + .get(line) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::InvalidRequest) +} + +fn line_span_end(starts: &[u32], counts: &[u32], line_end: usize) -> Result { + let line = line_end.checked_sub(1).ok_or(EngineError::InvalidRequest)?; + let start = line_span_start(starts, line)?; + let count = counts + .get(line) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .ok_or(EngineError::InvalidRequest)?; + start.checked_add(count).ok_or(EngineError::InvalidRequest) +} + fn line_fragments(flow: &FlowLayoutArena, line: FlowLine) -> Result<&[FlowFragment], EngineError> { let start = usize::try_from(line.fragment_start).map_err(|_| EngineError::InvalidRequest)?; let end = start @@ -1371,4 +1465,59 @@ mod tests { assert_eq!(reordered.semantic_change_masks, [0, 0]); assert_eq!(next_revision, 5); } + + #[test] + fn converged_lines_assign_revisions_only_inside_the_recomposed_glyph_range() { + let glyph = |stable_id, revision| LayoutGlyph { + stable_id, + content_revision: revision, + binding_handle: 1, + font_handle: 1, + glyph_id: stable_id, + semantic_id: stable_id, + material_id: 0, + clip_id: 0, + depth_key: 0, + font_size: 16.0, + raster_pixel_ratio: 1.0, + inline_start: stable_id as f32, + block_start: 0.0, + inline_extent: 8.0, + block_extent: 16.0, + }; + let make_arena = || { + let mut arena = PositionedGlyphArena { + glyphs: vec![glyph(1, 10), glyph(2, 20), glyph(3, 30)], + ..PositionedGlyphArena::default() + }; + for field in &mut arena.semantic_f32 { + field.extend([1.0, 2.0, 3.0]); + } + for field in &mut arena.semantic_u32 { + field.extend([1, 2, 3]); + } + arena + }; + let previous = make_arena(); + let mut next = make_arena(); + next.semantic_f32[0][1] = 4.0; + next.recomposed_glyphs = Some(RecomposedGlyphRange { + previous_start: 1, + previous_end: 2, + next_start: 1, + next_end: 2, + }); + let mut next_revision = 40; + next.assign_content_revisions(&previous, &mut IdentityIndex::default(), &mut next_revision) + .unwrap(); + assert_eq!( + next.glyphs + .iter() + .map(|glyph| glyph.content_revision) + .collect::>(), + [10, 40, 30] + ); + assert_eq!(next.semantic_change_masks, [0, 1, 0]); + assert_eq!(next_revision, 41); + } } From bd773c11a473f956288ddcc1924cc8bcceb35d43 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 16:34:52 -0400 Subject: [PATCH 104/128] perf(text): retain unaffected clusters --- docs/log.md | 7 + docs/packages/text.md | 2 +- docs/planning/rust-layout-engine.md | 8 + .../rust/shaper/src/engine/cluster_state.rs | 441 ++++++++++++++++++ packages/text/rust/shaper/src/engine/state.rs | 57 ++- 5 files changed, 499 insertions(+), 16 deletions(-) diff --git a/docs/log.md b/docs/log.md index 81812432..c514bd55 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-09 +- **Rebuilt clusters only for the incrementally shaped source run** — Exact grapheme/glyph topology now permits the + cluster builder to retain all other SoA lanes and rebuild the changed run's advances, bindings, glyph adjacency, + safe/break flags, and identities. The affected window includes its predecessor break because that decision depends on + the changed run's first safe-concatenation flag; any mismatch falls back cold. A field-for-field cold oracle covers + every retained lane. On 101 optimized updates, median/p95 improve from 6.894/9.314 to 5.881/8.406 ms with the same + five roughly 1.2 KiB patches. Optimized Wasm grows 7,633 raw bytes to 1,147,200; the p95 contract remains unmet. + - **Narrowed content-revision work to the recomposed line** — The line-convergence proof now carries exact old/new glyph spans into positioning. Retained prefix and suffix records preserve their revisions and publish zero semantic change masks; only the changed span compares fields or looks up stable identities. A focused test proves revisions diff --git a/docs/packages/text.md b/docs/packages/text.md index cb011c80..59c2a36a 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:328d24c65703db7d3aa96860a405455e3953bde03bfa3965e9f38cf18bf0a604' +source_digest: 'sha256:40dbd5bc3ded508f9dfc031f23b1b46e0a87885c099058b22ccb933f94622fa7' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index abcf78f8..0a661040 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1049,6 +1049,14 @@ field comparison and identity lookup. On the same 101-update optimized workload p95 to 6.894 / 9.314 ms. The 9.7% median reduction admits the range optimization, while the effectively unchanged p95 does not support a tail-latency claim. Optimized Wasm grows 1,693 raw bytes to 1,139,567. +The incremental shaping splice now identifies its affected source run for cluster aggregation. When grapheme and glyph +topology are exact, Rust copies retained cluster lanes, rebuilds only that run's advances, bindings, adjacency, safety, +break flags, and stable glyph identities, and also recomputes the predecessor break whose legality depends on the +run's first safe boundary. Any topology mismatch uses the cold builder. A field-for-field oracle proves the retained +result equals an independent cold rebuild. The same 101-update optimized workload improves from 6.894 / 9.314 ms +median/p95 to 5.881 / 8.406 ms, with five roughly 1.2 KiB patches. Optimized Wasm grows 7,633 raw bytes to 1,147,200. +This admits the retained aggregation path but remains above the 4 ms p95 contract. + The renderer's 25% instance slack is not the edit-storage design. Editing requires the selected ABI-private 64-cluster semantic chunks to reserve a small bounded gap so insert, delete, and replacement operations move only the affected chunk before summaries and downstream line state resume. The current production text, shape, cluster, and diff --git a/packages/text/rust/shaper/src/engine/cluster_state.rs b/packages/text/rust/shaper/src/engine/cluster_state.rs index 35e79bd1..94e75554 100644 --- a/packages/text/rust/shaper/src/engine/cluster_state.rs +++ b/packages/text/rust/shaper/src/engine/cluster_state.rs @@ -136,6 +136,308 @@ impl ClusterArena { Ok(()) } + pub(crate) fn rebuild_source_run_if_topology_is_stable( + &mut self, + previous: &Self, + input: ClusterBuildInput<'_>, + source_run: u32, + metrics_for: impl Fn(u32) -> Option, + ) -> Result, EngineError> { + let ClusterBuildInput { + text, + text_unit_ids, + unicode, + styles, + runs, + shape, + } = input; + let boundaries = unicode.grapheme_boundaries(); + if text.len() != text_unit_ids.len() + || text_unit_ids.contains(&0) + || previous.index_at.len() != text.len().saturating_add(1) + || boundaries.len().saturating_sub(1) != previous.starts.len() + || boundaries + .windows(2) + .zip(&previous.starts) + .any(|(pair, start)| pair[0] != *start) + || boundaries + .windows(2) + .zip(&previous.ends) + .any(|(pair, end)| pair[1] != *end) + || shape.glyph_ids.len() != previous.glyph_indices.len() + || [ + shape.clusters.len(), + shape.x_advances.len(), + shape.y_advances.len(), + shape.x_offsets.len(), + shape.y_offsets.len(), + shape.glyph_flags.len(), + ] + .iter() + .any(|length| *length != shape.glyph_ids.len()) + { + return Ok(None); + } + let source_index = usize::try_from(source_run).map_err(|_| EngineError::InvalidRequest)?; + let source = *runs.get(source_index).ok_or(EngineError::InvalidRequest)?; + let cluster_start = previous + .starts + .binary_search(&source.text_start) + .map_err(|_| EngineError::InvalidRequest)?; + let cluster_end = previous + .ends + .binary_search(&source.text_end) + .map(|index| index + 1) + .map_err(|_| EngineError::InvalidRequest)?; + if cluster_start >= cluster_end + || shape.runs.iter().any(|run| { + let start = usize::try_from(run.glyph_start).ok(); + let end = start.and_then(|start| { + usize::try_from(run.glyph_count) + .ok() + .and_then(|count| start.checked_add(count)) + }); + start.is_none() + || end.is_none() + || end.is_some_and(|end| end > shape.glyph_ids.len()) + }) + { + return Ok(None); + } + self.copy_from(previous)?; + for cluster in cluster_start..cluster_end { + let start = self.starts[cluster]; + let end = self.ends[cluster]; + let style_index = usize::try_from(self.style_indexes[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles.get(style_index).ok_or(EngineError::InvalidRequest)?; + if style.text_start > start || style.text_end < end { + self.clear(); + return Ok(None); + } + let hard_break = is_hard_break(text, start)?; + let word_spacing = if text.get(start as usize) == Some(&0x20) { + style.style.word_spacing + } else { + 0.0 + }; + self.advances[cluster] = if hard_break { + 0.0 + } else { + f64::from(style.style.letter_spacing + word_spacing) + }; + self.flags[cluster] = if hard_break { CLUSTER_HARD_BREAK } else { 0 }; + self.source_runs[cluster] = NO_SOURCE_RUN; + self.binding_handles[cluster] = 0; + self.font_handles[cluster] = 0; + self.stable_ids[cluster] = *text_unit_ids + .get(usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?) + .ok_or(EngineError::InvalidRequest)?; + self.glyph_counts[cluster] = 0; + self.shaped[cluster] = 0; + self.unsafe_before[cluster] = 0; + } + for shaped_run in shape.runs.iter().filter(|run| run.source_run == source_run) { + let metrics = metrics_for(shaped_run.font_handle).ok_or(EngineError::InvalidRequest)?; + if metrics.units_per_em == 0 { + return Err(EngineError::InvalidRequest); + } + let scale = f64::from(source.style.font_size) / f64::from(metrics.units_per_em); + let glyph_start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let glyph_end = glyph_start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in glyph_start..glyph_end { + let cluster = self.cluster_at(shape.clusters[glyph])?; + if cluster < cluster_start || cluster >= cluster_end { + return Err(EngineError::InvalidRequest); + } + if self.source_runs[cluster] == NO_SOURCE_RUN { + self.source_runs[cluster] = source_run; + self.binding_handles[cluster] = shaped_run.binding_handle; + self.font_handles[cluster] = shaped_run.font_handle; + } else if self.source_runs[cluster] != source_run + || self.binding_handles[cluster] != shaped_run.binding_handle + || self.font_handles[cluster] != shaped_run.font_handle + { + return Err(EngineError::InvalidRequest); + } + self.shaped[cluster] = 1; + self.glyph_counts[cluster] = self.glyph_counts[cluster] + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + self.unsafe_before[cluster] |= + u8::from(shape.glyph_flags[glyph] & GLYPH_UNSAFE_TO_BREAK != 0); + self.advances[cluster] += f64::from(shape.x_advances[glyph].unsigned_abs()) * scale; + } + } + let adjacency_start = usize::try_from(previous.glyph_starts[cluster_start]) + .map_err(|_| EngineError::InvalidRequest)?; + let adjacency_end = usize::try_from(previous.glyph_starts[cluster_end - 1]) + .ok() + .and_then(|start| { + usize::try_from(previous.glyph_counts[cluster_end - 1]) + .ok() + .and_then(|count| start.checked_add(count)) + }) + .ok_or(EngineError::InvalidRequest)?; + let mut cursor = adjacency_start; + for cluster in cluster_start..cluster_end { + self.glyph_starts[cluster] = + u32::try_from(cursor).map_err(|_| EngineError::ResultTooLarge)?; + cursor = cursor + .checked_add( + usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::ResultTooLarge)?; + self.glyph_counts[cluster] = 0; + } + if cursor != adjacency_end { + self.clear(); + return Ok(None); + } + for shaped_run in shape.runs.iter().filter(|run| run.source_run == source_run) { + let glyph_start = + usize::try_from(shaped_run.glyph_start).map_err(|_| EngineError::InvalidRequest)?; + let glyph_end = glyph_start + .checked_add( + usize::try_from(shaped_run.glyph_count) + .map_err(|_| EngineError::InvalidRequest)?, + ) + .ok_or(EngineError::InvalidRequest)?; + for glyph in glyph_start..glyph_end { + let cluster = self.cluster_at(shape.clusters[glyph])?; + let ordinal = usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let destination = usize::try_from(self.glyph_starts[cluster]) + .ok() + .and_then(|start| start.checked_add(ordinal)) + .ok_or(EngineError::ResultTooLarge)?; + self.glyph_indices[destination] = + u32::try_from(glyph).map_err(|_| EngineError::ResultTooLarge)?; + self.glyph_counts[cluster] = self.glyph_counts[cluster] + .checked_add(1) + .ok_or(EngineError::ResultTooLarge)?; + } + } + for cluster in cluster_start..cluster_end { + if self.shaped[cluster] != 0 && self.unsafe_before[cluster] == 0 { + self.flags[cluster] |= CLUSTER_SAFE_BEFORE; + } + } + if cluster_start > 0 { + self.flags[cluster_start - 1] &= !CLUSTER_ALLOWED_BREAK; + } + for line_break in unicode.line_breaks() { + let Ok(preceding) = self.ends.binary_search(&line_break.position) else { + continue; + }; + if preceding < cluster_start.saturating_sub(1) || preceding >= cluster_end { + continue; + } + if line_break.required { + self.flags[preceding] |= CLUSTER_REQUIRED_BREAK; + } else { + let safe = line_break.position == self.ends.last().copied().unwrap_or(0) + || self + .starts + .binary_search(&line_break.position) + .ok() + .is_some_and(|next| self.flags[next] & CLUSTER_SAFE_BEFORE != 0); + if safe { + self.flags[preceding] |= CLUSTER_ALLOWED_BREAK; + } + } + } + Ok(Some((cluster_start, cluster_end))) + } + + fn copy_from(&mut self, source: &Self) -> Result<(), EngineError> { + self.clear(); + self.reserve(source.starts.len())?; + reserve(&mut self.glyph_indices, source.glyph_indices.len())?; + reserve(&mut self.glyph_stable_ids, source.glyph_stable_ids.len())?; + reserve(&mut self.index_at, source.index_at.len())?; + macro_rules! copy_lane { + ($field:ident) => { + self.$field.extend_from_slice(&source.$field); + }; + } + copy_lane!(starts); + copy_lane!(ends); + copy_lane!(advances); + copy_lane!(flags); + copy_lane!(style_indexes); + copy_lane!(source_runs); + copy_lane!(binding_handles); + copy_lane!(font_handles); + copy_lane!(stable_ids); + copy_lane!(glyph_starts); + copy_lane!(glyph_counts); + copy_lane!(glyph_indices); + copy_lane!(glyph_stable_ids); + copy_lane!(index_at); + copy_lane!(shaped); + copy_lane!(unsafe_before); + Ok(()) + } + + pub(crate) fn assign_stable_glyph_ids_in_range( + &mut self, + previous: &Self, + cluster_start: usize, + cluster_end: usize, + index: &mut IdentityIndex, + next_id: &mut u32, + ) -> Result<(), EngineError> { + index + .prepare(cluster_end.saturating_sub(cluster_start)) + .map_err(identity_index_error)?; + for cluster in cluster_start..cluster_end { + index + .insert( + previous.stable_ids[cluster], + u32::try_from(cluster).map_err(|_| EngineError::ResultTooLarge)?, + ) + .map_err(identity_index_error)?; + } + *next_id = (*next_id).max(1); + for cluster in cluster_start..cluster_end { + let new_start = usize::try_from(self.glyph_starts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let new_count = usize::try_from(self.glyph_counts[cluster]) + .map_err(|_| EngineError::InvalidRequest)?; + let previous_cluster = index + .get(self.stable_ids[cluster]) + .and_then(|value| usize::try_from(value).ok()); + let previous_start = previous_cluster + .and_then(|cluster| previous.glyph_starts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + let previous_count = previous_cluster + .and_then(|cluster| previous.glyph_counts.get(cluster)) + .copied() + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(0); + for ordinal in 0..new_count { + self.glyph_stable_ids[new_start + ordinal] = if ordinal < previous_count { + previous.glyph_stable_ids[previous_start + ordinal] + } else { + let allocated = *next_id; + *next_id = next_id.checked_add(1).ok_or(EngineError::ResultTooLarge)?; + allocated + }; + } + } + Ok(()) + } + pub(crate) fn assign_stable_glyph_ids( &mut self, previous: &Self, @@ -577,4 +879,143 @@ mod tests { assert_eq!(next_id, 6); assert_eq!(index.capacities(), capacities); } + + #[test] + fn retained_source_run_rebuild_matches_the_cold_cluster_oracle() { + let old_text: Vec = "ab".encode_utf16().collect(); + let new_text: Vec = "ac".encode_utf16().collect(); + let mut old_unicode = UnicodeAnalysis::default(); + old_unicode.analyze(&old_text).unwrap(); + let mut new_unicode = UnicodeAnalysis::default(); + new_unicode.analyze(&new_text).unwrap(); + let style = ResolvedStyle::test_typography(16.0, 1.0, 0.0); + let styles = [StyleSegment { + text_start: 0, + text_end: 2, + style, + }]; + let runs = [ShapingRun { + text_start: 0, + text_end: 2, + script: u32::from_be_bytes(*b"Latn"), + direction: 4, + bidi_level: 0, + style, + }]; + let make_shape = |second_glyph, second_advance| ShapeArena { + runs: vec![ShapedRun { + source_run: 0, + binding_handle: 19, + font_handle: 9, + text_start: 0, + text_end: 2, + glyph_start: 0, + glyph_count: 2, + }], + glyph_ids: vec![1, second_glyph], + clusters: vec![0, 1], + x_advances: vec![500, second_advance], + y_advances: vec![0; 2], + x_offsets: vec![0; 2], + y_offsets: vec![0; 2], + glyph_flags: vec![0; 2], + }; + let old_shape = make_shape(2, 500); + let new_shape = make_shape(3, 600); + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = ClusterArena::default(); + previous + .build( + ClusterBuildInput { + text: &old_text, + text_unit_ids: &[10, 20], + unicode: &old_unicode, + styles: &styles, + runs: &runs, + shape: &old_shape, + }, + metrics, + ) + .unwrap(); + let mut next_id = 1; + previous + .assign_stable_glyph_ids( + &ClusterArena::default(), + &mut IdentityIndex::default(), + &mut next_id, + ) + .unwrap(); + let mut cold = ClusterArena::default(); + cold.build( + ClusterBuildInput { + text: &new_text, + text_unit_ids: &[10, 30], + unicode: &new_unicode, + styles: &styles, + runs: &runs, + shape: &new_shape, + }, + metrics, + ) + .unwrap(); + let mut cold_next_id = next_id; + cold.assign_stable_glyph_ids(&previous, &mut IdentityIndex::default(), &mut cold_next_id) + .unwrap(); + let mut retained = ClusterArena::default(); + let (cluster_start, cluster_end) = retained + .rebuild_source_run_if_topology_is_stable( + &previous, + ClusterBuildInput { + text: &new_text, + text_unit_ids: &[10, 30], + unicode: &new_unicode, + styles: &styles, + runs: &runs, + shape: &new_shape, + }, + 0, + metrics, + ) + .unwrap() + .unwrap(); + let mut retained_next_id = next_id; + retained + .assign_stable_glyph_ids_in_range( + &previous, + cluster_start, + cluster_end, + &mut IdentityIndex::default(), + &mut retained_next_id, + ) + .unwrap(); + macro_rules! assert_lane { + ($field:ident) => { + assert_eq!(retained.$field, cold.$field, stringify!($field)); + }; + } + assert_lane!(starts); + assert_lane!(ends); + assert_lane!(advances); + assert_lane!(flags); + assert_lane!(style_indexes); + assert_lane!(source_runs); + assert_lane!(binding_handles); + assert_lane!(font_handles); + assert_lane!(stable_ids); + assert_lane!(glyph_starts); + assert_lane!(glyph_counts); + assert_lane!(glyph_indices); + assert_lane!(glyph_stable_ids); + assert_lane!(index_at); + assert_lane!(shaped); + assert_lane!(unsafe_before); + assert_eq!(retained_next_id, cold_next_id); + } } diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 28bb82f3..19173df5 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -160,6 +160,7 @@ struct ParagraphState { pending_shaping_runs: ShapingRunArena, shape: ShapeArena, pending_shape: ShapeArena, + incremental_shape_source_run: Option, clusters: ClusterArena, pending_clusters: ClusterArena, glyph_identity_index: IdentityIndex, @@ -1212,6 +1213,7 @@ impl ParagraphState { self.pending_shaping_runs.clear(); self.shape.clear(); self.pending_shape.clear(); + self.incremental_shape_source_run = None; self.clusters.clear(); self.pending_clusters.clear(); self.geometry.clear(); @@ -1963,6 +1965,7 @@ impl ParagraphState { }); } self.boundary_shape_scratch.clear(); + self.incremental_shape_source_run = Some(affected_source_run); Ok(true) } @@ -1971,6 +1974,7 @@ impl ParagraphState { self.pending_fallback_spans.clear(); self.fallback_span_scratch.clear(); self.fallback_cluster_scratch.clear(); + self.incremental_shape_source_run = None; self.shape_prepared = false; } @@ -2021,21 +2025,44 @@ impl ParagraphState { self.clusters_prepared = true; return Ok(()); } - self.pending_clusters.build( - ClusterBuildInput { - text, - text_unit_ids, - unicode, - styles, - runs, - shape: if self.shape_prepared { - &self.pending_shape - } else { - &self.shape - }, - }, - |handle| shaper.font_metrics(handle), - )?; + let shape = if self.shape_prepared { + &self.pending_shape + } else { + &self.shape + }; + let build_input = || ClusterBuildInput { + text, + text_unit_ids, + unicode, + styles, + runs, + shape, + }; + if let Some(source_run) = self.incremental_shape_source_run + && let Some((cluster_start, cluster_end)) = self + .pending_clusters + .rebuild_source_run_if_topology_is_stable( + &self.clusters, + build_input(), + source_run, + |handle| shaper.font_metrics(handle), + )? + { + if let Err(error) = self.pending_clusters.assign_stable_glyph_ids_in_range( + &self.clusters, + cluster_start, + cluster_end, + &mut self.glyph_identity_index, + next_glyph_id, + ) { + self.abort_clusters(); + return Err(error); + } + self.clusters_prepared = true; + return Ok(()); + } + self.pending_clusters + .build(build_input(), |handle| shaper.font_metrics(handle))?; if let Err(error) = self.pending_clusters.assign_stable_glyph_ids( &self.clusters, &mut self.glyph_identity_index, From 03293e5ccd8c29c2fefd283e627190f92d3bdbcd Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 17:06:21 -0400 Subject: [PATCH 105/128] perf(text): converge narrow text edits --- docs/log.md | 9 + docs/packages/text.md | 12 +- docs/planning/rust-layout-engine.md | 23 +- .../shaper/src/engine/flow_composition.rs | 239 ++++++++++++------ packages/text/rust/shaper/src/engine/state.rs | 89 +++++-- packages/text/rust/shaper/src/unicode.rs | 39 +++ 6 files changed, 300 insertions(+), 111 deletions(-) diff --git a/docs/log.md b/docs/log.md index c514bd55..a9ec1348 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-09 +- **Continued recomposition until the edited line cursor actually converges** — The first retained-line proof required + a whole recomposed line to equal its predecessor, which made convergence after a shifted line boundary impossible. + Rust now recomposes consecutive old bands until cursor, height, and baseline return to the retained ending state, then + reuses the suffix. A three-line regression transfers advance across one boundary and converges at the next. Exact + equal-length ASCII-letter edits also retain structurally invariant Unicode and bidi results; punctuation, spacing, + non-ASCII, and structural edits remain on the complete analysis path. On the unchanged 101-update optimized workload, + median/p95 improve from 5.881/8.406 to 2.607/6.184 ms and RSD falls to 42.4%, with five roughly 1.2 KiB patches. + Optimized Wasm grows 66 raw bytes to 1,147,266. The break-sensitive p95 remains above the 4 ms contract. + - **Rebuilt clusters only for the incrementally shaped source run** — Exact grapheme/glyph topology now permits the cluster builder to retain all other SoA lanes and rebuild the changed run's advances, bindings, glyph adjacency, safe/break flags, and identities. The affected window includes its predecessor break because that decision depends on diff --git a/docs/packages/text.md b/docs/packages/text.md index 59c2a36a..e5544128 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:40dbd5bc3ded508f9dfc031f23b1b46e0a87885c099058b22ccb933f94622fa7' +source_digest: 'sha256:430487ab5eff88d211cc0eb9d998c40893e7e63751380d0d3b7c3e1796d6456e' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -179,7 +179,8 @@ their final collection occurs in the owning realm. It does not restore the delet The foundation currently has: -- 139 passing Rust engine tests after the semantic query corrections; +- 148 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and + later cursor-convergence regressions; - the package JavaScript/integration gate passing through the single-path public exports; - exact retained Amiri bidi, policy, ellipsis, clipping, UIKit-layout, and CJK contracts exercised by the browser `paragraph-contracts` target through public `FontLoader`, `Text`, `TextGroup`, `measureLayout()`, and `inspectLayout()`; @@ -223,6 +224,13 @@ unit, default-on `simd128`, stripping, and `wasm-opt -Oz --enable-simd` have alr Binaryen `-O3` and `-O4` added 11,976 and 13,661 raw bytes without a demonstrated latency improvement, so `-Oz` remains the evidence-backed setting. The `<4 ms` warm-path target and stable p95 closure remain open. +The latest unchanged 22,000-glyph localized-edit lane measures the complete production `text_update` plus Bitmap render +plan at 2.607 ms median / 6.184 ms p95 after 40 warmups over 101 updates. The fast ASCII-letter path reuses Unicode and +bidi state and recomposes until the line cursor converges; punctuation and spacing edits deliberately retain the full +break-sensitive path, so the 42.4% RSD describes remaining workload classes rather than a completed latency result. The +optimized SIMD shaper is 1,147,266 raw bytes. Five patches write roughly 1.2 KiB per update, and the retained high-water +mark remains 80.38 MiB. Median is now below 4 ms, but p95 and memory-growth gates remain open. + ## Merge gates still open Before the foundation stack is publishable: diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 0a661040..fdc8fb8e 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1027,11 +1027,11 @@ that shaping alone cannot meet the contract: cluster rebuilding, line compositio remain global, and the ordinary eight-warmup lane still catches a later 1,114,112-byte memory growth. Both are open failures, not reasons to weaken the gates. -The next checkpoint adds an exact one-line convergence proof for same-length localized edits. With geometry, font -metrics, limits, and ellipsis behavior unchanged, Rust recomposes the line containing the edit and compares its ending -cluster cursor, metrics, slots, fragments, hard-break state, and stable boundary identities with the retained line. A -match permits the prefix and suffix lines—and their positioned glyph records—to be retained; any mismatch discards the -partial result and runs the complete composer. A 101-update production run after 40 warmups improves the same workload +The next checkpoint added the first one-line convergence proof for same-length localized edits. With geometry, font +metrics, limits, and ellipsis behavior unchanged, Rust recomposed the line containing the edit and initially required +the complete line record to match the retained line. A match permitted the prefix and suffix lines—and their positioned +glyph records—to be retained; any mismatch discarded the partial result and ran the complete composer. A 101-update +production run after 40 warmups improved the same workload from the preceding 9.372 ms checkpoint to 7.668 ms median, an 18.2% end-to-end reduction, with 9.620 ms p95 and five roughly 1.2 KiB command-buffer patches. The optimized Wasm grows from 1,131,457 to 1,138,394 raw bytes (+6,937). This remains above budget. Cluster reconstruction, revision assignment, and plan gathering remain broad, and the retained @@ -1057,6 +1057,19 @@ result equals an independent cold rebuild. The same 101-update optimized workloa median/p95 to 5.881 / 8.406 ms, with five roughly 1.2 KiB patches. Optimized Wasm grows 7,633 raw bytes to 1,147,200. This admits the retained aggregation path but remains above the 4 ms p95 contract. +The composer now continues across old line bands until the new cursor reaches an old line's ending cluster with the +same height and baseline. Only that ending state controls whether the following line may be retained: requiring the +recomposed line's start and fragments to equal the old line made later convergence impossible after a boundary moved. +An exact three-line fixture transfers advance across the edited boundary, changes the second line from clusters +`[3,6)` to `[3,5)`, and proves the next recomposed line reaches cluster 9 before the suffix is retained. Equal-length +ASCII-letter replacement also reuses Unicode and bidi state because UTF-16 length, grapheme boundaries, Latin script, +UAX #14 class, and bidi class cannot change under that exact guard; spaces, punctuation, non-ASCII text, and structural +edits continue through complete analysis. On the unchanged production benchmark, 40 warmups and 101 measured updates +improve the preceding 5.881 / 8.406 ms median/p95 to 2.607 / 6.184 ms. RSD falls from the previously observed 137.6% +split to 42.4%, while five roughly 1.2 KiB patches remain. The 1,147,266-byte optimized Wasm is 66 bytes larger than +the retained-cluster checkpoint. The median meets the 4 ms ceiling, but the break-sensitive p95 does not; retained +policy gathering, plan compilation, and chunk-local insert/delete storage remain open. + The renderer's 25% instance slack is not the edit-storage design. Editing requires the selected ABI-private 64-cluster semantic chunks to reserve a small bounded gap so insert, delete, and replacement operations move only the affected chunk before summaries and downstream line state resume. The current production text, shape, cluster, and diff --git a/packages/text/rust/shaper/src/engine/flow_composition.rs b/packages/text/rust/shaper/src/engine/flow_composition.rs index b9864661..59043d91 100644 --- a/packages/text/rust/shaper/src/engine/flow_composition.rs +++ b/packages/text/rust/shaper/src/engine/flow_composition.rs @@ -210,7 +210,7 @@ impl FlowLayoutArena { } #[allow(clippy::too_many_arguments)] - pub(crate) fn rebuild_one_line_if_state_converges( + pub(crate) fn rebuild_until_state_converges( &mut self, previous: &Self, geometry: &FlowGeometryArena, @@ -250,95 +250,84 @@ impl FlowLayoutArena { }) else { return Ok(false); }; - let old_line = previous.lines[line_index]; - let old_fragments = line_fragments(previous, old_line)?; + let first_line = previous.lines[line_index]; + let old_fragments = line_fragments(previous, first_line)?; let Some(first_fragment) = old_fragments.first() else { return Ok(false); }; - let Some(last_fragment) = old_fragments.last() else { - return Ok(false); - }; let cluster_start = usize::try_from(first_fragment.line.cluster_start) .map_err(|_| EngineError::InvalidRequest)?; - let old_cluster_end = usize::try_from(last_fragment.line.cluster_end) - .map_err(|_| EngineError::InvalidRequest)?; if previous_clusters.stable_ids.get(cluster_start) != clusters.stable_ids.get(cluster_start) - || (old_cluster_end < clusters.stable_ids.len() - && previous_clusters.stable_ids.get(old_cluster_end) - != clusters.stable_ids.get(old_cluster_end)) { return Ok(false); } - let Some(region_index) = geometry - .regions - .iter() - .position(|region| region.record.id == old_line.region_id) - else { - return Ok(false); - }; - let region = geometry - .regions - .get(region_index) - .ok_or(EngineError::InvalidRequest)?; for prefix in 0..line_index { self.append_retained_line(previous, prefix)?; } let mut cursor = LineCursor::at_cluster(cluster_start); - let composed_start = self.fragments.len(); - let Some(height) = self.compose_band( - geometry, - region_index, - old_line.flow_thread_id, - old_line.region_id, - old_line.transform_index, - old_line.clip_id, - clusters, - styles, - slots, - &mut cursor, - old_line.block_start, - f64::from(region.record.block_end), - LineExtents { - above: old_line.baseline, - below: old_line.height - old_line.baseline, - }, - wrapping_for_flow_thread(geometry, old_line.flow_thread_id)?, - old_line.align, - max_slots_per_band, - metrics_for, - first_font_for_stack, - )? - else { - self.clear(); - return Ok(false); - }; - let new_line = *self.lines.last().ok_or(EngineError::InvalidRequest)?; - let new_fragments = self - .fragments - .get(composed_start..) - .ok_or(EngineError::InvalidRequest)?; - let converged = cursor.cluster() == old_cluster_end - && height == old_line.height - && new_line.baseline == old_line.baseline - && new_fragments.len() == old_fragments.len() - && new_fragments.iter().zip(old_fragments).all(|(new, old)| { - new.slot_start == old.slot_start - && new.slot_end == old.slot_end - && new.line.cluster_start == old.line.cluster_start - && new.line.cluster_end == old.line.cluster_end - && new.line.text_start == old.line.text_start - && new.line.text_end == old.line.text_end - && new.line.hard_break == old.line.hard_break - }); - if !converged { - self.clear(); - return Ok(false); - } - for suffix in line_index + 1..previous.lines.len() { - self.append_retained_line(previous, suffix)?; + for candidate in line_index..previous.lines.len() { + let old_line = previous.lines[candidate]; + let old_fragments = line_fragments(previous, old_line)?; + let old_cluster_end = old_fragments + .last() + .and_then(|fragment| usize::try_from(fragment.line.cluster_end).ok()) + .ok_or(EngineError::InvalidRequest)?; + let Some(region_index) = geometry + .regions + .iter() + .position(|region| region.record.id == old_line.region_id) + else { + self.clear(); + return Ok(false); + }; + let region = geometry + .regions + .get(region_index) + .ok_or(EngineError::InvalidRequest)?; + let Some(height) = self.compose_band( + geometry, + region_index, + old_line.flow_thread_id, + old_line.region_id, + old_line.transform_index, + old_line.clip_id, + clusters, + styles, + slots, + &mut cursor, + old_line.block_start, + f64::from(region.record.block_end), + LineExtents { + above: old_line.baseline, + below: old_line.height - old_line.baseline, + }, + wrapping_for_flow_thread(geometry, old_line.flow_thread_id)?, + old_line.align, + max_slots_per_band, + metrics_for, + first_font_for_stack, + )? + else { + self.clear(); + return Ok(false); + }; + let new_line = *self.lines.last().ok_or(EngineError::InvalidRequest)?; + let metrics_stable = + height == old_line.height && new_line.baseline == old_line.baseline; + if metrics_stable && cursor.cluster() == old_cluster_end { + for suffix in candidate + 1..previous.lines.len() { + self.append_retained_line(previous, suffix)?; + } + self.recomposed_lines = Some((line_index, candidate + 1)); + return Ok(true); + } + if !metrics_stable { + self.clear(); + return Ok(false); + } } - self.recomposed_lines = Some((line_index, line_index + 1)); - Ok(true) + self.clear(); + Ok(false) } fn append_retained_line( @@ -955,7 +944,7 @@ mod tests { let mut changed = FlowLayoutArena::default(); assert!( changed - .rebuild_one_line_if_state_converges( + .rebuild_until_state_converges( &previous, &geometry, &previous_clusters, @@ -976,6 +965,100 @@ mod tests { assert_eq!(changed.fragments[2], retained_suffix); } + #[test] + fn localized_edit_recomposes_multiple_lines_until_cursor_state_converges() { + let make_clusters = |advances: Vec| ClusterArena { + starts: (0..9).collect(), + ends: (1..10).collect(), + advances, + flags: vec![CLUSTER_SAFE_BEFORE; 9], + style_indexes: vec![0; 9], + source_runs: vec![0; 9], + font_handles: vec![1; 9], + stable_ids: (1..10).collect(), + index_at: (0..10).collect(), + ..ClusterArena::default() + }; + let previous_clusters = make_clusters(vec![2.0; 9]); + let changed_clusters = make_clusters(vec![2.0, 2.0, 2.0, 3.0, 3.0, 1.0, 1.0, 2.0, 2.0]); + let styles = [StyleSegment { + text_start: 0, + text_end: 9, + style: ResolvedStyle::test_typography(10.0, 0.0, 0.0), + }]; + let mut narrow_region = region(); + narrow_region.inline_end = 6.0; + narrow_region.clip_inline_end = 6.0; + narrow_region.exclusion_count = 0; + let geometry = FlowGeometryArena { + constraints: vec![constraint()], + regions: vec![RetainedRegion { + record: narrow_region, + vertex_start: 0, + }], + ..FlowGeometryArena::default() + }; + let metrics = |_| { + Some(FontMetrics { + units_per_em: 1_000, + ascender: 800, + descender: -200, + line_gap: 0, + }) + }; + let mut previous = FlowLayoutArena::default(); + previous + .build( + &geometry, + &previous_clusters, + &styles, + &mut InlineSlotArena::default(), + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap(); + assert_eq!( + previous + .fragments + .iter() + .map(|fragment| fragment.line.cluster_end) + .collect::>(), + [3, 6, 9] + ); + + let retained_prefix = previous.fragments[0]; + let mut changed = FlowLayoutArena::default(); + assert!( + changed + .rebuild_until_state_converges( + &previous, + &geometry, + &previous_clusters, + &changed_clusters, + &styles, + &mut InlineSlotArena::default(), + 3, + 8, + 1, + metrics, + |_| Some(1), + ) + .unwrap() + ); + assert_eq!(changed.fragments[0], retained_prefix); + assert_eq!( + changed + .fragments + .iter() + .map(|fragment| (fragment.line.cluster_start, fragment.line.cluster_end)) + .collect::>(), + [(0, 3), (3, 5), (5, 9)] + ); + assert_eq!(changed.recomposed_line_range(), Some((1, 3))); + } + #[test] fn localized_edit_clears_partial_layout_when_line_state_does_not_converge() { let make_clusters = |advances: Vec| ClusterArena { @@ -1033,7 +1116,7 @@ mod tests { let mut changed = FlowLayoutArena::default(); assert!( !changed - .rebuild_one_line_if_state_converges( + .rebuild_until_state_converges( &previous, &geometry, &previous_clusters, diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 19173df5..e907f020 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -154,6 +154,7 @@ struct ParagraphState { pending_resolved_styles: ResolvedStyleArena, unicode: UnicodeAnalysis, pending_unicode: UnicodeAnalysis, + unicode_reused_for_text_edit: bool, bidi: BidiAnalysis, pending_bidi: BidiAnalysis, shaping_runs: ShapingRunArena, @@ -1207,6 +1208,7 @@ impl ParagraphState { self.pending_resolved_styles.clear(); self.unicode.clear(); self.pending_unicode.clear(); + self.unicode_reused_for_text_edit = false; self.bidi.clear(); self.pending_bidi.clear(); self.shaping_runs.clear(); @@ -1560,6 +1562,18 @@ impl ParagraphState { if !self.text_prepared { return Ok(()); } + if let Some(edit) = self.text_edit + && self.unicode.reusable_for_ascii_letter_edit( + &self.text, + &self.pending_text, + edit.old_start, + edit.old_end, + edit.new_end, + ) + { + self.unicode_reused_for_text_edit = true; + return Ok(()); + } self.pending_unicode .analyze(&self.pending_text) .map_err(unicode_error)?; @@ -1569,6 +1583,7 @@ impl ParagraphState { fn abort_unicode(&mut self) { self.unicode_prepared = false; + self.unicode_reused_for_text_edit = false; } fn commit_unicode(&mut self) { @@ -1580,6 +1595,9 @@ impl ParagraphState { fn prepare_bidi(&mut self) -> Result<(), EngineError> { self.abort_bidi(); + if self.unicode_reused_for_text_edit && !self.style_invalidation.bidi { + return Ok(()); + } if !self.text_prepared && !self.style_invalidation.bidi { return Ok(()); } @@ -2182,32 +2200,30 @@ impl ParagraphState { && let Some(edit) = self.text_edit && edit.old_end.saturating_sub(edit.old_start) == edit.new_end.saturating_sub(edit.old_start) - && self - .pending_flow_layout - .rebuild_one_line_if_state_converges( - &self.flow_layout, - geometry, - &self.clusters, - clusters, - styles, - &mut self.flow_slot_scratch, - u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?, - max_lines, - max_slots_per_band, - |handle| shaper.font_metrics(handle), - |stack_handle| { - font_stacks - .binary_search_by_key(&stack_handle, |stack| stack.handle) - .ok() - .and_then(|index| font_stacks[index].fonts.first().copied()) - .and_then(|handle| { - font_bindings - .iter() - .find(|binding| binding.handle == handle) - .map(|binding| binding.shaping_handle) - }) - }, - )? + && self.pending_flow_layout.rebuild_until_state_converges( + &self.flow_layout, + geometry, + &self.clusters, + clusters, + styles, + &mut self.flow_slot_scratch, + u32::try_from(edit.old_start).map_err(|_| EngineError::ResultTooLarge)?, + max_lines, + max_slots_per_band, + |handle| shaper.font_metrics(handle), + |stack_handle| { + font_stacks + .binary_search_by_key(&stack_handle, |stack| stack.handle) + .ok() + .and_then(|index| font_stacks[index].fonts.first().copied()) + .and_then(|handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| binding.shaping_handle) + }) + }, + )? { self.flow_layout_prepared = true; return Ok(()); @@ -3643,6 +3659,27 @@ mod tests { assert!(paragraph.bidi.levels.is_empty()); } + #[test] + fn ascii_text_reuse_does_not_suppress_an_independent_bidi_invalidation() { + let mut paragraph = ParagraphState::default(); + paragraph.text = "abc".encode_utf16().collect(); + paragraph.pending_text = "axc".encode_utf16().collect(); + paragraph.text_prepared = true; + paragraph.text_edit = Some(TextEdit { + old_start: 1, + old_end: 2, + new_end: 2, + }); + paragraph.style_invalidation.bidi = true; + paragraph.unicode.analyze(¶graph.text).unwrap(); + + paragraph.prepare_unicode().unwrap(); + assert!(paragraph.unicode_reused_for_text_edit); + paragraph.prepare_bidi().unwrap(); + assert!(paragraph.bidi_prepared); + assert_eq!(paragraph.pending_bidi.paragraph_levels, [0]); + } + #[test] fn root_direction_reanalyzes_bidi_without_a_text_mutation() { let mut engine = TextEngine::default(); diff --git a/packages/text/rust/shaper/src/unicode.rs b/packages/text/rust/shaper/src/unicode.rs index a129d77d..95fc1bd8 100644 --- a/packages/text/rust/shaper/src/unicode.rs +++ b/packages/text/rust/shaper/src/unicode.rs @@ -81,6 +81,29 @@ impl UnicodeAnalysis { self.line_breaks.breaks() } + pub(crate) fn reusable_for_ascii_letter_edit( + &self, + previous: &[u16], + next: &[u16], + start: usize, + previous_end: usize, + next_end: usize, + ) -> bool { + previous.len() == next.len() + && previous_end.saturating_sub(start) == next_end.saturating_sub(start) + && previous_end <= previous.len() + && next_end <= next.len() + && previous + .get(start..previous_end) + .zip(next.get(start..next_end)) + .is_some_and(|(previous, next)| { + !previous.is_empty() + && previous.iter().zip(next).all(|(previous, next)| { + is_ascii_letter(*previous) && is_ascii_letter(*next) + }) + }) + } + pub(crate) fn clear(&mut self) { self.utf8.clear(); self.line_breaks.clear(); @@ -295,6 +318,10 @@ fn is_neutral_script(script: u32) -> bool { matches!(script, COMMON_SCRIPT | INHERITED_SCRIPT | UNKNOWN_SCRIPT) } +fn is_ascii_letter(unit: u16) -> bool { + matches!(unit, 0x41..=0x5a | 0x61..=0x7a) +} + #[cfg(test)] mod tests { use super::*; @@ -364,4 +391,16 @@ mod tests { assert_eq!(analysis.grapheme_boundaries.capacity(), boundary_capacity); assert_eq!(analysis.analyze(&[0xd800]), Err(UnicodeError::InvalidUtf16)); } + + #[test] + fn only_equal_length_ascii_letter_edits_reuse_unicode_structure() { + let analysis = UnicodeAnalysis::default(); + let previous: Vec = "alpha".encode_utf16().collect(); + let letters: Vec = "aloha".encode_utf16().collect(); + let digit: Vec = "al0ha".encode_utf16().collect(); + assert!(analysis.reusable_for_ascii_letter_edit(&previous, &letters, 2, 3, 3)); + assert!(!analysis.reusable_for_ascii_letter_edit(&previous, &digit, 2, 3, 3)); + assert!(!analysis.reusable_for_ascii_letter_edit(&previous, &letters, 2, 4, 3)); + assert!(!analysis.reusable_for_ascii_letter_edit(&previous, &letters, 6, 7, 7)); + } } From 05ff68015d3ce5e0dec1e92afce3c02a44ad99e6 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 17:38:04 -0400 Subject: [PATCH 106/128] perf(text): retain gathered policy inputs --- docs/log.md | 9 + docs/packages/text.md | 13 +- docs/planning/rust-layout-engine.md | 14 + .../rust/shaper/src/engine/policy_gather.rs | 438 ++++++++++++++++-- packages/text/rust/shaper/src/engine/state.rs | 243 ++++++++-- 5 files changed, 647 insertions(+), 70 deletions(-) diff --git a/docs/log.md b/docs/log.md index a9ec1348..7a133024 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-09 +- **Retained policy inputs and rebuilt only from the first storage mismatch** — Gathered field-major policy inputs now + commit under the exact session revision, policy fingerprint, and capability set. A one-byte selection lane skips + binding/resource/policy work for zero-change glyphs; changed records update only reachable fields. Identity replacement + stays in the same physical topology, while technique, program, resource, transform, material, clip, or depth changes + retain the verified prefix and fully gather the suffix. Commit/abort and disposal tests cover cache lifecycle, and an + oracle proves identity/field updates plus a material-triggered suffix rebuild. The unchanged optimized 101-update lane + improves from 2.607/6.184 to 1.314/5.863 ms median/p95 with five roughly 1.2 KiB patches. RSD remains 76.2%, so the + break-sensitive tail is open. Optimized Wasm grows 5,856 bytes to 1,153,122; retained high-water memory is 80.19 MiB. + - **Continued recomposition until the edited line cursor actually converges** — The first retained-line proof required a whole recomposed line to equal its predecessor, which made convergence after a shifted line boundary impossible. Rust now recomposes consecutive old bands until cursor, height, and baseline return to the retained ending state, then diff --git a/docs/packages/text.md b/docs/packages/text.md index e5544128..47ac1ecd 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:430487ab5eff88d211cc0eb9d998c40893e7e63751380d0d3b7c3e1796d6456e' +source_digest: 'sha256:7f665fbe53b5942b4c6624c4dae010936fbafd8ae9a888dcf29ae8bd7fe72b21' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -179,7 +179,7 @@ their final collection occurs in the owning realm. It does not restore the delet The foundation currently has: -- 148 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and +- 149 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and later cursor-convergence regressions; - the package JavaScript/integration gate passing through the single-path public exports; - exact retained Amiri bidi, policy, ellipsis, clipping, UIKit-layout, and CJK contracts exercised by the browser @@ -224,13 +224,20 @@ unit, default-on `simd128`, stripping, and `wasm-opt -Oz --enable-simd` have alr Binaryen `-O3` and `-O4` added 11,976 and 13,661 raw bytes without a demonstrated latency improvement, so `-Oz` remains the evidence-backed setting. The `<4 ms` warm-path target and stable p95 closure remain open. -The latest unchanged 22,000-glyph localized-edit lane measures the complete production `text_update` plus Bitmap render +The preceding unchanged 22,000-glyph localized-edit lane measured the complete production `text_update` plus Bitmap render plan at 2.607 ms median / 6.184 ms p95 after 40 warmups over 101 updates. The fast ASCII-letter path reuses Unicode and bidi state and recomposes until the line cursor converges; punctuation and spacing edits deliberately retain the full break-sensitive path, so the 42.4% RSD describes remaining workload classes rather than a completed latency result. The optimized SIMD shaper is 1,147,266 raw bytes. Five patches write roughly 1.2 KiB per update, and the retained high-water mark remains 80.38 MiB. Median is now below 4 ms, but p95 and memory-growth gates remain open. +Policy gather now retains complete prior input lanes by committed session/policy/capability revision. Zero-change glyphs +reuse them without binding or policy work; changed glyphs update only reachable lanes. A resource or draw-storage key +change retains the verified prefix and fully rebuilds the suffix, preserving correct replacement-buffer inputs without +double-scanning the prefix. The same production lane now measures 1.314 ms median / 5.863 ms p95 with 76.2% RSD, five +patches, and roughly 1.2 KiB written. The 1,153,122-byte optimized shaper is 5,856 bytes larger than the prior checkpoint, +and retained high-water memory is 80.19 MiB. The fast class approaches 1 ms; the break-sensitive p95 remains open. + ## Merge gates still open Before the foundation stack is publishable: diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index fdc8fb8e..106da0bb 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1070,6 +1070,20 @@ split to 42.4%, while five roughly 1.2 KiB patches remain. The 1,147,266-byte op the retained-cluster checkpoint. The median meets the 4 ms ceiling, but the break-sensitive p95 does not; retained policy gathering, plan compilation, and chunk-local insert/delete storage remain open. +Policy gathering now retains its validated field-major inputs under an exact session revision, policy fingerprint, and +capability-set key. A one-byte source-selection lane lets zero-change positioned glyphs reuse the prior plan record +without font-resource selection or policy interpretation. A changed glyph rereads only inputs reachable from its +semantic change mask. Stable glyph identity may change without invalidating physical topology; technique, program, +resource, transform, material, clip, and depth keys remain exact fallback boundaries. When selection topology changes, +the gather keeps the verified prefix, truncates at the first mismatch, and completely gathers only that suffix and later +paragraphs, so a new allocation still has every input it needs without a second prefix scan. Cache state promotes only +with the engine transaction, abort cannot publish it, and font-binding/policy disposal invalidates it. An oracle changes +identity and one policy field in place, then changes material and proves the suffix rebuild matches a complete input. +On the unchanged 40-warmup/101-update production run, median/p95 improve from 2.607 / 6.184 ms to 1.314 / 5.863 ms; +five roughly 1.2 KiB patches remain. RSD is 76.2%, so the fast class is near the 1 ms design target but break-sensitive +edits still miss the p95 contract. Optimized Wasm grows 5,856 raw bytes to 1,153,122, and the retained high-water mark is +80.19 MiB. Ordered-plan compilation and chunk-local insert/delete storage remain open. + The renderer's 25% instance slack is not the edit-storage design. Editing requires the selected ABI-private 64-cluster semantic chunks to reserve a small bounded gap so insert, delete, and replacement operations move only the affected chunk before summaries and downstream line state resume. The current production text, shape, cluster, and diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index 00b7882b..0609eed5 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -58,12 +58,21 @@ pub enum GatherError { SourceFieldMissing, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RetainedGather { + Complete, + RebuildFrom(usize), +} + #[derive(Default)] pub struct PolicyGatherWorkspace { glyphs: Vec, + source_selected: Vec, semantic_change_masks: Vec, f32_fields: Vec>, u32_fields: Vec>, + retained_cursor: usize, + retained_source_cursor: usize, } #[repr(C, align(16))] @@ -88,6 +97,7 @@ pub struct GatheredPlanInput<'a> { impl PolicyGatherWorkspace { pub fn reserve_records(&mut self, record_capacity: usize) -> Result<(), GatherError> { reserve(&mut self.glyphs, record_capacity)?; + reserve(&mut self.source_selected, record_capacity)?; reserve(&mut self.semantic_change_masks, record_capacity) } @@ -142,17 +152,199 @@ impl PolicyGatherWorkspace { Ok(()) } + pub fn begin_retained( + &mut self, + policy: &ValidatedPolicy, + record_capacity: usize, + ) -> Result { + self.reserve_policy(policy, record_capacity)?; + self.retained_cursor = 0; + self.retained_source_cursor = 0; + let retained_len = self.glyphs.len(); + Ok(self.semantic_change_masks.len() == retained_len + && self + .f32_fields + .iter() + .all(|field| field.len == retained_len) + && self + .u32_fields + .iter() + .all(|field| field.len == retained_len)) + } + + pub fn append_retained<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + mut binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result { + validate_semantic_shape(input)?; + let mut cursor = self.retained_cursor; + let mut source_cursor = self.retained_source_cursor; + let mut cached_font_handle = None; + let mut cached_binding = None; + let mut cached_program = None; + for glyph_index in 0..input.glyphs.len() { + let glyph = input.glyphs[glyph_index]; + let Some(was_selected) = self.source_selected.get(source_cursor).copied() else { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + }; + source_cursor += 1; + let change_mask = input + .semantic_change_masks + .get(glyph_index) + .copied() + .unwrap_or(0); + if change_mask == 0 { + if was_selected != 0 { + let Some(previous) = self.glyphs.get(cursor) else { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + }; + if previous.stable_id != glyph.stable_id + || previous.transform_id != input.transform_id + { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + } + self.semantic_change_masks[cursor] = 0; + cursor += 1; + } + continue; + } + let binding = if cached_font_handle == Some(glyph.binding_handle) { + cached_binding.ok_or(GatherError::FontBindingMissing)? + } else { + let binding = binding_for_font(glyph.binding_handle) + .ok_or(GatherError::FontBindingMissing)?; + cached_font_handle = Some(glyph.binding_handle); + cached_binding = Some(binding); + binding + }; + let selected = + binding.select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio); + if selected.is_some() != (was_selected != 0) { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + } + let Some(selected) = selected else { + continue; + }; + let technique = binding.technique(); + let variant = binding.program_variant(); + let program = match cached_program { + Some((cached_technique, cached_variant, program)) + if cached_technique == technique && cached_variant == variant => + { + program + } + _ => { + let program = policy + .program(capability_set, technique, variant) + .ok_or(GatherError::ProgramMissing)?; + cached_program = Some((technique, variant, program)); + program + } + }; + let next = plan_glyph(input, glyph, binding, selected)?; + let Some(previous) = self.glyphs.get(cursor).copied() else { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + }; + if !same_storage_topology(previous, next) { + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor - 1; + return Ok(RetainedGather::RebuildFrom(glyph_index)); + } + let selection_changed = change_mask & RESOURCE_SELECTION_CHANGES != 0; + let (f32_inputs, u32_inputs) = policy + .input_masks_for_changes( + capability_set, + technique, + variant, + change_mask, + selection_changed, + ) + .ok_or(GatherError::ProgramMissing)?; + self.update_fields( + cursor, + input, + glyph_index, + binding, + selected, + program, + f32_inputs, + u32_inputs, + )?; + self.glyphs[cursor] = next; + self.semantic_change_masks[cursor] = change_mask; + cursor += 1; + } + self.retained_cursor = cursor; + self.retained_source_cursor = source_cursor; + Ok(RetainedGather::Complete) + } + + pub fn finish_retained(&self) -> bool { + self.retained_cursor == self.glyphs.len() + && self.retained_source_cursor == self.source_selected.len() + } + + pub fn truncate_to_retained_prefix(&mut self) { + self.glyphs.truncate(self.retained_cursor); + self.source_selected.truncate(self.retained_source_cursor); + self.semantic_change_masks.truncate(self.retained_cursor); + for field in &mut self.f32_fields { + field.truncate(self.retained_cursor); + } + for field in &mut self.u32_fields { + field.truncate(self.retained_cursor); + } + } + pub fn append<'binding>( &mut self, policy: &ValidatedPolicy, capability_set: CapabilitySetId, input: LayoutPlanInput<'_>, force_all_inputs: bool, + binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, + ) -> Result<(), GatherError> { + self.append_from( + policy, + capability_set, + input, + 0, + force_all_inputs, + binding_for_font, + ) + } + + pub fn append_from<'binding>( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: LayoutPlanInput<'_>, + source_start: usize, + force_all_inputs: bool, mut binding_for_font: impl FnMut(u32) -> Option<&'binding FontRenderBinding>, ) -> Result<(), GatherError> { validate_semantic_shape(input)?; - let required = self.glyphs.len().saturating_add(input.glyphs.len()); + let remaining = input.glyphs.len().saturating_sub(source_start); + if source_start > input.glyphs.len() { + return Err(GatherError::InvalidSemanticShape); + } + let required = self.glyphs.len().saturating_add(remaining); + let source_required = self.source_selected.len().saturating_add(remaining); if self.glyphs.capacity() < required + || self.source_selected.capacity() < source_required || self.semantic_change_masks.capacity() < required || self .f32_fields @@ -178,7 +370,7 @@ impl PolicyGatherWorkspace { let mut cached_font_handle = None; let mut cached_binding = None; let mut cached_program = None; - for glyph_index in 0..input.glyphs.len() { + for glyph_index in source_start..input.glyphs.len() { let glyph = input.glyphs[glyph_index]; let binding = if cached_font_handle == Some(glyph.binding_handle) { cached_binding.ok_or(GatherError::FontBindingMissing)? @@ -189,9 +381,10 @@ impl PolicyGatherWorkspace { cached_binding = Some(binding); binding }; - let Some(selected) = - binding.select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio) - else { + let selected = + binding.select(glyph.glyph_id, glyph.font_size, glyph.raster_pixel_ratio); + self.source_selected.push(u8::from(selected.is_some())); + let Some(selected) = selected else { continue; }; let technique = binding.technique(); @@ -228,32 +421,8 @@ impl PolicyGatherWorkspace { f32_inputs, u32_inputs, )?; - let resource = binding - .resources() - .get( - usize::try_from(selected.resource) - .map_err(|_| GatherError::ResourceBindingMissing)?, - ) - .ok_or(GatherError::ResourceBindingMissing)?; - self.glyphs.push(PlanGlyph { - stable_id: glyph.stable_id, - content_revision: glyph.content_revision, - technique: binding.technique(), - program_variant: binding.program_variant(), - resource_id: resource.id, - resource_generation: resource.generation, - resource_kind: resource.kind, - resource_reference: resource.reference, - semantic_id: glyph.semantic_id, - transform_id: input.transform_id, - material_id: glyph.material_id, - clip_id: glyph.clip_id, - depth_key: glyph.depth_key, - inline_start: glyph.inline_start, - block_start: glyph.block_start, - inline_extent: glyph.inline_extent, - block_extent: glyph.block_extent, - }); + self.glyphs + .push(plan_glyph(input, glyph, binding, selected)?); self.semantic_change_masks.push( input .semantic_change_masks @@ -331,8 +500,58 @@ impl PolicyGatherWorkspace { Ok(()) } + #[allow(clippy::too_many_arguments)] + fn update_fields( + &mut self, + output_index: usize, + input: LayoutPlanInput<'_>, + glyph_index: usize, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, + program: &ProgramDescriptor, + required_f32: u32, + required_u32: u32, + ) -> Result<(), GatherError> { + let f32_count = usize::from(program.f32_input_count); + let u32_count = usize::from(program.u32_input_count); + for field in 0..f32_count { + if required_f32 & (1 << field) == 0 { + continue; + } + let source = program.inputs[field]; + let value = source_f32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )?; + self.f32_fields[field].set(output_index, value)?; + } + for field in 0..u32_count { + if required_u32 & (1 << field) == 0 { + continue; + } + let source = program.inputs[f32_count + field]; + let value = source_u32( + source.scope, + source.field, + input, + glyph_index, + binding, + selected, + )?; + self.u32_fields[field].set(output_index, value)?; + } + Ok(()) + } + fn clear(&mut self) { + self.retained_cursor = 0; + self.retained_source_cursor = 0; self.glyphs.clear(); + self.source_selected.clear(); self.semantic_change_masks.clear(); for field in &mut self.f32_fields { field.clear(); @@ -382,6 +601,19 @@ impl AlignedField { Ok(()) } + fn set(&mut self, index: usize, value: T) -> Result<(), GatherError> { + if index >= self.len { + return Err(GatherError::InvalidSemanticShape); + } + self.blocks[index / 4].values[index % 4] = value; + Ok(()) + } + + fn truncate(&mut self, len: usize) { + self.blocks.truncate(len.div_ceil(4)); + self.len = self.len.min(len); + } + fn clear(&mut self) { self.blocks.clear(); self.len = 0; @@ -431,6 +663,50 @@ fn validate_semantic_shape(input: LayoutPlanInput<'_>) -> Result<(), GatherError Ok(()) } +fn plan_glyph( + input: LayoutPlanInput<'_>, + glyph: LayoutGlyph, + binding: &FontRenderBinding, + selected: SelectedGlyphBinding, +) -> Result { + let resource = binding + .resources() + .get(usize::try_from(selected.resource).map_err(|_| GatherError::ResourceBindingMissing)?) + .ok_or(GatherError::ResourceBindingMissing)?; + Ok(PlanGlyph { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + technique: binding.technique(), + program_variant: binding.program_variant(), + resource_id: resource.id, + resource_generation: resource.generation, + resource_kind: resource.kind, + resource_reference: resource.reference, + semantic_id: glyph.semantic_id, + transform_id: input.transform_id, + material_id: glyph.material_id, + clip_id: glyph.clip_id, + depth_key: glyph.depth_key, + inline_start: glyph.inline_start, + block_start: glyph.block_start, + inline_extent: glyph.inline_extent, + block_extent: glyph.block_extent, + }) +} + +fn same_storage_topology(previous: PlanGlyph, next: PlanGlyph) -> bool { + previous.technique == next.technique + && previous.program_variant == next.program_variant + && previous.resource_id == next.resource_id + && previous.resource_generation == next.resource_generation + && previous.resource_kind == next.resource_kind + && previous.resource_reference == next.resource_reference + && previous.transform_id == next.transform_id + && previous.material_id == next.material_id + && previous.clip_id == next.clip_id + && previous.depth_key == next.depth_key +} + fn source_f32( scope: InputScope, field: u8, @@ -677,6 +953,106 @@ mod tests { assert_eq!(workspace.capacities(), capacities); } + #[test] + fn retained_gather_updates_changed_fields_and_rejects_storage_topology_changes() { + let binding = binding(); + let policy = policy(); + let glyphs = [layout_glyph(1, 0), layout_glyph(2, 1)]; + let initial_x = [10.0, 20.0]; + let semantic_kind = [100, 200]; + let mut workspace = PolicyGatherWorkspace::default(); + workspace + .gather( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &glyphs, + semantic_change_masks: &[], + semantic_f32: &[&initial_x], + semantic_u32: &[&semantic_kind], + }, + true, + |_| Some(&binding), + ) + .unwrap(); + + let changed_x = [999.0, 25.0]; + let mut changed_glyphs = glyphs; + changed_glyphs[1].stable_id = 3; + changed_glyphs[1].content_revision = 2; + assert!(workspace.begin_retained(&policy, 2).unwrap()); + assert_eq!( + workspace + .append_retained( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &changed_glyphs, + semantic_change_masks: &[0, 1], + semantic_f32: &[&changed_x], + semantic_u32: &[&semantic_kind], + }, + |_| Some(&binding), + ) + .unwrap(), + RetainedGather::Complete + ); + assert!(workspace.finish_retained()); + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.glyphs[1].stable_id, 3); + assert_eq!(input.f32_fields[0], [10.0, 25.0]); + assert_eq!(input.f32_fields[1], [1.0, 2.0]); + + let mut changed_topology = changed_glyphs; + changed_topology[1].material_id = 7; + assert!(workspace.begin_retained(&policy, 2).unwrap()); + assert_eq!( + workspace + .append_retained( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &changed_topology, + semantic_change_masks: &[ + 0, + super::super::positioning::ALL_SEMANTIC_CHANGES + ], + semantic_f32: &[&changed_x], + semantic_u32: &[&semantic_kind], + }, + |_| Some(&binding), + ) + .unwrap(), + RetainedGather::RebuildFrom(1) + ); + workspace.truncate_to_retained_prefix(); + workspace + .append_from( + &policy, + CAPABILITY, + LayoutPlanInput { + transform_id: 1, + glyphs: &changed_topology, + semantic_change_masks: &[0, crate::engine::positioning::ALL_SEMANTIC_CHANGES], + semantic_f32: &[&changed_x], + semantic_u32: &[&semantic_kind], + }, + 1, + true, + |_| Some(&binding), + ) + .unwrap(); + let gathered = workspace.view(); + let input = gathered.plan_input(); + assert_eq!(input.glyphs.len(), 2); + assert_eq!(input.glyphs[1].material_id, 7); + assert_eq!(input.f32_fields[0], [10.0, 25.0]); + } + #[test] fn appends_independent_layouts_into_one_plan_input() { let binding = binding(); diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index e907f020..4e40c7e3 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -19,6 +19,7 @@ use super::{ policy::{ALLOCATION_ORDERED_DIRECT, CapabilitySetId, ValidatedPolicy}, policy_gather::{ DEFAULT_GATHER_RECORD_CAPACITY, GatherError, LayoutPlanInput, PolicyGatherWorkspace, + RetainedGather, }, positioning::PositionedGlyphArena, render_plan::RenderPlanView, @@ -51,6 +52,8 @@ pub struct TextEngine { font_stacks: Vec, sessions: BTreeMap, gather: PolicyGatherWorkspace, + gather_cache: Option, + prepared_gather_cache: Option, } struct RegisteredFontBinding { @@ -210,7 +213,21 @@ struct PolicyBinding { fingerprint: u64, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct GatherCacheKey { + session_id: u32, + revision: SessionRevision, + policy_handle: u32, + policy_fingerprint: u64, + capability_set: u32, +} + impl TextEngine { + fn invalidate_gather_cache(&mut self) { + self.gather_cache = None; + self.prepared_gather_cache = None; + } + pub fn initialize(&mut self) -> Result<(), EngineError> { self.gather .reserve_records(DEFAULT_GATHER_RECORD_CAPACITY) @@ -246,6 +263,7 @@ impl TextEngine { shaping_handle, binding, }); + self.invalidate_gather_cache(); Ok(()) } @@ -256,6 +274,7 @@ impl TextEngine { .position(|binding| binding.handle == handle) { self.font_bindings.swap_remove(index); + self.invalidate_gather_cache(); } } @@ -278,8 +297,12 @@ impl TextEngine { } pub fn dispose_bindings_for_shaping_font(&mut self, shaping_handle: u32) { + let previous_len = self.font_bindings.len(); self.font_bindings .retain(|binding| binding.shaping_handle != shaping_handle); + if self.font_bindings.len() != previous_len { + self.invalidate_gather_cache(); + } } pub fn font_binding_count(&self) -> u32 { @@ -384,14 +407,16 @@ impl TextEngine { .reserve_policy(&policy, DEFAULT_GATHER_RECORD_CAPACITY) .map_err(|_| EngineError::ResultTooLarge)?; self.policies.insert(handle, policy); + self.invalidate_gather_cache(); Ok(()) } pub fn dispose_policy(&mut self, handle: u32) -> Result<(), EngineError> { self.policies .remove(&handle) - .map(|_| ()) - .ok_or(EngineError::PolicyMissing) + .ok_or(EngineError::PolicyMissing)?; + self.invalidate_gather_cache(); + Ok(()) } pub fn policy(&self, handle: u32) -> Result<&ValidatedPolicy, EngineError> { @@ -420,8 +445,17 @@ impl TextEngine { pub fn dispose_session(&mut self, handle: u32) -> Result<(), EngineError> { self.sessions .remove(&handle) - .map(|_| ()) - .ok_or(EngineError::SessionMissing) + .ok_or(EngineError::SessionMissing)?; + if self + .gather_cache + .is_some_and(|cache| cache.session_id == handle) + || self + .prepared_gather_cache + .is_some_and(|cache| cache.session_id == handle) + { + self.invalidate_gather_cache(); + } + Ok(()) } pub fn reserve_session_text(&mut self, handle: u32, capacity: u32) -> Result<(), EngineError> { @@ -521,9 +555,12 @@ impl TextEngine { return Err(EngineError::InvalidRequest); } let policy_fingerprint = policy.fingerprint(); + let cached_gather = self.gather_cache; let font_bindings = &self.font_bindings; let font_stacks = &self.font_stacks; let gather = &mut self.gather; + let gather_cache = &mut self.gather_cache; + let prepared_gather_cache = &mut self.prepared_gather_cache; let session = self .sessions .get_mut(&request.session_id) @@ -554,6 +591,17 @@ impl TextEngine { .checked_add(1) .ok_or(EngineError::RevisionExhausted)?, }; + let current_gather_key = GatherCacheKey { + session_id: request.session_id, + revision: session.revision, + policy_handle: request.policy_handle, + policy_fingerprint, + capability_set: request.capability_set, + }; + let next_gather_key = GatherCacheKey { + revision: next, + ..current_gather_key + }; let checkpoint = session.revision.plan == 0 || request.consumed_plan_revision != session.revision.plan; // A completed renderer fence is external monotonic state. It remains accepted even if @@ -567,6 +615,7 @@ impl TextEngine { } else { None }; + let mut gather_output_matches_next = false; let preparation = (|| { session.semantic_records.clear(); session.prepare_lifecycle( @@ -630,6 +679,7 @@ impl TextEngine { .all(|program| program.allocation_strategy == ALLOCATION_ORDERED_DIRECT); if reuse_ordered_plan { session.plan.prepare_reuse().map_err(plan_error)?; + gather_output_matches_next = cached_gather == Some(current_gather_key); } else { let record_count = session @@ -648,39 +698,36 @@ impl TextEngine { .checked_add(positioned.glyphs().len()) .ok_or(EngineError::ResultTooLarge) })?; - gather.begin(policy, record_count).map_err(gather_error)?; - for order_index in 0..session.active_order().len() { - let paragraph_id = session.active_order()[order_index].id; - let paragraph = session - .paragraph(paragraph_id) - .ok_or(EngineError::InvalidRequest)?; - let positioned = if paragraph.state.positioned_prepared { - ¶graph.state.pending_positioned - } else { - ¶graph.state.positioned - }; - let semantic_f32 = positioned.semantic_f32(); - let semantic_u32 = positioned.semantic_u32(); - gather - .append( - policy, - CapabilitySetId(request.capability_set), - LayoutPlanInput { - transform_id: paragraph_id, - glyphs: positioned.glyphs(), - semantic_change_masks: positioned.semantic_change_masks(), - semantic_f32: &semantic_f32, - semantic_u32: &semantic_u32, - }, - checkpoint || !paragraph.positioned_changed, - |handle| { - font_bindings - .iter() - .find(|binding| binding.handle == handle) - .map(|binding| &binding.binding) - }, - ) + *gather_cache = None; + *prepared_gather_cache = None; + let capability_set = CapabilitySetId(request.capability_set); + let attempted_retained = cached_gather == Some(current_gather_key); + let retained = attempted_retained + && gather + .begin_retained(policy, record_count) .map_err(gather_error)?; + if retained { + append_session_gather( + gather, + session, + policy, + capability_set, + font_bindings, + true, + checkpoint, + )?; + } + if !retained { + gather.begin(policy, record_count).map_err(gather_error)?; + append_session_gather( + gather, + session, + policy, + capability_set, + font_bindings, + false, + checkpoint, + )?; } let gathered = gather.view(); let mut plan_input = gathered.plan_input(); @@ -696,6 +743,7 @@ impl TextEngine { request.acknowledged_publication_generation, ) .map_err(plan_error)?; + gather_output_matches_next = true; } let include_layout_inspection = request.semantic_view_mask & super::frame::SEMANTIC_VIEW_LAYOUT_INSPECTION != 0; @@ -865,6 +913,9 @@ impl TextEngine { session.abort_pending(); return Err(error); } + if gather_output_matches_next { + *prepared_gather_cache = Some(next_gather_key); + } Ok(PreparedUpdate { session_id: request.session_id, previous: session.revision, @@ -913,6 +964,7 @@ impl TextEngine { } pub(crate) fn abort_update(&mut self, prepared: PreparedUpdate) -> Result<(), EngineError> { + let next_gather_key = prepared_gather_key(prepared, prepared.next); let session = self .sessions .get_mut(&prepared.session_id) @@ -921,6 +973,9 @@ impl TextEngine { return Err(EngineError::RevisionConflict); } session.abort_pending(); + if self.prepared_gather_cache == Some(next_gather_key) { + self.prepared_gather_cache = None; + } Ok(()) } @@ -928,6 +983,8 @@ impl TextEngine { &mut self, prepared: PreparedUpdate, ) -> Result { + let previous_gather_key = prepared_gather_key(prepared, prepared.previous); + let next_gather_key = prepared_gather_key(prepared, prepared.next); let session = self .sessions .get_mut(&prepared.session_id) @@ -947,6 +1004,12 @@ impl TextEngine { fingerprint: prepared.policy_fingerprint, }); session.revision = prepared.next; + if self.prepared_gather_cache == Some(next_gather_key) { + self.gather_cache = Some(next_gather_key); + self.prepared_gather_cache = None; + } else if self.gather_cache == Some(previous_gather_key) { + self.gather_cache = Some(next_gather_key); + } Ok(CommittedUpdate { session_id: prepared.session_id, revision: prepared.next, @@ -956,6 +1019,95 @@ impl TextEngine { } } +fn prepared_gather_key(prepared: PreparedUpdate, revision: SessionRevision) -> GatherCacheKey { + GatherCacheKey { + session_id: prepared.session_id, + revision, + policy_handle: prepared.policy_handle, + policy_fingerprint: prepared.policy_fingerprint, + capability_set: prepared.capability_set, + } +} + +fn append_session_gather( + gather: &mut PolicyGatherWorkspace, + session: &EngineSession, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + font_bindings: &[RegisteredFontBinding], + retained: bool, + checkpoint: bool, +) -> Result<(), EngineError> { + let incremental = retained; + let mut retaining = retained; + for ordered in session.active_order() { + let paragraph = session + .paragraph(ordered.id) + .ok_or(EngineError::InvalidRequest)?; + let positioned = if paragraph.state.positioned_prepared { + ¶graph.state.pending_positioned + } else { + ¶graph.state.positioned + }; + let semantic_f32 = positioned.semantic_f32(); + let semantic_u32 = positioned.semantic_u32(); + let semantic_change_masks = if retaining && !paragraph.positioned_changed { + &[][..] + } else { + positioned.semantic_change_masks() + }; + let input = LayoutPlanInput { + transform_id: ordered.id, + glyphs: positioned.glyphs(), + semantic_change_masks, + semantic_f32: &semantic_f32, + semantic_u32: &semantic_u32, + }; + let binding_for_font = |handle| { + font_bindings + .iter() + .find(|binding| binding.handle == handle) + .map(|binding| &binding.binding) + }; + if retaining { + match gather + .append_retained(policy, capability_set, input, binding_for_font) + .map_err(gather_error)? + { + RetainedGather::Complete => {} + RetainedGather::RebuildFrom(source_start) => { + gather.truncate_to_retained_prefix(); + gather + .append_from( + policy, + capability_set, + input, + source_start, + true, + binding_for_font, + ) + .map_err(gather_error)?; + retaining = false; + } + } + } else { + gather + .append( + policy, + capability_set, + input, + incremental || checkpoint || !paragraph.positioned_changed, + binding_for_font, + ) + .map_err(gather_error)?; + } + } + if retaining && !gather.finish_retained() { + gather.truncate_to_retained_prefix(); + } + Ok(()) +} + impl EngineSession { #[cfg(test)] fn first_paragraph_state(&self) -> Option<&ParagraphState> { @@ -3452,11 +3604,27 @@ mod tests { assert!(first.checkpoint); assert_eq!(first.required_base_revision, 0); assert_eq!(first.revision, SessionRevision { engine: 1, plan: 1 }); + assert_eq!( + engine.gather_cache.map(|cache| cache.revision), + Some(first.revision) + ); let second = engine.prepare_update(update(1, 1, 1), 2).unwrap(); + assert_eq!( + engine.gather_cache.map(|cache| cache.revision), + Some(first.revision) + ); + assert_eq!( + engine.prepared_gather_cache.map(|cache| cache.revision), + Some(SessionRevision { engine: 2, plan: 2 }) + ); let second = engine.commit_update(second).unwrap(); assert!(!second.checkpoint); assert_eq!(second.required_base_revision, 1); + assert_eq!( + engine.gather_cache.map(|cache| cache.revision), + Some(second.revision) + ); assert_eq!( engine.prepare_update(update(1, 2, 1), 3), @@ -3533,7 +3701,10 @@ mod tests { .unwrap(); engine.create_session(4).unwrap(); let prepared = engine.prepare_update(update(0, 0, 0), 1).unwrap(); + assert!(engine.prepared_gather_cache.is_some()); engine.abort_update(prepared).unwrap(); + assert!(engine.gather_cache.is_none()); + assert!(engine.prepared_gather_cache.is_none()); assert_eq!( engine.session_revision(4).unwrap(), SessionRevision::default() From b19c664424546b6e8cce0703e2a79d26722ff74f Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 17:51:06 -0400 Subject: [PATCH 107/128] perf(text): retain ordered plan topology --- docs/log.md | 9 + docs/packages/text.md | 11 +- .../rust/shaper/src/engine/ordered_plan.rs | 361 +++++++++++++----- 3 files changed, 288 insertions(+), 93 deletions(-) diff --git a/docs/log.md b/docs/log.md index 7a133024..89e2558f 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-09 +- **Retained ordered-plan topology when physical storage membership stayed invariant** — The ordered-direct compiler now + reuses committed glyph-to-batch and glyph-to-slot mappings under the exact policy fingerprint and capability set. It + still validates every glyph and stable identity, and any physical storage-key mismatch returns to complete batch + discovery; a material-partition regression proves both paths. Three consecutive optimized 101-update runs measure + 1.164/5.761, 1.153/5.740, and 1.155/5.738 ms median/p95 with five roughly 1.2 KiB patches. The preceding checkpoint + measured 1.314/5.863 ms. Optimized Wasm grows 4,189 bytes to 1,157,311, and retained high-water memory falls from + 80.19 to 79.81 MiB. The fast class is now near 1 ms, while 81.4–81.6% RSD and the roughly 5.74 ms p95 keep the + break-sensitive tail open. + - **Retained policy inputs and rebuilt only from the first storage mismatch** — Gathered field-major policy inputs now commit under the exact session revision, policy fingerprint, and capability set. A one-byte selection lane skips binding/resource/policy work for zero-change glyphs; changed records update only reachable fields. Identity replacement diff --git a/docs/packages/text.md b/docs/packages/text.md index 47ac1ecd..9630469b 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:7f665fbe53b5942b4c6624c4dae010936fbafd8ae9a888dcf29ae8bd7fe72b21' +source_digest: 'sha256:5756368cd2774be1f5ed202ef33c8a7c9aa16c872637aed881f5c272fafe5d3c' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -179,7 +179,7 @@ their final collection occurs in the owning realm. It does not restore the delet The foundation currently has: -- 149 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and +- 150 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and later cursor-convergence regressions; - the package JavaScript/integration gate passing through the single-path public exports; - exact retained Amiri bidi, policy, ellipsis, clipping, UIKit-layout, and CJK contracts exercised by the browser @@ -238,6 +238,13 @@ double-scanning the prefix. The same production lane now measures 1.314 ms media patches, and roughly 1.2 KiB written. The 1,153,122-byte optimized shaper is 5,856 bytes larger than the prior checkpoint, and retained high-water memory is 80.19 MiB. The fast class approaches 1 ms; the break-sensitive p95 remains open. +The ordered-direct compiler additionally retains committed glyph-to-batch and glyph-to-slot topology while policy, +capability, glyph count, and every physical storage key remain compatible. It still validates every glyph and stable +identity; the first storage mismatch falls back to complete batch discovery. Three consecutive optimized runs measured +1.164/5.761, 1.153/5.740, and 1.155/5.738 ms median/p95, versus the preceding 1.314/5.863 ms checkpoint. The optimized +shaper is 1,157,311 raw bytes, a 4,189-byte increase, and retained high-water memory is 79.81 MiB. The repeated median gain +is established; the roughly 5.74 ms p95 and 81.4–81.6% RSD still fail the tail-latency gate. + ## Merge gates still open Before the foundation stack is publishable: diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index df8e1bef..f0efa314 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -82,6 +82,7 @@ struct BatchKey { resource_id: u32, resource_generation: u32, resource_kind: u16, + storage_key_mask: u32, resource_reference: u32, material_id: u32, clip_id: u32, @@ -154,9 +155,15 @@ pub struct OrderedPlanCompiler { payload: Vec, next_buffer_id: u32, pending_next_buffer_id: u32, + policy_fingerprint: u64, + pending_policy_fingerprint: u64, + capability_set: u32, + pending_capability_set: u32, buffer_id_limit: u32, publish_bindings: bool, prepared: bool, + #[cfg(test)] + retained_topology_preparations: u32, } impl OrderedPlanCompiler { @@ -223,99 +230,18 @@ impl OrderedPlanCompiler { .ok_or(OrderedPlanError::CapabilitySetMissing)?; validate_input(input)?; self.reset_pending(); - reserve(&mut self.input_batches, input.glyphs.len())?; - reserve(&mut self.input_slots, input.glyphs.len())?; - reserve(&mut self.pending_instances, input.glyphs.len())?; - self.input_batches.resize(input.glyphs.len(), NONE); - self.input_batches.fill(NONE); - self.input_slots.resize(input.glyphs.len(), 0); - self.prepare_identity_set(input.glyphs.len())?; - - for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { - validate_glyph(glyph)?; - if !self.insert_identity(glyph.stable_id) { - return Err(OrderedPlanError::DuplicateIdentity); - } - let program = policy - .program(capability_set, glyph.technique, glyph.program_variant) - .ok_or(OrderedPlanError::ProgramMissing)?; - if program.allocation_strategy != ALLOCATION_ORDERED_DIRECT { - if strict_strategy { - return Err(OrderedPlanError::UnsupportedStrategy); - } - continue; - } - let resource_bit = 1_u32 - .checked_shl(u32::from(glyph.resource_kind - 1)) - .ok_or(OrderedPlanError::InvalidResource)?; - if program.resource_kind_mask & resource_bit == 0 { - return Err(OrderedPlanError::InvalidResource); - } - let key = BatchKey { - technique: glyph.technique, - program_variant: glyph.program_variant, - program_id: program.id.0, - resource_id: glyph.resource_id, - resource_generation: glyph.resource_generation, - resource_kind: glyph.resource_kind, - resource_reference: glyph.resource_reference, - material_id: if program.storage_key_mask & BATCH_MATERIAL != 0 { - glyph.material_id - } else { - 0 - }, - clip_id: if program.storage_key_mask & super::policy::BATCH_CLIP != 0 { - glyph.clip_id - } else { - 0 - }, - depth_key: if program.storage_key_mask & super::policy::BATCH_DEPTH != 0 { - glyph.depth_key - } else { - 0 - }, - }; - let batch_index = match self - .pending_batches - .iter() - .position(|batch| batch.state.key == key) - { - Some(index) => index, - None => { - reserve(&mut self.pending_batches, 1)?; - let prior_index = self - .batches - .iter() - .position(|batch| batch.key == key) - .map(|index| index as u32); - self.pending_batches.push(PendingBatch { - state: BatchState { - key, - instance_start: 0, - instance_count: 0, - buffer_start: 0, - buffer_count: 0, - }, - prior_index, - capacity: 0, - buffer_ids: [0; MAX_PHYSICAL_BUFFERS], - buffer_generations: [0; MAX_PHYSICAL_BUFFERS], - }); - self.pending_batches.len() - 1 - } - }; - self.pending_batches[batch_index].state.instance_count = self.pending_batches - [batch_index] - .state - .instance_count - .checked_add(1) - .ok_or(OrderedPlanError::ArithmeticOverflow)?; - self.input_batches[input_index] = - u32::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let retained_topology = + !checkpoint && self.prepare_retained_topology(policy, capability_set, input)?; + #[cfg(test)] + if retained_topology { + self.retained_topology_preparations += 1; + } + if !retained_topology { + self.prepare_complete_topology(policy, capability_set, input, strict_strategy)?; } - - self.layout_pending_instances(input)?; self.pending_next_buffer_id = self.next_buffer_id; + self.pending_policy_fingerprint = policy.fingerprint(); + self.pending_capability_set = capability_set.0; let context = PrepareContext { policy, capability_set, @@ -449,6 +375,8 @@ impl OrderedPlanCompiler { mem::swap(&mut self.live_draws, &mut self.draws); self.draws.clear(); self.next_buffer_id = self.pending_next_buffer_id; + self.policy_fingerprint = self.pending_policy_fingerprint; + self.capability_set = self.pending_capability_set; self.prepared = false; Ok(()) } @@ -531,6 +459,179 @@ impl OrderedPlanCompiler { } } + fn prepare_complete_topology( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + strict_strategy: bool, + ) -> Result<(), OrderedPlanError> { + reserve(&mut self.input_batches, input.glyphs.len())?; + reserve(&mut self.input_slots, input.glyphs.len())?; + reserve(&mut self.pending_instances, input.glyphs.len())?; + self.input_batches.resize(input.glyphs.len(), NONE); + self.input_batches.fill(NONE); + self.input_slots.resize(input.glyphs.len(), 0); + self.prepare_identity_set(input.glyphs.len())?; + + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + validate_glyph(glyph)?; + if !self.insert_identity(glyph.stable_id) { + return Err(OrderedPlanError::DuplicateIdentity); + } + let program = policy + .program(capability_set, glyph.technique, glyph.program_variant) + .ok_or(OrderedPlanError::ProgramMissing)?; + if program.allocation_strategy != ALLOCATION_ORDERED_DIRECT { + if strict_strategy { + return Err(OrderedPlanError::UnsupportedStrategy); + } + continue; + } + let resource_bit = 1_u32 + .checked_shl(u32::from(glyph.resource_kind - 1)) + .ok_or(OrderedPlanError::InvalidResource)?; + if program.resource_kind_mask & resource_bit == 0 { + return Err(OrderedPlanError::InvalidResource); + } + let key = batch_key(program, glyph); + let batch_index = match self + .pending_batches + .iter() + .position(|batch| batch.state.key == key) + { + Some(index) => index, + None => { + reserve(&mut self.pending_batches, 1)?; + let prior_index = self + .batches + .iter() + .position(|batch| batch.key == key) + .map(|index| index as u32); + self.pending_batches.push(PendingBatch { + state: BatchState { + key, + instance_start: 0, + instance_count: 0, + buffer_start: 0, + buffer_count: 0, + }, + prior_index, + capacity: 0, + buffer_ids: [0; MAX_PHYSICAL_BUFFERS], + buffer_generations: [0; MAX_PHYSICAL_BUFFERS], + }); + self.pending_batches.len() - 1 + } + }; + self.pending_batches[batch_index].state.instance_count = self.pending_batches + [batch_index] + .state + .instance_count + .checked_add(1) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + self.input_batches[input_index] = + u32::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + } + self.layout_pending_instances(input) + } + + fn prepare_retained_topology( + &mut self, + policy: &ValidatedPolicy, + capability_set: CapabilitySetId, + input: OrderedPlanInput<'_>, + ) -> Result { + if self.policy_fingerprint != policy.fingerprint() + || self.capability_set != capability_set.0 + || self.input_batches.len() != input.glyphs.len() + || self.input_slots.len() != input.glyphs.len() + || self.instances.len() != input.glyphs.len() + { + return Ok(false); + } + self.prepare_identity_set(input.glyphs.len())?; + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + validate_glyph(glyph)?; + if !self.insert_identity(glyph.stable_id) { + return Err(OrderedPlanError::DuplicateIdentity); + } + let batch_index = self.input_batches[input_index]; + if batch_index == NONE { + return Ok(false); + } + let batch_index = + usize::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?; + let Some(batch) = self.batches.get(batch_index).copied() else { + return Ok(false); + }; + if !batch_key_matches(batch.key, glyph) { + return Ok(false); + } + let slot = self.input_slots[input_index]; + if slot >= batch.instance_count { + return Ok(false); + } + let instance_index = batch + .instance_start + .checked_add(slot) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + if self + .instances + .get( + usize::try_from(instance_index) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + ) + .is_none_or(|instance| instance.input_index != input_index as u32) + { + return Ok(false); + } + } + + reserve(&mut self.pending_batches, self.batches.len())?; + for (batch_index, batch) in self.batches.iter().copied().enumerate() { + let buffers = self + .buffers + .get(range(batch.buffer_start, u32::from(batch.buffer_count))?) + .ok_or(OrderedPlanError::InvalidIdentity)?; + let mut pending = PendingBatch { + state: batch, + prior_index: Some( + u32::try_from(batch_index).map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + ), + capacity: buffers.first().map_or(0, |buffer| buffer.capacity), + buffer_ids: [0; MAX_PHYSICAL_BUFFERS], + buffer_generations: [0; MAX_PHYSICAL_BUFFERS], + }; + for (buffer_index, buffer) in buffers.iter().enumerate() { + pending.buffer_ids[buffer_index] = buffer.id; + pending.buffer_generations[buffer_index] = buffer.generation; + } + self.pending_batches.push(pending); + } + reserve(&mut self.pending_instances, self.instances.len())?; + self.pending_instances + .resize(self.instances.len(), InstanceState::default()); + for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { + let batch_index = self.input_batches[input_index] as usize; + let destination = self.batches[batch_index] + .instance_start + .checked_add(self.input_slots[input_index]) + .ok_or(OrderedPlanError::ArithmeticOverflow)?; + self.pending_instances[destination as usize] = InstanceState { + stable_id: glyph.stable_id, + content_revision: glyph.content_revision, + input_index: input_index as u32, + semantic_change_mask: input + .semantic_change_masks + .get(input_index) + .copied() + .unwrap_or(super::positioning::ALL_SEMANTIC_CHANGES), + }; + } + Ok(true) + } + fn layout_pending_instances( &mut self, input: OrderedPlanInput<'_>, @@ -1394,6 +1495,47 @@ fn range(start: u32, count: u32) -> Result, OrderedPlanE Ok(start as usize..end as usize) } +fn batch_key(program: &super::policy::ProgramDescriptor, glyph: OrderedGlyph) -> BatchKey { + BatchKey { + technique: glyph.technique, + program_variant: glyph.program_variant, + program_id: program.id.0, + resource_id: glyph.resource_id, + resource_generation: glyph.resource_generation, + resource_kind: glyph.resource_kind, + storage_key_mask: program.storage_key_mask, + resource_reference: glyph.resource_reference, + material_id: if program.storage_key_mask & BATCH_MATERIAL != 0 { + glyph.material_id + } else { + 0 + }, + clip_id: if program.storage_key_mask & super::policy::BATCH_CLIP != 0 { + glyph.clip_id + } else { + 0 + }, + depth_key: if program.storage_key_mask & super::policy::BATCH_DEPTH != 0 { + glyph.depth_key + } else { + 0 + }, + } +} + +fn batch_key_matches(key: BatchKey, glyph: OrderedGlyph) -> bool { + key.technique == glyph.technique + && key.program_variant == glyph.program_variant + && key.resource_id == glyph.resource_id + && key.resource_generation == glyph.resource_generation + && key.resource_kind == glyph.resource_kind + && key.resource_reference == glyph.resource_reference + && (key.storage_key_mask & BATCH_MATERIAL == 0 || key.material_id == glyph.material_id) + && (key.storage_key_mask & super::policy::BATCH_CLIP == 0 || key.clip_id == glyph.clip_id) + && (key.storage_key_mask & super::policy::BATCH_DEPTH == 0 + || key.depth_key == glyph.depth_key) +} + fn reserve(values: &mut Vec, additional: usize) -> Result<(), OrderedPlanError> { values .try_reserve(additional) @@ -1710,6 +1852,43 @@ mod tests { assert!(plan_layout(plan).is_ok()); } + #[test] + fn retained_topology_requires_unchanged_physical_storage_membership() { + let policy = policy_with_material_storage(true); + let mut compiler = OrderedPlanCompiler::default(); + let initial = [glyph(1, 1), glyph(2, 1)]; + prepare(&mut compiler, &policy, &initial, &[1.0, 2.0], true); + compiler.commit().unwrap(); + + let content_changed = [glyph(1, 2), glyph(2, 1)]; + prepare( + &mut compiler, + &policy, + &content_changed, + &[10.0, 2.0], + false, + ); + assert_eq!(compiler.retained_topology_preparations, 1); + compiler.commit().unwrap(); + + let mut moved_material = glyph(2, 2); + moved_material.material_id = 2; + let storage_changed = [glyph(1, 2), moved_material]; + prepare( + &mut compiler, + &policy, + &storage_changed, + &[10.0, 20.0], + false, + ); + assert_eq!(compiler.retained_topology_preparations, 1); + let plan = compiler + .plan_view(7, CAPABILITY, policy.fingerprint()) + .unwrap(); + assert_eq!(plan.buffers.len(), 2); + assert_eq!(plan.draws.len(), 2); + } + #[test] fn glyph_spans_split_at_the_wire_record_limit() { let policy = policy_with_limits(false, 512 * 1024); From f0fad23740429a7018f6e862c1f2da71fb0b73e5 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 17:59:45 -0400 Subject: [PATCH 108/128] test(text): benchmark localized splices --- docs/log.md | 8 +++++ docs/packages/text.md | 9 ++++- .../scripts/benchmark-rust-layout-engine.mjs | 34 ++++++++++++++++--- .../support/render-technique-proof.mjs | 31 ++++++++++------- 4 files changed, 63 insertions(+), 19 deletions(-) diff --git a/docs/log.md b/docs/log.md index 89e2558f..1ca3e3e2 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Added the missing middle-splice workload before choosing edit storage** — The unchanged replacement case remains the + canonical comparison, while a new `localized-splice` case alternates one UTF-16 insertion and deletion in the middle + of the same 22,000-glyph fixture. Ordered-direct measures 9.119 ms median / 10.016 ms p95 and writes 511.3 KiB because + physical records after the insertion move. Stable-indirect proves the intended bandwidth result at 452 B but currently + regresses to 10.776/51.067 ms; equal-length stable replacement is also 2.903/19.312 ms versus ordered-direct's roughly + 1.15/5.74 ms. No default changes: stable planning and Three indirection must become correct and fast before chunk-local + UTF-16 storage can be credited with the smaller remaining edit cost. + - **Retained ordered-plan topology when physical storage membership stayed invariant** — The ordered-direct compiler now reuses committed glyph-to-batch and glyph-to-slot mappings under the exact policy fingerprint and capability set. It still validates every glyph and stable identity, and any physical storage-key mismatch returns to complete batch diff --git a/docs/packages/text.md b/docs/packages/text.md index 9630469b..95066573 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:5756368cd2774be1f5ed202ef33c8a7c9aa16c872637aed881f5c272fafe5d3c' +source_digest: 'sha256:45bd229154c46ae55792637f90932ef600fa7ad524fa3b680338cf13f4f28161' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -245,6 +245,13 @@ identity; the first storage mismatch falls back to complete batch discovery. Thr shaper is 1,157,311 raw bytes, a 4,189-byte increase, and retained high-water memory is 79.81 MiB. The repeated median gain is established; the roughly 5.74 ms p95 and 81.4–81.6% RSD still fail the tail-latency gate. +The direct benchmark now also keeps an independent middle-splice lane. Alternating one UTF-16 insertion/deletion through +ordered-direct storage measures 9.119 ms median / 10.016 ms p95 and writes 511.3 KiB because following physical records +move. Stable-indirect reduces that publication to 452 B, but its current compiler measures 10.776/51.067 ms; even its +equal-length replacement path measures 2.903/19.312 ms. This establishes the storage-policy tradeoff without changing the +default: stable planning and Three shader indirection remain optimization/correctness work, and chunk-local text storage +cannot be claimed as the dominant splice fix while the physical plan has this cost. + ## Merge gates still open Before the foundation stack is publishable: diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index 00876f09..241d7ffa 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -33,7 +33,7 @@ const [wasm, abi, artifact] = await Promise.all([ ]); const validated = await validateFontArtifact(artifact); const raster = await validateRaster(options.technique, artifact, validated); -const technique = techniqueProof(abi, options.technique, raster); +const technique = techniqueProof(abi, options.technique, raster, options.allocation); const outputCapacity = technique.outputBytesPerGlyph > 48 ? 8 * 1024 * 1024 : 4 * 1024 * 1024; const instance = await WebAssembly.instantiate(await WebAssembly.compile(wasm), {}); const memory = instance.exports[abi.memory]; @@ -66,11 +66,11 @@ const initial = updateBytes({ let sessionMemory; console.log( - `technique=${options.technique} output=${technique.outputBytesPerGlyph} bytes/glyph · memory bytes: instantiate=${memoryAtInstantiation}, initialize=${memoryAfterInitialize}, registered=${memoryAfterRegistration}`, + `technique=${options.technique} allocation=${options.allocation} output=${technique.outputBytesPerGlyph} bytes/glyph · memory bytes: instantiate=${memoryAtInstantiation}, initialize=${memoryAfterInitialize}, registered=${memoryAfterRegistration}`, ); const reports = []; -const cases = ['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit']; +const cases = ['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit', 'localized-splice']; for (const name of options.case === undefined ? cases : [options.case]) { reports.push(name === 'cold' ? measureCold() : measureWarm(name)); } @@ -98,6 +98,8 @@ function measureWarm(name) { let state = execute(initial, true); const liveGlyphCount = state.glyphCount; const localizedText = [...utf16]; + const spliceStart = Math.floor(utf16.length / 2); + let spliceInserted = false; let suffixLength = utf16.length; const samples = []; const plans = []; @@ -142,6 +144,17 @@ function measureWarm(name) { textMutation: { start, deleteCount: 1, insert: [replacement] }, geometry: baseGeometry, }); + } else if (name === 'localized-splice') { + const insert = spliceInserted ? [] : [0x61]; + const deleteCount = spliceInserted ? 1 : 0; + spliceInserted = !spliceInserted; + const textEnd = utf16.length + Number(spliceInserted); + bytes = updateBytes({ + ...common, + textMutation: { start: spliceStart, deleteCount, insert }, + style: { ...baseStyle, textEnd }, + geometry: baseGeometry, + }); } else { bytes = updateBytes({ ...common, geometry: baseGeometry }); } @@ -312,7 +325,9 @@ function printReport(caseReports) { ); } console.log('column-resize is the existing layout-width case: one fully active column is reflowed end to end.'); - console.log('suffix-edit matches the TypeScript text benchmark; localized-edit is an additional one-code-unit edit.'); + console.log( + 'suffix-edit matches the TypeScript text benchmark; localized-edit replaces one code unit; localized-splice alternates one middle insertion/deletion.', + ); console.log(`Wasm memory after retained high-water mark: ${(memory.buffer.byteLength / 1024 / 1024).toFixed(2)} MiB`); } @@ -335,6 +350,7 @@ function parseArguments(arguments_) { }; return { technique: normalizeTechnique(readString('--technique', 'bitmap')), + allocation: readAllocation('--allocation'), wasm: readString('--wasm'), case: readCase('--case'), glyphs: read('--glyphs', 22_000), @@ -352,12 +368,20 @@ function parseArguments(arguments_) { const value = readString(name); if ( value !== undefined && - !['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit'].includes(value) + !['cold', 'no-op', 'font-size', 'column-resize', 'suffix-edit', 'localized-edit', 'localized-splice'].includes( + value, + ) ) { throw new RangeError(`unknown benchmark case: ${value}`); } return value; } + + function readAllocation(name) { + const value = readString(name, 'ordered'); + if (value !== 'ordered' && value !== 'stable') throw new RangeError(`unknown allocation strategy: ${value}`); + return value; + } } function normalizeTechnique(value) { diff --git a/packages/text/scripts/support/render-technique-proof.mjs b/packages/text/scripts/support/render-technique-proof.mjs index 6ca6edcf..16328af9 100644 --- a/packages/text/scripts/support/render-technique-proof.mjs +++ b/packages/text/scripts/support/render-technique-proof.mjs @@ -4,18 +4,18 @@ const ABSENT_PAGE = 0xffff; const MISSING_RESOURCE = 0xffff_ffff; const TECHNIQUE_ID = 1; -export function techniqueProof(abi, name, raster) { - if (name === 'bitmap') return bitmapProof(abi, raster); - if (name === 'mtsdf') return mtsdfProof(abi, raster); - if (name === 'slug') return slugProof(abi, raster); +export function techniqueProof(abi, name, raster, allocation = 'ordered') { + if (name === 'bitmap') return bitmapProof(abi, raster, allocation); + if (name === 'mtsdf') return mtsdfProof(abi, raster, allocation); + if (name === 'slug') return slugProof(abi, raster, allocation); throw new RangeError(`unknown render technique ${name}`); } -function bitmapProof(abi, raster) { +function bitmapProof(abi, raster, allocation) { const strike = raster.strikes[0]; const view = recordView(strike.records); const fields = denseAtlasFields(view, raster.glyphCount, strike.planeUnitsPerEm, strike.pages); - return proof(abi, bitmapProgram(abi, 'strike'), { + return proof(abi, bitmapProgram(abi, 'strike'), allocation, { glyphCount: raster.glyphCount, strikes: [strike.ppem], resources: strike.pages.map(resource), @@ -24,7 +24,7 @@ function bitmapProof(abi, raster) { }); } -function mtsdfProof(abi, raster) { +function mtsdfProof(abi, raster, allocation) { const extension = raster.document.extensions.PMNDRS_font_distance_field; const view = recordView(raster.records); const fields = denseAtlasFields(view, raster.glyphCount, extension.planeUnitsPerEm, raster.pages); @@ -42,7 +42,7 @@ function mtsdfProof(abi, raster) { return view.getUint16(record + 14, true) / height; }), ); - return proof(abi, mtsdfProgram(abi), { + return proof(abi, mtsdfProgram(abi), allocation, { glyphCount: raster.glyphCount, strikes: [0], resources: [{ id: 1, generation: 1, kind: 1, reference: 1 }], @@ -52,7 +52,7 @@ function mtsdfProof(abi, raster) { }); } -function slugProof(abi, raster) { +function slugProof(abi, raster, allocation) { const extension = raster.document.extensions.PMNDRS_font_slug; const view = recordView(raster.records); const units = extension.planeUnitsPerEm; @@ -85,7 +85,7 @@ function slugProof(abi, raster) { horizontalBands, verticalBands, ]; - return proof(abi, slugProgram(abi), { + return proof(abi, slugProgram(abi), allocation, { glyphCount: raster.glyphCount, strikes: [0], resources: raster.pages.map(resource), @@ -102,11 +102,16 @@ function slugProof(abi, raster) { }); } -function proof(abi, descriptor, binding) { +function proof(abi, descriptor, allocation, binding) { + const allocationStrategy = + allocation === 'stable' + ? abi.policy.allocationStrategies.stableIndirect + : abi.policy.allocationStrategies.orderedDirect; + const selected = { ...descriptor, allocationStrategy }; return { - policyBytes: renderPolicyBytesFromPrograms(abi, [descriptor]), + policyBytes: renderPolicyBytesFromPrograms(abi, [selected]), bindingBytes: fontBindingBytes(abi, { techniqueId: TECHNIQUE_ID, ...binding }), - outputBytesPerGlyph: descriptor.buffers.reduce( + outputBytesPerGlyph: selected.buffers.reduce( (sum, buffer) => sum + buffer.vectorWidth * scalarBytes(abi, buffer.scalar), 0, ), From 8dcc44b932f13678f5a91eb7bc929a28e13b222d Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 21:28:02 -0400 Subject: [PATCH 109/128] refactor(text): remove legacy raster packing paths --- packages/text/src/font.ts | 51 +-- packages/text/src/index.ts | 26 +- packages/text/src/raster-runtime.ts | 355 ------------------ packages/text/src/raster-technique.ts | 129 ++----- packages/text/src/raster.ts | 135 +------ packages/text/src/raster/bitmap-technique.ts | 171 +-------- packages/text/src/raster/msdf.ts | 215 +---------- packages/text/src/raster/slug-technique.ts | 191 +--------- packages/text/src/text-runtime.ts | 18 +- .../tests/integration/bitmap-baker.test.mjs | 7 +- .../tests/integration/mtsdf-baker.test.mjs | 67 +--- .../tests/package/bitmap-technique.test.mjs | 82 ---- .../tests/package/mtsdf-technique.test.mjs | 82 ---- .../tests/package/raster-lifecycle.test.mjs | 46 --- .../tests/package/raster-technique.test.mjs | 8 - .../tests/package/slug-technique.test.mjs | 93 ----- .../builtin-raster-techniques-api.test.ts | 22 +- packages/text/tests/types/public-api.test.ts | 188 ++-------- .../tests/types/raster-technique-api.test.ts | 52 +-- 19 files changed, 117 insertions(+), 1821 deletions(-) delete mode 100644 packages/text/src/raster-runtime.ts delete mode 100644 packages/text/tests/package/bitmap-technique.test.mjs delete mode 100644 packages/text/tests/package/mtsdf-technique.test.mjs delete mode 100644 packages/text/tests/package/raster-lifecycle.test.mjs delete mode 100644 packages/text/tests/package/slug-technique.test.mjs diff --git a/packages/text/src/font.ts b/packages/text/src/font.ts index 4f0006bb..1de8c975 100644 --- a/packages/text/src/font.ts +++ b/packages/text/src/font.ts @@ -1,14 +1,5 @@ -import type { - AnyRasterModule, - AnyRasterInput, - LoadedRaster, - RasterLoadOptions, - RasterReference, - RasterRequest, - RasterModuleOptionsOf, - RasterSelection, - RegisteredRaster, -} from './raster.js'; +import type { RasterLoadOptions, RasterReference, RasterSelection, RegisteredRaster } from './raster.js'; +import type { AnyRasterTechnique, RasterTechniqueInput, RasterTechniqueRequest } from './raster-technique.js'; import type { FontHandle, FontKey, RasterKey, Sha256Hex } from './identity.js'; export interface FontMetrics { @@ -52,48 +43,34 @@ export interface BakedFontSource { export type FontInput = string | URL | FontSourceOverride | BakedFontSource; -export interface FontToken { +export interface FontToken { readonly input: Input; - readonly raster: RasterRequest; + readonly raster: RasterTechniqueRequest; } export interface AnyFontToken { readonly input: FontInput; readonly raster: { - readonly module: AnyRasterModule; + readonly technique: AnyRasterTechnique; readonly options?: unknown; }; } -/** @deprecated Merged-v0 loaded font/raster pair retained only by the v0 React harness. */ -export interface LoadedFontV0 { - readonly input: Input; - readonly font: RegisteredFont; - readonly raster: LoadedRaster; -} - export type FontInputOf = Token['input']; -export type FontRasterModuleOf = Token['raster']['module']; - -export function defineFont( - input: Input, - raster: Module & - ([RasterModuleOptionsOf] extends [never] - ? unknown - : undefined extends RasterModuleOptionsOf - ? unknown - : never), -): FontToken; +export type FontRasterTechniqueOf = Token['raster']['technique']; -export function defineFont( +export function defineFont( input: Input, - raster: RasterRequest, -): FontToken; + raster: RasterTechniqueInput, +): FontToken; -export function defineFont(input: FontInput, raster: AnyRasterInput): AnyFontToken { +export function defineFont( + input: FontInput, + raster: AnyRasterTechnique | RasterTechniqueRequest, +): AnyFontToken { return { input, - raster: 'module' in raster ? raster : { module: raster }, + raster: 'technique' in raster ? raster : { technique: raster }, }; } diff --git a/packages/text/src/index.ts b/packages/text/src/index.ts index 331b3db1..0af55fa0 100644 --- a/packages/text/src/index.ts +++ b/packages/text/src/index.ts @@ -25,9 +25,8 @@ export type { BakedFontSource, FontInput, FontInputOf, - LoadedFontV0, FontMetrics, - FontRasterModuleOf, + FontRasterTechniqueOf, FontSourceOverride, FontToken, RegisteredFont, @@ -89,26 +88,16 @@ export { SpanNestingError } from './internal/span-cascade.js'; export type { GlyphPaint, LinearRgba, ResolvedPaint } from './paint.js'; export type { - AnyRasterModule, - AnyRasterInput, JsonValue, - LoadedRaster, - RasterBatchOf, - RasterBatchStage, RasterKind, RasterKindOf, RasterLoadOptions, - RasterModule, - RasterInput, RasterReference, - RasterRequest, RasterResolver, RasterResolverContext, RasterResourceResolver, RasterResourceResolverContext, RasterResourceSource, - RasterResourceOf, - RasterModuleOptionsOf, RasterOptionsArgument, RasterSelection, RasterSource, @@ -118,27 +107,18 @@ export type { RuntimeRasterBakerLoader, RuntimeRasterBakerModule, } from './raster.js'; -export { defineRaster, defineRasterBatchStage } from './raster.js'; -export { RasterRuntime } from './raster-runtime.js'; -export type { RasterDrawBatch, RasterObjectDrawBatch } from './raster.js'; export type { AnyRasterTechnique, - GlyphBatchStorage, - GlyphBatchStorageOf, - GlyphBatchStorageShape, - GlyphRange, - RasterBindingOf, RasterDataOf, - RasterGlyphInput, - RasterGlyphSelection, - RasterGlyphWriteInput, RasterResourceId, RasterTechnique, RasterTechniqueDescriptorOf, RasterTechniqueId, RasterOptionsOf, RasterTechniqueOptionsOf, + RasterTechniqueInput, + RasterTechniqueRequest, RasterTechniqueTypesOf, } from './raster-technique.js'; export { defineRasterResourceId, defineRasterTechnique } from './raster-technique.js'; diff --git a/packages/text/src/raster-runtime.ts b/packages/text/src/raster-runtime.ts deleted file mode 100644 index f21c0f76..00000000 --- a/packages/text/src/raster-runtime.ts +++ /dev/null @@ -1,355 +0,0 @@ -import type { RasterBakeArtifact } from './bake.js'; -import type { RegisteredFont } from './font.js'; -import type { RasterKey } from './identity.js'; -import { FontLoadError, registeredFontRegistry } from './loader.js'; -import { canonicalJson, deriveRasterKey } from './internal/raster-identity.js'; -import { getRegisteredFontData } from './internal/registered-font.js'; -import type { - AnyRasterModule, - LoadedRaster, - RasterKindOf, - RasterLoadOptions, - RasterRequest, - RuntimeRasterBakerModule, -} from './raster.js'; - -type LoadedAnyRaster = LoadedRaster; - -interface CachedRaster { - readonly promise: Promise; - readonly controller: AbortController; - readonly descriptorKey: string; - value: LoadedAnyRaster | undefined; - consumers: number; - settled: boolean; -} - -interface ObservedRegistry { - readonly fonts: Set; - readonly unsubscribe: () => void; -} - -/** - * Resolves, validates, decodes, and caches raster resources per font generation. - * Failed loads are never retained, and font disposal releases decoded resources. - */ -export class RasterRuntime { - readonly #fonts = new Map>>(); - readonly #registries = new Map, ObservedRegistry>(); - #disposed = false; - - load( - font: RegisteredFont, - request: RasterRequest, - options: RasterLoadOptions = {}, - ): Promise> { - this.#assertActive(); - options.signal?.throwIfAborted(); - return this.#load(font, request, options); - } - - /** @internal Return a current decoded resource without crossing a Promise boundary. */ - _peek( - font: RegisteredFont, - request: RasterRequest, - ): LoadedRaster | undefined { - this.#assertActive(); - const rasters = this.#fonts.get(font)?.get(request.module); - if (rasters === undefined) return undefined; - const descriptorKey = rasterDescriptorKey(request.module, request.options); - for (const [rasterKey, cached] of rasters) { - if (cached.descriptorKey !== descriptorKey || cached.value === undefined) continue; - if (isCurrentRaster(font, rasterKey as RasterKey, cached.value.artifact)) { - return cached.value as LoadedRaster; - } - rasters.delete(rasterKey); - request.module.dispose(cached.value.resource); - return undefined; - } - return undefined; - } - - dispose(): void { - if (this.#disposed) return; - this.#disposed = true; - for (const { unsubscribe } of this.#registries.values()) unsubscribe(); - this.#registries.clear(); - for (const font of this.#fonts.keys()) this.#disposeFontResources(font); - } - - async #load( - font: RegisteredFont, - request: RasterRequest, - options: RasterLoadOptions, - ): Promise> { - const module = request.module; - const descriptor = module.descriptor(request.options); - const descriptorKey = rasterDescriptorKey(module, request.options, descriptor); - const rasterKey = await deriveRasterKey({ - descriptor, - extension: module.extension, - kind: module.kind, - version: module.version, - }); - this.#assertActive(); - options.signal?.throwIfAborted(); - - this.#observeRegistry(font); - let modules = this.#fonts.get(font); - if (modules === undefined) { - modules = new Map(); - this.#fonts.set(font, modules); - } - let rasters = modules.get(module); - if (rasters === undefined) { - rasters = new Map(); - modules.set(module, rasters); - } - const existing = rasters.get(rasterKey); - if (existing !== undefined) { - if (existing.controller.signal.aborted) { - if (rasters.get(rasterKey) === existing) rasters.delete(rasterKey); - return this.#load(font, request, options); - } - const loaded = await consumeCachedRaster(existing, options.signal); - if (this.#fonts.get(font)?.get(module)?.get(rasterKey) !== existing) { - throw rasterLoadInvalidated(); - } - if (isCurrentRaster(font, rasterKey, loaded.artifact)) return loaded; - rasters.delete(rasterKey); - module.dispose(loaded.resource); - return this.#load(font, request, options); - } - - const controller = new AbortController(); - const loadOptions: RasterLoadOptions = { ...options, signal: controller.signal }; - let cached!: CachedRaster; - const promise = this.#loadUncached(font, request, rasterKey, loadOptions) - .then((loaded) => { - if ( - this.#disposed || - controller.signal.aborted || - this.#fonts.get(font)?.get(module)?.get(rasterKey) !== cached || - !isCurrentRaster(font, rasterKey, loaded.artifact) - ) { - module.dispose(loaded.resource); - throw rasterLoadInvalidated(); - } - return loaded; - }) - .then( - (loaded) => { - cached.settled = true; - cached.value = loaded; - return loaded; - }, - (error: unknown) => { - cached.settled = true; - if (rasters?.get(rasterKey) === cached) rasters.delete(rasterKey); - throw error; - }, - ); - cached = { promise, controller, descriptorKey, value: undefined, consumers: 0, settled: false }; - rasters.set(rasterKey, cached); - return consumeCachedRaster(cached, options.signal); - } - - async #loadUncached( - font: RegisteredFont, - request: RasterRequest, - rasterKey: RasterKey, - options: RasterLoadOptions, - ): Promise> { - const module = request.module; - const kind = module.kind as RasterKindOf; - let artifact: LoadedRaster['artifact']; - try { - artifact = await font.loadRaster({ rasterKey, kind }, options); - } catch (error) { - if (!isRasterMiss(error)) throw error; - artifact = await this.#runtimeBake(font, request, rasterKey, options.signal); - } - options.signal?.throwIfAborted(); - const resource = await module.decode(font, artifact, options.signal); - // The cache publication guard owns this resource once decode returns, including - // disposal when a module ignores cancellation and completes after invalidation. - return { module, artifact, resource }; - } - - async #runtimeBake( - font: RegisteredFont, - request: RasterRequest, - rasterKey: RasterKey, - signal: AbortSignal | undefined, - ) { - const loadBaker = request.module.runtimeBaker; - if (loadBaker === undefined) { - throw new FontLoadError('RASTER_NOT_FOUND', `${request.module.kind} has no baked artifact or runtime baker`); - } - const registeredData = getRegisteredFontData(font); - const source = registeredData.sourceBytes; - if (source === undefined) { - throw new FontLoadError( - 'RASTER_SOURCE_UNAVAILABLE', - `${request.module.kind} runtime generation requires retained source bytes`, - ); - } - signal?.throwIfAborted(); - const imported = await loadBaker(); - const baker = 'default' in imported ? imported.default : imported; - assertMatchingBaker(request.module, baker); - const baked = await baker.bake({ - source: source.slice(), - font, - fontFaceIndex: registeredData.fontFaceIndex, - rasterKey, - options: request.options, - ...(signal === undefined ? {} : { signal }), - }); - assertMatchingArtifact(request.module, rasterKey, baked); - const rasterArtifacts = baked.artifacts.filter((artifact) => artifact.role === 'raster'); - if (rasterArtifacts.length !== 1) { - throw new FontLoadError( - 'INVALID_RASTER_ASSET', - 'runtime raster generation must return one authoritative raster artifact', - ); - } - const rasterArtifact = rasterArtifacts[0]; - if (rasterArtifact === undefined) throw new Error('unreachable raster artifact state'); - const registered = await registeredFontRegistry(font)._attachGeneratedRaster(font, rasterArtifact.bytes, { - rasterKey, - kind: baked.kind, - extension: baked.extension, - version: baked.version, - }); - if (registered.kind !== request.module.kind) { - registered.dispose(); - throw new FontLoadError( - 'RASTER_INCOMPATIBLE', - 'registered runtime raster kind does not match the selected module', - ); - } - return registered as LoadedRaster['artifact']; - } - - #observeRegistry(font: RegisteredFont): void { - const registry = registeredFontRegistry(font); - const observed = this.#registries.get(registry); - if (observed !== undefined) { - observed.fonts.add(font); - return; - } - this.#registries.set(registry, { - fonts: new Set([font]), - unsubscribe: registry._onFontDispose((disposed) => this.#disposeFont(disposed, registry)), - }); - } - - #disposeFont(font: RegisteredFont, registry: ReturnType): void { - this.#disposeFontResources(font); - const observed = this.#registries.get(registry); - if (observed === undefined) return; - observed.fonts.delete(font); - if (observed.fonts.size === 0) { - observed.unsubscribe(); - this.#registries.delete(registry); - } - } - - #disposeFontResources(font: RegisteredFont): void { - const modules = this.#fonts.get(font); - if (modules === undefined) return; - this.#fonts.delete(font); - for (const [module, rasters] of modules) { - for (const { promise, controller } of rasters.values()) { - controller.abort(rasterLoadInvalidated()); - void promise.then( - ({ resource }) => module.dispose(resource), - () => undefined, - ); - } - } - } - - #assertActive(): void { - if (this.#disposed) throw new Error('raster runtime is disposed'); - } -} - -function rasterDescriptorKey( - module: AnyRasterModule, - options: unknown, - descriptor = module.descriptor(options), -): string { - return canonicalJson({ descriptor, extension: module.extension, kind: module.kind, version: module.version }); -} - -function isCurrentRaster(font: RegisteredFont, rasterKey: RasterKey, raster: LoadedAnyRaster['artifact']): boolean { - try { - return font.getRaster(rasterKey) === raster; - } catch { - return false; - } -} - -function rasterLoadInvalidated(): DOMException { - return new DOMException('The raster load was invalidated', 'AbortError'); -} - -function isRasterMiss(error: unknown): boolean { - return error instanceof FontLoadError && (error.code === 'RASTER_NOT_FOUND' || error.code === 'RASTER_FETCH'); -} - -function assertMatchingBaker(module: AnyRasterModule, baker: RuntimeRasterBakerModule): void { - if (baker.kind !== module.kind) { - throw new FontLoadError('RASTER_INCOMPATIBLE', 'runtime raster baker kind does not match module'); - } -} - -function assertMatchingArtifact(module: AnyRasterModule, rasterKey: RasterKey, artifact: RasterBakeArtifact): void { - if ( - artifact.rasterKey !== rasterKey || - artifact.kind !== module.kind || - artifact.extension !== module.extension || - artifact.version !== module.version - ) { - throw new FontLoadError('RASTER_INCOMPATIBLE', 'runtime raster artifact does not match the selected module'); - } -} - -function consumeCachedRaster( - cached: CachedRaster, - signal: AbortSignal | undefined, -): Promise> { - signal?.throwIfAborted(); - cached.consumers += 1; - return new Promise>((resolve, reject) => { - let active = true; - const release = (): void => { - if (!active) return; - active = false; - signal?.removeEventListener('abort', aborted); - cached.consumers -= 1; - if (cached.consumers === 0 && !cached.settled) { - cached.controller.abort(signal?.reason ?? rasterLoadInvalidated()); - } - }; - const aborted = (): void => { - release(); - reject(signal?.reason ?? rasterLoadInvalidated()); - }; - signal?.addEventListener('abort', aborted, { once: true }); - void (cached.promise as Promise>).then( - (value) => { - if (!active) return; - release(); - resolve(value); - }, - (error: unknown) => { - if (!active) return; - release(); - reject(error); - }, - ); - }); -} diff --git a/packages/text/src/raster-technique.ts b/packages/text/src/raster-technique.ts index f5584d74..b0d2b211 100644 --- a/packages/text/src/raster-technique.ts +++ b/packages/text/src/raster-technique.ts @@ -1,13 +1,5 @@ import type { RegisteredFont } from './font.js'; -import type { GlyphPaint, ResolvedPaint } from './paint.js'; -import type { - AnyRasterModule, - JsonValue, - RasterModuleOptionsOf, - RasterOptionsArgument, - RegisteredRaster, - RuntimeRasterBakerLoader, -} from './raster.js'; +import type { JsonValue, RasterOptionsArgument, RegisteredRaster, RuntimeRasterBakerLoader } from './raster.js'; declare const rasterTechniqueIdBrand: unique symbol; declare const rasterResourceIdBrand: unique symbol; @@ -19,26 +11,10 @@ export type RasterTechniqueId = string & { readonly [rasterTechniqueIdBrand]: tr /** Stable technique-authored identity for one physical raster resource. */ export type RasterResourceId = string & { readonly [rasterResourceIdBrand]: true }; -/** One logical glyph-instance range. */ -export interface GlyphRange { - readonly start: number; - readonly count: number; -} - -/** Every public canonical storage field is an independently addressable typed-array view. */ -export type GlyphBatchStorageShape = { - readonly [Field in keyof Storage]: ArrayBufferView; -}; - -/** Type-erased canonical storage at heterogeneous runtime boundaries. */ -export type GlyphBatchStorage = Readonly>>; - -interface RasterTechniqueTypeMap { +interface RasterTechniqueTypeMap { readonly options: Options; readonly descriptor: Descriptor; readonly data: Data; - readonly binding: Binding; - readonly storage: Storage; } /** Common identity retained when concrete technique-associated types are intentionally erased. */ @@ -47,50 +23,18 @@ export interface AnyRasterTechnique { readonly kind: string; readonly extension: string; readonly version: number; - readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; -} - -/** - * Valid only for the duration of the `select` or `writeStorage` call that - * receives it. Core pools these objects across updates, so a technique that - * needs a field beyond the call must copy the value rather than retain the - * input. - */ -export interface RasterGlyphInput { - readonly data: Data; - readonly glyphId: number; - readonly fontSize: number; - /** Paragraph-local displayed origin after any caller-authored glyph override. */ - readonly originX: number; - /** Paragraph-local displayed origin after any caller-authored glyph override. */ - readonly originY: number; - readonly rasterPixelRatio: number; - readonly paint: ResolvedPaint; -} - -export interface RasterGlyphWriteInput { - readonly data: Data; - /** The selection already used by core to form this physical glyph batch. */ - readonly binding: Binding; - readonly glyphs: readonly RasterGlyphInput[]; -} - -export interface RasterGlyphSelection { - readonly resource: RasterResourceId; - readonly pipelineVariant: number; - readonly binding: Binding; + readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; } +/** Renderer-neutral raster identity, decoding, and ownership contract. */ export interface RasterTechnique< Id extends RasterTechniqueId, Kind extends string, Options, Descriptor extends JsonValue, Data, - Binding, - Storage extends GlyphBatchStorageShape, > extends AnyRasterTechnique { - readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; + readonly [rasterTechniqueTypes]?: RasterTechniqueTypeMap; readonly id: Id; readonly kind: Kind; @@ -100,55 +44,44 @@ export interface RasterTechnique< descriptor(options: RasterOptionsArgument): Descriptor; decode(font: RegisteredFont, raster: RegisteredRaster, signal?: AbortSignal): Promise; - /** Return undefined when a shaped glyph intentionally has no renderable raster instance. */ - select(input: RasterGlyphInput): RasterGlyphSelection | undefined; - createStorage(capacity: number): Storage; - writeStorage(storage: Storage, range: GlyphRange, input: RasterGlyphWriteInput): void; - validatePaint?(paint: GlyphPaint): void; dispose(data: Data): void; } export type RasterTechniqueTypesOf = - Technique extends RasterTechnique< - infer _Id, - infer _Kind, - infer Options, - infer Descriptor, - infer Data, - infer Binding, - infer Storage - > - ? RasterTechniqueTypeMap - : RasterTechniqueTypeMap; - -export type RasterTechniqueOptionsOf = - RasterTechniqueTypesOf['options']; - -/** Options inferred from either the target-v1 technique or the merged-v0 raster module. */ -export type RasterOptionsOf = Raster extends AnyRasterTechnique - ? RasterTechniqueOptionsOf - : Raster extends AnyRasterModule - ? RasterModuleOptionsOf - : never; + Technique extends RasterTechnique + ? RasterTechniqueTypeMap + : RasterTechniqueTypeMap; + +export type RasterOptionsOf = RasterTechniqueTypesOf['options']; + +export type RasterTechniqueOptionsOf = RasterOptionsOf; + +export type RasterTechniqueRequest = { + readonly technique: Technique; +} & ([RasterOptionsOf] extends [never] + ? { readonly options?: never } + : undefined extends RasterOptionsOf + ? { readonly options?: RasterOptionsOf } + : { readonly options: RasterOptionsOf }); + +export type RasterTechniqueInput = [RasterOptionsOf] extends [never] + ? Technique | RasterTechniqueRequest + : undefined extends RasterOptionsOf + ? Technique | RasterTechniqueRequest + : RasterTechniqueRequest; export type RasterTechniqueDescriptorOf = RasterTechniqueTypesOf['descriptor']; export type RasterDataOf = RasterTechniqueTypesOf['data']; -export type RasterBindingOf = RasterTechniqueTypesOf['binding']; - -export type GlyphBatchStorageOf = RasterTechniqueTypesOf['storage']; - type RasterTechniqueDefinition< Id extends string, Kind extends string, Options, Descriptor extends JsonValue, Data, - Binding, - Storage extends GlyphBatchStorageShape, -> = Omit, 'id'> & { +> = Omit, 'id'> & { readonly id: Id; }; @@ -159,13 +92,11 @@ export function defineRasterTechnique< Options, Descriptor extends JsonValue, Data, - Binding, - Storage extends GlyphBatchStorageShape, >( - technique: RasterTechniqueDefinition, -): RasterTechnique { + technique: RasterTechniqueDefinition, +): RasterTechnique { assertIdentifier(technique.id, 'raster technique ID'); - return technique as RasterTechnique; + return technique as RasterTechnique; } /** Brand a stable resource identity produced by a portable technique. */ diff --git a/packages/text/src/raster.ts b/packages/text/src/raster.ts index 6d58004d..53b88c0a 100644 --- a/packages/text/src/raster.ts +++ b/packages/text/src/raster.ts @@ -1,8 +1,6 @@ import type { RegisteredFont } from './font.js'; -import type { ParagraphLayout } from './layout.js'; -import type { FontHandle, FontSlot, RasterHandle, RasterKey, Sha256Hex } from './identity.js'; +import type { FontHandle, RasterHandle, RasterKey, Sha256Hex } from './identity.js'; import type { BakeProgressListener, RasterBakeArtifact } from './bake.js'; -import type { GlyphPaint } from './paint.js'; export type RasterKind = string; @@ -10,8 +8,6 @@ export type JsonValue = null | boolean | number | string | readonly JsonValue[] export type RasterOptionsArgument = [Options] extends [never] ? undefined : Options; -type RasterOptionsOptional = [Options] extends [never] ? true : undefined extends Options ? true : false; - export type StaticNumberTuple = number extends Values[number] ? never : Values; @@ -111,131 +107,4 @@ export type RuntimeRasterBakerLoader = () => Promi RuntimeRasterBakerModule | { readonly default: RuntimeRasterBakerModule } >; -export interface RasterModule { - readonly kind: Kind; - readonly extension: string; - readonly version: number; - readonly runtimeBaker?: RuntimeRasterBakerLoader; - - descriptor(options: RasterOptionsArgument): JsonValue; - - decode(font: RegisteredFont, raster: RegisteredRaster, signal?: AbortSignal): Promise; - - prepare(layout: ParagraphLayout, resource: Resource, fontSlot: FontSlot, signal?: AbortSignal): void | Promise; - /** - * Stage one complete renderer-owned batch generation. The implementation must not mutate - * `previous` before commit and must release partial allocations before throwing. Each stage - * owns its unpublished state independently: aborting one candidate must not affect another - * candidate staged concurrently against the same `previous` batch. - */ - stageBatch( - previous: DrawBatch | undefined, - layout: ParagraphLayout, - resource: Resource, - fontSlot: FontSlot, - paint: GlyphPaint, - rasterPixelRatio: number, - ): RasterBatchStage; - validatePaint?(paint: GlyphPaint): void; - dispose(resource: Resource): void; -} - -// Deliberately erase every module parameter at heterogeneous token/cache boundaries. -// Keeping `Kind` as `string` makes the mutable-in-position module surface invariant, -// so a concrete `RasterModule<'bitmap', ...>` no longer satisfies this erasure. -export type AnyRasterModule = RasterModule; - -export type RasterKindOf = - Raster extends RasterModule ? Kind : Raster['kind']; - -export type RasterResourceOf = - Module extends RasterModule ? Resource : never; - -export type RasterBatchOf = - Module extends RasterModule ? DrawBatch : never; - -export type RasterModuleOptionsOf = - Module extends RasterModule ? Options : never; - -type RasterRequestBase = { - readonly module: Module; -}; - -export type RasterRequest = RasterRequestBase & - ([RasterModuleOptionsOf] extends [never] - ? { readonly options?: never } - : undefined extends RasterModuleOptionsOf - ? { readonly options?: RasterModuleOptionsOf } - : { readonly options: RasterModuleOptionsOf }); - -export type RasterInput = - RasterOptionsOptional> extends true - ? Module | RasterRequest - : RasterRequest; - -export type AnyRasterInput = - | AnyRasterModule - | { - readonly module: AnyRasterModule; - readonly options?: unknown; - }; - -export interface LoadedRaster { - readonly module: Module; - readonly artifact: RegisteredRaster>; - readonly resource: RasterResourceOf; -} - -/** Renderer-neutral ownership surface implemented by every raster-owned batch. */ -export interface RasterDrawBatch { - /** Release all renderer resources owned by this batch. Safe to call repeatedly. */ - dispose(): void; -} - -/** Renderer adapter batch that publishes one host-owned scene object. */ -export interface RasterObjectDrawBatch extends RasterDrawBatch { - readonly object: SceneObject; - /** Synchronously and infallibly apply the owning object's order while preserving draw-local ordering. */ - setRenderOrderBase(base: number): void; -} - -/** - * One unpublished raster generation. The target may retain `previous` or replace it, but staging - * must not mutate committed state. Commit is synchronous and infallible; abort is idempotent and - * becomes a no-op after commit transfers batch ownership to the caller. - */ -export interface RasterBatchStage { - readonly batch: DrawBatch; - /** Publish the fully validated stage. Safe to call repeatedly. */ - commit(): void; - /** Release an unpublished stage without touching the live batch. Safe to call repeatedly. */ - abort(): void; -} - -/** Build the required one-shot ownership state machine around a package-owned staged update. */ -export function defineRasterBatchStage( - batch: DrawBatch, - publish: () => void, - release: () => void, -): RasterBatchStage { - let state: 'staged' | 'committed' | 'aborted' = 'staged'; - return { - batch, - commit() { - if (state !== 'staged') return; - state = 'committed'; - publish(); - }, - abort() { - if (state !== 'staged') return; - state = 'aborted'; - release(); - }, - }; -} - -export function defineRaster( - module: RasterModule, -): RasterModule { - return module; -} +export type RasterKindOf = Raster['kind']; diff --git a/packages/text/src/raster/bitmap-technique.ts b/packages/text/src/raster/bitmap-technique.ts index a0a0a9c1..d5285a70 100644 --- a/packages/text/src/raster/bitmap-technique.ts +++ b/packages/text/src/raster/bitmap-technique.ts @@ -11,7 +11,6 @@ import { } from '../internal/bitmap-contract.js'; import { nearestBitmapStrikeIndex } from '../internal/bitmap-strike.js'; import { - ABSENT_GLYPH_PAGE, DENSE_GLYPH_RECORD_STRIDE, decodeEmbeddedLosslessAtlasPage, jsonArray, @@ -22,15 +21,11 @@ import { type RasterAtlasPage, } from '../internal/raster-atlas.js'; import { decodeRasterCoverage } from '../internal/raster-coverage-artifact.js'; -import type { GlyphPaint, ResolvedPaint } from '../paint.js'; -import { RasterCoverageError, type RasterCoverage } from '../raster-coverage.js'; +import type { RasterCoverage } from '../raster-coverage.js'; import type { RegisteredRaster } from '../raster.js'; import { defineRasterResourceId, defineRasterTechnique, - type GlyphRange, - type RasterGlyphInput, - type RasterGlyphWriteInput, type RasterResourceId, type RasterTechnique, type RasterTechniqueId, @@ -51,7 +46,6 @@ export { } from '../internal/bitmap-contract.js'; const RECORD_STRIDE = DENSE_GLYPH_RECORD_STRIDE; -const ABSENT_PAGE = ABSENT_GLYPH_PAGE; const MAX_RUNTIME_TEXTURE_BYTES = 256 * 1024 * 1024; export interface BitmapTechniqueOptions { @@ -64,20 +58,11 @@ export interface BitmapPageData extends RasterAtlasPage { readonly resource: RasterResourceId; } -export interface BitmapBinding { - readonly strike: number; - readonly page: number; - readonly ppem: number; - readonly width: number; - readonly height: number; -} - export interface BitmapStrikeData { readonly ppem: number; readonly planeUnitsPerEm: number; readonly records: Uint8Array; readonly pages: readonly BitmapPageData[]; - readonly bindings: readonly BitmapBinding[]; } export interface BitmapData { @@ -98,23 +83,13 @@ export function selectBitmapStrikePpem( return strikes[nearestBitmapStrikeIndex(strikes, cssFontSize, rasterPixelRatio)]!.ppem; } -export interface BitmapGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly uvOrigins: Float32Array; - readonly uvSizes: Float32Array; - readonly colors: Float32Array; -} - -/** Renderer-neutral Bitmap decoding, strike/page selection, and canonical instance packing. */ +/** Renderer-neutral Bitmap identity, decoding, and ownership. */ export const bitmap: RasterTechnique< RasterTechniqueId & 'pmndrs.bitmap', typeof BITMAP_KIND, BitmapTechniqueOptions, BitmapDescriptorV0, - BitmapData, - BitmapBinding, - BitmapGlyphBatchStorage + BitmapData > = defineRasterTechnique({ id: 'pmndrs.bitmap', kind: BITMAP_KIND, @@ -130,43 +105,6 @@ export const bitmap: RasterTechnique< signal?.throwIfAborted(); return data; }, - select(input: RasterGlyphInput) { - const { data, glyphId } = input; - assertGlyphId(data, glyphId); - assertCoverage(data, glyphId); - if (!Number.isFinite(input.fontSize) || input.fontSize <= 0) { - throw new TypeError('bitmap glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(input.rasterPixelRatio) || input.rasterPixelRatio <= 0) { - throw new TypeError('bitmap rasterPixelRatio must be a positive finite value'); - } - const strikeIndex = nearestBitmapStrikeIndex(data.strikes, input.fontSize, input.rasterPixelRatio); - const strike = data.strikes[strikeIndex]!; - const pageIndex = recordView(strike).getUint16(glyphId * RECORD_STRIDE + 16, true); - if (pageIndex === ABSENT_PAGE) return undefined; - const page = strike.pages[pageIndex]; - const binding = strike.bindings[pageIndex]; - if (page === undefined || binding === undefined) throw new TypeError('bitmap glyph references a missing page'); - return { resource: page.resource, pipelineVariant: 0, binding }; - }, - createStorage(capacity: number): BitmapGlyphBatchStorage { - assertCapacity(capacity); - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - uvOrigins: new Float32Array(capacity * 2), - uvSizes: new Float32Array(capacity * 2), - colors: new Float32Array(capacity * 4), - }; - }, - writeStorage( - storage: BitmapGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - writeBitmapStorage(storage, range, input); - }, - validatePaint: assertBitmapPaint, dispose() {}, }); @@ -252,108 +190,7 @@ async function decodeBitmapData(font: RegisteredFont, raster: RegisteredRaster): }, ); validateDenseGlyphRecords(records, pages, 'bitmap'); - const bindings = pages.map((page, pageIndex) => - Object.freeze({ strike: strikeIndex, page: pageIndex, ppem, width: page.width, height: page.height }), - ); - strikes.push({ ppem, planeUnitsPerEm, records, pages, bindings }); + strikes.push({ ppem, planeUnitsPerEm, records, pages }); } return { strikes, ...(coverage === undefined ? {} : { coverage: coverage.bits }) }; } - -function writeBitmapStorage( - storage: BitmapGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, -): void { - assertWriteRange(storage, range, input.glyphs.length); - const strike = input.data.strikes[input.binding.strike]; - if (strike?.bindings[input.binding.page] !== input.binding) { - throw new TypeError('bitmap write binding does not belong to its data'); - } - const records = recordView(strike); - for (let index = 0; index < input.glyphs.length; index += 1) { - const glyph = input.glyphs[index]!; - assertGlyphId(input.data, glyph.glyphId); - assertCoverage(input.data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('bitmap glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('bitmap glyph origins must be finite values'); - } - assertResolvedPaint(glyph.paint); - const record = glyph.glyphId * RECORD_STRIDE; - const pageIndex = records.getUint16(record + 16, true); - if (pageIndex !== input.binding.page) throw new TypeError('bitmap glyph does not belong to the selected page'); - const scale = glyph.fontSize / strike.planeUnitsPerEm; - const planeLeft = records.getInt16(record, true); - const planeBottom = records.getInt16(record + 2, true); - const planeRight = records.getInt16(record + 4, true); - const planeTop = records.getInt16(record + 6, true); - const atlasLeft = records.getUint16(record + 8, true); - const atlasTop = records.getUint16(record + 10, true); - const atlasRight = records.getUint16(record + 12, true); - const atlasBottom = records.getUint16(record + 14, true); - const instance = range.start + index; - const vectorOffset = instance * 2; - storage.origins[vectorOffset] = glyph.originX + planeLeft * scale; - storage.origins[vectorOffset + 1] = glyph.originY - planeTop * scale; - storage.sizes[vectorOffset] = (planeRight - planeLeft) * scale; - storage.sizes[vectorOffset + 1] = (planeTop - planeBottom) * scale; - storage.uvOrigins[vectorOffset] = atlasLeft / input.binding.width; - storage.uvOrigins[vectorOffset + 1] = atlasTop / input.binding.height; - storage.uvSizes[vectorOffset] = (atlasRight - atlasLeft) / input.binding.width; - storage.uvSizes[vectorOffset + 1] = (atlasBottom - atlasTop) / input.binding.height; - storage.colors.set(glyph.paint.color, instance * 4); - } -} - -function recordView(strike: BitmapStrikeData): DataView { - return new DataView(strike.records.buffer, strike.records.byteOffset, strike.records.byteLength); -} - -function assertGlyphId(data: BitmapData, glyphId: number): void { - const recordCount = data.strikes[0]!.records.byteLength / RECORD_STRIDE; - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= recordCount) { - throw new TypeError('bitmap glyph is outside the registered font'); - } -} - -function assertCoverage(data: BitmapData, glyphId: number): void { - if (data.coverage !== undefined && (data.coverage[glyphId >> 3]! & (1 << (glyphId & 7))) === 0) { - throw new RasterCoverageError(BITMAP_KIND, [glyphId]); - } -} - -function assertBitmapPaint(paint: GlyphPaint): void { - for (const entry of paint.palette) assertResolvedPaint(entry); -} - -function assertResolvedPaint(paint: ResolvedPaint): void { - if (paint.outline !== undefined || paint.shadow !== undefined) { - throw new TypeError('bitmap raster does not support outline or shadow paint'); - } - if (paint.color.length !== 4 || paint.color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError('bitmap color must contain four finite linear values in [0, 1]'); - } -} - -function assertWriteRange(storage: BitmapGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.colors.length / 4; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('bitmap storage write range is outside its capacity'); - } -} - -function assertCapacity(capacity: number): void { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('bitmap storage capacity must be a non-negative safe integer'); - } -} diff --git a/packages/text/src/raster/msdf.ts b/packages/text/src/raster/msdf.ts index 8df664df..8a4b10e8 100644 --- a/packages/text/src/raster/msdf.ts +++ b/packages/text/src/raster/msdf.ts @@ -19,7 +19,6 @@ import { type MsdfOptions, } from '../internal/msdf-contract.js'; import { - ABSENT_GLYPH_PAGE, DENSE_GLYPH_RECORD_STRIDE, decodeEmbeddedLosslessAtlasPage, jsonArray, @@ -29,15 +28,10 @@ import { type RasterAtlasPage, } from '../internal/raster-atlas.js'; import { decodeRasterCoverage } from '../internal/raster-coverage-artifact.js'; -import type { GlyphPaint, LinearRgba, ResolvedPaint } from '../paint.js'; -import { RasterCoverageError } from '../raster-coverage.js'; import type { JsonValue, RegisteredRaster } from '../raster.js'; import { defineRasterResourceId, defineRasterTechnique, - type GlyphRange, - type RasterGlyphInput, - type RasterGlyphWriteInput, type RasterResourceId, type RasterTechnique, type RasterTechniqueId, @@ -64,7 +58,6 @@ export { export { DENSE_GLYPH_RECORD_STRIDE as MSDF_GLYPH_RECORD_STRIDE } from '../internal/raster-atlas.js'; const RECORD_STRIDE = DENSE_GLYPH_RECORD_STRIDE; -const ABSENT_PAGE = ABSENT_GLYPH_PAGE; const MAX_RUNTIME_TEXTURE_BYTES = 256 * 1024 * 1024; export interface MsdfPageData extends RasterAtlasPage { @@ -88,29 +81,13 @@ export interface MsdfData { readonly pages: readonly MsdfPageData[]; } -export interface MsdfGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly uvOrigins: Float32Array; - readonly uvSizes: Float32Array; - readonly uvBounds: Float32Array; - readonly shadowOffsets: Float32Array; - readonly fillColors: Float32Array; - readonly outlineColors: Float32Array; - readonly outlineWidths: Float32Array; - readonly shadowColors: Float32Array; - readonly pageIndices: Uint16Array; -} - -/** Renderer-neutral MSDF decoding, physical selection, and canonical instance packing. */ +/** Renderer-neutral MSDF identity, decoding, and ownership. */ export const msdf: RasterTechnique< RasterTechniqueId & 'pmndrs.msdf', typeof MSDF_KIND, MsdfOptions | undefined, MsdfDescriptorV0, - MsdfData, - MsdfBinding, - MsdfGlyphBatchStorage + MsdfData > = defineRasterTechnique({ id: 'pmndrs.msdf', kind: MSDF_KIND, @@ -126,39 +103,6 @@ export const msdf: RasterTechnique< signal?.throwIfAborted(); return data; }, - select(input: RasterGlyphInput) { - const { data, glyphId } = input; - assertGlyphId(data, glyphId); - assertCoverage(data, glyphId); - const pageIndex = recordView(data).getUint16(glyphId * RECORD_STRIDE + 16, true); - if (pageIndex === ABSENT_PAGE) return undefined; - if (data.pages[pageIndex] === undefined) throw new TypeError('MSDF glyph references a missing page'); - return { resource: data.resource, pipelineVariant: 0, binding: data.binding }; - }, - createStorage(capacity: number): MsdfGlyphBatchStorage { - assertCapacity(capacity); - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - uvOrigins: new Float32Array(capacity * 2), - uvSizes: new Float32Array(capacity * 2), - uvBounds: new Float32Array(capacity * 4), - shadowOffsets: new Float32Array(capacity * 2), - fillColors: new Float32Array(capacity * 4), - outlineColors: new Float32Array(capacity * 4), - outlineWidths: new Float32Array(capacity), - shadowColors: new Float32Array(capacity * 4), - pageIndices: new Uint16Array(capacity), - }; - }, - writeStorage( - storage: MsdfGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - writeMsdfStorage(storage, range, input); - }, - validatePaint: assertMsdfPaint, dispose() {}, }); @@ -243,133 +187,6 @@ async function decodeMsdfData(font: RegisteredFont, raster: RegisteredRaster): P }; } -function writeMsdfStorage( - storage: MsdfGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, -): void { - assertWriteRange(storage, range, input.glyphs.length); - if (input.binding !== input.data.binding) throw new TypeError('MSDF write binding does not belong to its data'); - const records = recordView(input.data); - for (let index = 0; index < input.glyphs.length; index += 1) { - writeMsdfGlyph(storage, range.start + index, input.data, records, input.glyphs[index]!); - } -} - -function writeMsdfGlyph( - storage: MsdfGlyphBatchStorage, - instance: number, - data: MsdfData, - records: DataView, - glyph: RasterGlyphInput, -): void { - assertGlyphId(data, glyph.glyphId); - assertCoverage(data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('MSDF glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('MSDF glyph origins must be finite values'); - } - assertResolvedPaint(glyph.paint); - const record = glyph.glyphId * RECORD_STRIDE; - const planeLeft = records.getInt16(record, true); - const planeBottom = records.getInt16(record + 2, true); - const planeRight = records.getInt16(record + 4, true); - const planeTop = records.getInt16(record + 6, true); - const atlasLeft = records.getUint16(record + 8, true); - const atlasTop = records.getUint16(record + 10, true); - const atlasRight = records.getUint16(record + 12, true); - const atlasBottom = records.getUint16(record + 14, true); - const pageIndex = records.getUint16(record + 16, true); - if (pageIndex === ABSENT_PAGE || data.pages[pageIndex] === undefined) { - throw new TypeError('MSDF storage write requires a selected renderable glyph'); - } - const scale = glyph.fontSize / data.planeUnitsPerEm; - const baseOriginX = glyph.originX + planeLeft * scale; - const baseOriginY = glyph.originY - planeTop * scale; - const baseWidth = (planeRight - planeLeft) * scale; - const baseHeight = (planeTop - planeBottom) * scale; - const shadowX = glyph.paint.shadow?.offset[0] ?? 0; - const shadowY = glyph.paint.shadow?.offset[1] ?? 0; - const originX = baseOriginX + Math.min(0, shadowX); - const originY = baseOriginY + Math.min(0, shadowY); - const width = baseWidth + Math.abs(shadowX); - const height = baseHeight + Math.abs(shadowY); - const baseUvX = atlasLeft / data.binding.width; - const baseUvY = atlasTop / data.binding.height; - const baseUvWidth = (atlasRight - atlasLeft) / data.binding.width; - const baseUvHeight = (atlasBottom - atlasTop) / data.binding.height; - const uvPerUnitX = baseUvWidth / baseWidth; - const uvPerUnitY = baseUvHeight / baseHeight; - const vectorOffset = instance * 2; - storage.origins[vectorOffset] = originX; - storage.origins[vectorOffset + 1] = originY; - storage.sizes[vectorOffset] = width; - storage.sizes[vectorOffset + 1] = height; - storage.uvOrigins[vectorOffset] = baseUvX + (originX - baseOriginX) * uvPerUnitX; - storage.uvOrigins[vectorOffset + 1] = baseUvY + (originY - baseOriginY) * uvPerUnitY; - storage.uvSizes[vectorOffset] = width * uvPerUnitX; - storage.uvSizes[vectorOffset + 1] = height * uvPerUnitY; - storage.shadowOffsets[vectorOffset] = shadowX * uvPerUnitX; - storage.shadowOffsets[vectorOffset + 1] = shadowY * uvPerUnitY; - const boundsOffset = instance * 4; - storage.uvBounds[boundsOffset] = baseUvX; - storage.uvBounds[boundsOffset + 1] = baseUvY; - storage.uvBounds[boundsOffset + 2] = baseUvX + baseUvWidth; - storage.uvBounds[boundsOffset + 3] = baseUvY + baseUvHeight; - storage.fillColors.set(glyph.paint.color, boundsOffset); - storage.outlineColors.set(glyph.paint.outline?.color ?? TRANSPARENT, boundsOffset); - storage.shadowColors.set(glyph.paint.shadow?.color ?? TRANSPARENT, boundsOffset); - storage.outlineWidths[instance] = resolveOutlineDistance(data, glyph.fontSize, glyph.paint.outline?.width ?? 0); - storage.pageIndices[instance] = pageIndex; -} - -function recordView(data: MsdfData): DataView { - return new DataView(data.records.buffer, data.records.byteOffset, data.records.byteLength); -} - -function assertGlyphId(data: MsdfData, glyphId: number): void { - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= data.records.byteLength / RECORD_STRIDE) { - throw new TypeError('MSDF glyph is outside the registered font'); - } -} - -function assertCoverage(data: MsdfData, glyphId: number): void { - if (data.coverage !== undefined && (data.coverage[glyphId >> 3]! & (1 << (glyphId & 7))) === 0) { - throw new RasterCoverageError(MSDF_KIND, [glyphId]); - } -} - -function resolveOutlineDistance(data: MsdfData, fontSize: number, outlineWidth: number): number { - const atlasPixels = outlineWidth / (fontSize / data.planeUnitsPerEm); - const maximum = data.pixelRange / 2; - if (atlasPixels > maximum) { - throw new RangeError(`MSDF outline width exceeds the ${maximum}-atlas-pixel field limit`); - } - return atlasPixels / data.pixelRange; -} - -function assertWriteRange(storage: MsdfGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.pageIndices.length; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('MSDF storage write range is outside its capacity'); - } -} - -function assertCapacity(capacity: number): void { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('MSDF storage capacity must be a non-negative safe integer'); - } -} - function configuredInteger(value: unknown, label: string, maximum: number): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1 || value > maximum) { throw new TypeError(`${label} must be an integer in 1..=${maximum}`); @@ -386,31 +203,3 @@ function validateMsdfPageDirectory(value: JsonValue, pageIndex: number): void { throw new TypeError('MSDF V0 pages accept only the lossless rgba8unorm baseline'); } } - -function assertMsdfPaint(paint: GlyphPaint): void { - for (const entry of paint.palette) assertResolvedPaint(entry); -} - -function assertResolvedPaint(paint: ResolvedPaint): void { - assertLinearColor(paint.color, 'MSDF fill'); - if (paint.outline !== undefined) { - assertLinearColor(paint.outline.color, 'MSDF outline'); - if (!Number.isFinite(paint.outline.width) || paint.outline.width < 0) { - throw new TypeError('MSDF outline width must be a non-negative finite value'); - } - } - if (paint.shadow !== undefined) { - assertLinearColor(paint.shadow.color, 'MSDF shadow'); - if (paint.shadow.offset.some((value) => !Number.isFinite(value))) { - throw new TypeError('MSDF shadow offsets must be finite values'); - } - } -} - -function assertLinearColor(color: readonly number[], label: string): void { - if (color.length !== 4 || color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError(`${label} color must contain four finite linear values in [0, 1]`); - } -} - -const TRANSPARENT: LinearRgba = [0, 0, 0, 0]; diff --git a/packages/text/src/raster/slug-technique.ts b/packages/text/src/raster/slug-technique.ts index a7ba7e72..1c935e74 100644 --- a/packages/text/src/raster/slug-technique.ts +++ b/packages/text/src/raster/slug-technique.ts @@ -19,14 +19,10 @@ import { slugDescriptor, type SlugDescriptorV0, } from '../internal/slug-contract.js'; -import type { GlyphPaint, ResolvedPaint } from '../paint.js'; import type { JsonValue, RasterResourceSource, RegisteredRaster } from '../raster.js'; import { defineRasterResourceId, defineRasterTechnique, - type GlyphRange, - type RasterGlyphInput, - type RasterGlyphWriteInput, type RasterResourceId, type RasterTechnique, type RasterTechniqueId, @@ -64,48 +60,19 @@ export interface SlugPageData { readonly referenceBytes: Uint8Array; } -export interface SlugBinding { - readonly page: number; - readonly curveWidth: number; - readonly curveHeight: number; - readonly headerWidth: number; - readonly headerHeight: number; - readonly referenceWidth: number; - readonly referenceHeight: number; -} - export interface SlugData { readonly planeUnitsPerEm: number; readonly records: Uint8Array; readonly pages: readonly SlugPageData[]; - readonly bindings: readonly SlugBinding[]; } -export interface SlugGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly emOrigins: Float32Array; - readonly emSizes: Float32Array; - readonly inverseScales: Float32Array; - readonly bandTransforms: Float32Array; - readonly colors: Float32Array; - readonly curveBases: Uint32Array; - readonly horizontalHeaderBases: Uint32Array; - readonly verticalHeaderBases: Uint32Array; - readonly referenceBases: Uint32Array; - readonly horizontalBandCounts: Uint32Array; - readonly verticalBandCounts: Uint32Array; -} - -/** Renderer-neutral Slug decoding, page selection, and canonical analytic instance packing. */ +/** Renderer-neutral Slug identity, decoding, and ownership. */ export const slug: RasterTechnique< RasterTechniqueId & 'pmndrs.slug', typeof SLUG_KIND, undefined, SlugDescriptorV0, - SlugData, - SlugBinding, - SlugGlyphBatchStorage + SlugData > = defineRasterTechnique({ id: 'pmndrs.slug', kind: SLUG_KIND, @@ -121,41 +88,6 @@ export const slug: RasterTechnique< signal?.throwIfAborted(); return data; }, - select(input: RasterGlyphInput) { - assertGlyphId(input.data, input.glyphId); - const pageIndex = recordView(input.data).getUint16(input.glyphId * SLUG_GLYPH_RECORD_STRIDE + 8, true); - if (pageIndex === ABSENT_PAGE) return undefined; - const page = input.data.pages[pageIndex]; - const binding = input.data.bindings[pageIndex]; - if (page === undefined || binding === undefined) throw new TypeError('Slug glyph references a missing page'); - return { resource: page.resource, pipelineVariant: 0, binding }; - }, - createStorage(capacity: number): SlugGlyphBatchStorage { - assertCapacity(capacity); - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - emOrigins: new Float32Array(capacity * 2), - emSizes: new Float32Array(capacity * 2), - inverseScales: new Float32Array(capacity), - bandTransforms: new Float32Array(capacity * 4), - colors: new Float32Array(capacity * 4), - curveBases: new Uint32Array(capacity), - horizontalHeaderBases: new Uint32Array(capacity), - verticalHeaderBases: new Uint32Array(capacity), - referenceBases: new Uint32Array(capacity), - horizontalBandCounts: new Uint32Array(capacity), - verticalBandCounts: new Uint32Array(capacity), - }; - }, - writeStorage( - storage: SlugGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - writeSlugStorage(storage, range, input); - }, - validatePaint: assertSlugPaint, dispose() {}, }); @@ -199,18 +131,7 @@ async function decodeSlugData(font: RegisteredFont, raster: RegisteredRaster, si ); } validateSlugRecordTable(records, pages, font.glyphCount); - const bindings = pages.map((page, index) => - Object.freeze({ - page: index, - curveWidth: page.curveWidth, - curveHeight: page.curveHeight, - headerWidth: page.headerWidth, - headerHeight: page.headerHeight, - referenceWidth: page.referenceWidth, - referenceHeight: page.referenceHeight, - }), - ); - return { planeUnitsPerEm: SLUG_PLANE_UNITS_PER_EM, records, pages, bindings }; + return { planeUnitsPerEm: SLUG_PLANE_UNITS_PER_EM, records, pages }; } async function decodeSlugPage( @@ -293,64 +214,6 @@ async function decodeSlugPage( }; } -function writeSlugStorage( - storage: SlugGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, -): void { - assertWriteRange(storage, range, input.glyphs.length); - if (input.data.bindings[input.binding.page] !== input.binding) { - throw new TypeError('Slug write binding does not belong to its data'); - } - const records = recordView(input.data); - for (let index = 0; index < input.glyphs.length; index += 1) { - const glyph = input.glyphs[index]!; - assertGlyphId(input.data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('Slug glyph font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('Slug glyph origins must be finite values'); - } - assertResolvedPaint(glyph.paint); - const record = glyph.glyphId * SLUG_GLYPH_RECORD_STRIDE; - const pageIndex = records.getUint16(record + 8, true); - if (pageIndex !== input.binding.page) throw new TypeError('Slug glyph does not belong to the selected page'); - const left = records.getInt16(record, true); - const bottom = records.getInt16(record + 2, true); - const right = records.getInt16(record + 4, true); - const top = records.getInt16(record + 6, true); - const horizontalBands = records.getUint16(record + 10, true); - const verticalBands = records.getUint16(record + 12, true); - const normalizedLeft = left / input.data.planeUnitsPerEm; - const normalizedBottom = bottom / input.data.planeUnitsPerEm; - const normalizedTop = top / input.data.planeUnitsPerEm; - const normalizedWidth = (right - left) / input.data.planeUnitsPerEm; - const normalizedHeight = (top - bottom) / input.data.planeUnitsPerEm; - const scale = glyph.fontSize / input.data.planeUnitsPerEm; - const instance = range.start + index; - setVector2(storage.origins, instance, glyph.originX + left * scale, glyph.originY - top * scale); - setVector2(storage.sizes, instance, (right - left) * scale, (top - bottom) * scale); - setVector2(storage.emOrigins, instance, normalizedLeft, normalizedTop); - setVector2(storage.emSizes, instance, normalizedWidth, normalizedHeight); - storage.inverseScales[instance] = 1 / glyph.fontSize; - const transformOffset = instance * 4; - const bandScaleX = verticalBands / normalizedWidth; - const bandScaleY = horizontalBands / normalizedHeight; - storage.bandTransforms.set( - [bandScaleX, bandScaleY, -normalizedLeft * bandScaleX, -normalizedBottom * bandScaleY], - transformOffset, - ); - storage.colors.set(glyph.paint.color, transformOffset); - storage.curveBases[instance] = records.getUint32(record + 16, true); - storage.horizontalHeaderBases[instance] = records.getUint32(record + 24, true); - storage.verticalHeaderBases[instance] = records.getUint32(record + 28, true); - storage.referenceBases[instance] = records.getUint32(record + 32, true); - storage.horizontalBandCounts[instance] = horizontalBands; - storage.verticalBandCounts[instance] = verticalBands; - } -} - async function rasterResourceBytes( raster: RegisteredRaster, value: JsonValue | undefined, @@ -429,34 +292,6 @@ function validateSlugRecordTable(records: Uint8Array, pages: readonly SlugPageDa } } -function recordView(data: SlugData): DataView { - return new DataView(data.records.buffer, data.records.byteOffset, data.records.byteLength); -} - -function assertGlyphId(data: SlugData, glyphId: number): void { - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= data.records.byteLength / SLUG_GLYPH_RECORD_STRIDE) { - throw new TypeError('Slug glyph is outside the registered font'); - } -} - -function assertSlugPaint(paint: GlyphPaint): void { - for (const entry of paint.palette) assertResolvedPaint(entry); -} - -function assertResolvedPaint(paint: ResolvedPaint): void { - if (paint.outline !== undefined || paint.shadow !== undefined) { - throw new TypeError('Slug V0 supports fill paint only'); - } - if (paint.color.length !== 4 || paint.color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError('Slug fill color must contain four finite linear values in [0, 1]'); - } -} - -function setVector2(target: Float32Array, index: number, x: number, y: number): void { - target[index * 2] = x; - target[index * 2 + 1] = y; -} - function absentRecordIsCanonical(records: Uint8Array, offset: number): boolean { for (let byte = 0; byte < SLUG_GLYPH_RECORD_STRIDE; byte += 1) { if (byte === 8 || byte === 9) continue; @@ -512,23 +347,3 @@ function checkedBytes(left: number, right: number): number { } return total; } - -function assertWriteRange(storage: SlugGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.inverseScales.length; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('Slug storage write range is outside its capacity'); - } -} - -function assertCapacity(capacity: number): void { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('Slug storage capacity must be a non-negative safe integer'); - } -} diff --git a/packages/text/src/text-runtime.ts b/packages/text/src/text-runtime.ts index 45e48ea8..bb524319 100644 --- a/packages/text/src/text-runtime.ts +++ b/packages/text/src/text-runtime.ts @@ -10,7 +10,13 @@ import { } from './loader.js'; import { canonicalJson, deriveRasterKey } from './internal/raster-identity.js'; import { getRegisteredFontData } from './internal/registered-font.js'; -import type { AnyRasterTechnique, RasterDataOf, RasterOptionsOf, RasterTechniqueTypesOf } from './raster-technique.js'; +import type { + AnyRasterTechnique, + RasterDataOf, + RasterOptionsOf, + RasterTechniqueRequest, + RasterTechniqueTypesOf, +} from './raster-technique.js'; import type { RasterKindOf, RasterOptionsArgument, @@ -30,17 +36,9 @@ export type LoadedFontInput = | { readonly baked: string | URL } | { readonly source: string | URL; readonly runtimeBake: RuntimeFontBake }; -type TechniqueRasterRequest = { - readonly technique: Technique; -} & ([RasterOptionsOf] extends [never] - ? { readonly options?: never } - : undefined extends RasterOptionsOf - ? { readonly options?: RasterOptionsOf } - : { readonly options: RasterOptionsOf }); - export interface LoadedFontRequest { readonly input: LoadedFontInput; - readonly raster: TechniqueRasterRequest; + readonly raster: RasterTechniqueRequest; } export interface TextRuntime { diff --git a/packages/text/tests/integration/bitmap-baker.test.mjs b/packages/text/tests/integration/bitmap-baker.test.mjs index 8ddbb2bc..5c3aec67 100644 --- a/packages/text/tests/integration/bitmap-baker.test.mjs +++ b/packages/text/tests/integration/bitmap-baker.test.mjs @@ -2,7 +2,6 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { RasterCoverageError } from '@pmndrs/text'; import { bitmapBakerAbi, bitmapBakerFromCore, @@ -157,10 +156,8 @@ test('bakes bounded coverage with deterministic progress and a validated selecti dispose() {}, }; const data = await bitmap.decode(font, runtimeRaster); - const paint = { color: [1, 1, 1, 1] }; - const selection = (glyphId) => ({ data, glyphId, fontSize: 16, originX: 0, originY: 0, rasterPixelRatio: 1, paint }); - assert.ok(bitmap.select(selection(43))); - assert.throws(() => bitmap.select(selection(45)), RasterCoverageError); + assert.equal(data.coverage[43 >> 3] & (1 << (43 & 7)), 1 << (43 & 7)); + assert.equal(data.coverage[45 >> 3] & (1 << (45 & 7)), 0); bitmap.dispose(data); const mismatchedPolicy = { diff --git a/packages/text/tests/integration/mtsdf-baker.test.mjs b/packages/text/tests/integration/mtsdf-baker.test.mjs index 60e8252c..86aa6d54 100644 --- a/packages/text/tests/integration/mtsdf-baker.test.mjs +++ b/packages/text/tests/integration/mtsdf-baker.test.mjs @@ -2,8 +2,6 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -import { RasterCoverageError } from '@pmndrs/text'; - import { createMsdfBaker, createMsdfBakerFromInstance, @@ -33,17 +31,6 @@ const showcaseShapingHash = '3f8183c0d56b8b225b8a6a7b2fda80966579b46636b96975434 const publishedAbi = JSON.parse(await readFile(abiUrl, 'utf8')); const progressImports = { env: { pmndrs_text_bake_progress() {} } }; -/** Packs one canonical glyph batch through the portable technique, as core does before any renderer sees it. */ -function packedStorage(data, glyphs) { - const storage = msdf.createStorage(glyphs.length); - msdf.writeStorage(storage, { start: 0, count: glyphs.length }, { data, binding: data.binding, glyphs }); - return storage; -} - -function glyphInput(data, glyphId, index, paint) { - return { data, glyphId, fontSize: 64, originX: 12 + index * 80, originY: 24, rasterPixelRatio: 1, paint }; -} - async function setup() { const [wasm, source] = await Promise.all([readFile(wasmUrl), readFile(fontUrl)]); const module = await WebAssembly.compile(wasm); @@ -198,23 +185,7 @@ test('bakes and validates authenticated 32 px/em quality policies', async () => assert.equal(data.pixelRange, 4); const records = views[extension.recordBufferView]; assert.ok(records); - const glyphId = firstPresentGlyph(records); - const outlined = { color: [1, 1, 1, 1], outline: { color: [0, 0, 0, 1], width: 2 } }; - const storage = packedStorage(data, [{ ...glyphInput(data, glyphId, 0, outlined), fontSize: 32 }]); - assert.equal(storage.outlineWidths[0], 0.5); - assert.throws( - () => - packedStorage(data, [ - { - ...glyphInput(data, glyphId, 0, { - color: [1, 1, 1, 1], - outline: { color: [0, 0, 0, 1], width: 2.0001 }, - }), - fontSize: 32, - }, - ]), - /2-atlas-pixel field limit/, - ); + assert.ok(firstPresentGlyph(records) >= 0); } finally { msdf.dispose(data); } @@ -268,9 +239,8 @@ test('bakes bounded coverage with deterministic progress and a validated selecti dispose() {}, }; const data = await msdf.decode(font, runtimeRaster); - const paint = { color: [1, 1, 1, 1] }; - assert.ok(msdf.select(glyphInput(data, 43, 0, paint))); - assert.throws(() => msdf.select(glyphInput(data, 45, 0, paint)), RasterCoverageError); + assert.equal(data.coverage[43 >> 3] & (1 << (43 & 7)), 1 << (43 & 7)); + assert.equal(data.coverage[45 >> 3] & (1 << (45 & 7)), 0); msdf.dispose(data); }); @@ -657,33 +627,12 @@ async function exerciseRuntime(result, rasterArtifact, extension, rasterKey) { assert.ok(decodedPageBytes < paddedBindingBytes, 'decoded pages must not carry the binding padding'); const glyphIds = firstPresentGlyphByPage(records, data.pages.length); - const decorated = { - color: [1, 0.75, 0.5, 1], - outline: { color: [0, 0, 0, 1], width: 2 }, - shadow: { color: [0, 0, 0, 0.5], offset: [3, 4] }, - }; - const glyphs = [...glyphIds].map((glyphId, index) => glyphInput(data, glyphId, index, decorated)); - for (const glyph of glyphs) { - assert.deepEqual(msdf.select(glyph), { resource: data.resource, pipelineVariant: 0, binding: data.binding }); - } - const storage = packedStorage(data, glyphs); - assert.equal(storage.outlineWidths[0], 0.25); - assert.deepEqual([...storage.pageIndices], [...glyphIds.keys()], 'each baked page keeps its own record page index'); - assert.ok(storage.shadowOffsets[0] > 0, 'a positive shadow offset packs a positive horizontal UV displacement'); - assert.ok(storage.shadowOffsets[1] > 0, 'a positive shadow offset packs a positive vertical UV displacement'); - assert.deepEqual([...storage.fillColors.slice(0, 4)], decorated.color); - assert.deepEqual([...storage.shadowColors.slice(0, 4)], decorated.shadow.color); - - // Dropping the decoration is a structural change: the shadow no longer widens the instance quad. - const plain = packedStorage( - data, - [...glyphIds].map((glyphId, index) => glyphInput(data, glyphId, index, { color: [0.25, 0.5, 1, 0.75] })), + const recordView = new DataView(records.buffer, records.byteOffset, records.byteLength); + assert.deepEqual( + [...glyphIds].map((glyphId) => recordView.getUint16(glyphId * 20 + 16, true)), + [...glyphIds.keys()], + 'each baked page keeps its own record page index', ); - assert.equal(plain.outlineWidths[0], 0); - assert.deepEqual([...plain.shadowOffsets.slice(0, 2)], [0, 0]); - assert.ok(plain.sizes[0] < storage.sizes[0], 'removing the shadow shrinks the packed instance width'); - assert.ok(plain.sizes[1] < storage.sizes[1], 'removing the shadow shrinks the packed instance height'); - assert.deepEqual([...plain.fillColors.slice(0, 4)], [0.25, 0.5, 1, 0.75]); msdf.dispose(data); } diff --git a/packages/text/tests/package/bitmap-technique.test.mjs b/packages/text/tests/package/bitmap-technique.test.mjs deleted file mode 100644 index cf74df12..00000000 --- a/packages/text/tests/package/bitmap-technique.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterResourceId } from '@pmndrs/text'; -import { bitmap } from '@pmndrs/text/raster/bitmap'; - -function records() { - const bytes = new Uint8Array(40); - const view = new DataView(bytes.buffer); - view.setUint16(16, 0xffff, true); - view.setInt16(20, -2, true); - view.setInt16(22, -3, true); - view.setInt16(24, 8, true); - view.setInt16(26, 10, true); - view.setUint16(28, 4, true); - view.setUint16(30, 5, true); - view.setUint16(32, 14, true); - view.setUint16(34, 18, true); - view.setUint16(36, 0, true); - return bytes; -} - -function strike(index, ppem) { - const resource = defineRasterResourceId(`test/bitmap/strike/${index}/page/0`); - const binding = Object.freeze({ strike: index, page: 0, ppem, width: 32, height: 32 }); - return { - ppem, - planeUnitsPerEm: 16, - records: records(), - pages: [{ width: 32, height: 32, format: 'r8unorm', bytes: new Uint8Array(32 * 32), resource }], - bindings: [binding], - }; -} - -const data = { strikes: [strike(0, 16), strike(1, 32)] }; -const paint = { color: [1, 0.5, 0.25, 1] }; - -function glyph(glyphId, fontSize = 16) { - return { - data, - glyphId, - fontSize, - originX: 100, - originY: 50, - rasterPixelRatio: 1, - paint, - }; -} - -test('portable Bitmap selection omits absent records and chooses a physical strike page', () => { - assert.equal(bitmap.select(glyph(0)), undefined); - assert.deepEqual(bitmap.select(glyph(1, 31)), { - resource: data.strikes[1].pages[0].resource, - pipelineVariant: 0, - binding: data.strikes[1].bindings[0], - }); -}); - -test('portable Bitmap storage packs positive-down origins and top-left UVs', () => { - const storage = bitmap.createStorage(2); - const binding = data.strikes[0].bindings[0]; - bitmap.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }); - - assert.deepEqual([...storage.origins], [0, 0, 98, 40]); - assert.deepEqual([...storage.sizes], [0, 0, 10, 13]); - assert.deepEqual([...storage.uvOrigins], [0, 0, 4 / 32, 5 / 32]); - assert.deepEqual([...storage.uvSizes], [0, 0, 10 / 32, 13 / 32]); - assert.deepEqual([...storage.colors.slice(4)], paint.color); -}); - -test('portable Bitmap storage rejects a binding from outside its decoded data', () => { - const storage = bitmap.createStorage(1); - assert.throws( - () => - bitmap.writeStorage( - storage, - { start: 0, count: 1 }, - { data, binding: { ...data.strikes[0].bindings[0] }, glyphs: [glyph(1)] }, - ), - /binding does not belong/, - ); -}); diff --git a/packages/text/tests/package/mtsdf-technique.test.mjs b/packages/text/tests/package/mtsdf-technique.test.mjs deleted file mode 100644 index 9419f85f..00000000 --- a/packages/text/tests/package/mtsdf-technique.test.mjs +++ /dev/null @@ -1,82 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterResourceId } from '@pmndrs/text'; -import { msdf } from '@pmndrs/text/raster/msdf'; - -const binding = Object.freeze({ width: 32, height: 32, layers: 1 }); -const records = new Uint8Array(40); -const view = new DataView(records.buffer); -view.setUint16(16, 0xffff, true); -view.setInt16(20, -2, true); -view.setInt16(22, -3, true); -view.setInt16(24, 8, true); -view.setInt16(26, 10, true); -view.setUint16(28, 4, true); -view.setUint16(30, 5, true); -view.setUint16(32, 14, true); -view.setUint16(34, 18, true); -view.setUint16(36, 0, true); - -const data = { - resource: defineRasterResourceId('test/msdf/font/atlas'), - binding, - emSize: 16, - pixelRange: 8, - planeUnitsPerEm: 16, - records, - pages: [{ width: 32, height: 32, format: 'rgba8unorm', bytes: new Uint8Array(32 * 32 * 4) }], -}; - -const paint = { - color: [1, 0.5, 0.25, 1], - outline: { color: [0, 1, 0, 1], width: 1 }, - shadow: { color: [0, 0, 1, 0.5], offset: [2, 3] }, -}; - -function glyph(glyphId) { - return { - data, - glyphId, - fontSize: 16, - originX: 100, - originY: 50, - rasterPixelRatio: 1, - paint, - }; -} - -test('portable MSDF selection omits absent records and retains one atlas binding', () => { - assert.equal(msdf.select(glyph(0)), undefined); - assert.deepEqual(msdf.select(glyph(1)), { - resource: data.resource, - pipelineVariant: 0, - binding, - }); -}); - -test('portable MSDF storage packs positive-down paragraph origins without renderer objects', () => { - const storage = msdf.createStorage(2); - msdf.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }); - - assert.deepEqual([...storage.origins], [0, 0, 98, 40]); - assert.deepEqual([...storage.sizes], [0, 0, 12, 16]); - assert.deepEqual([...storage.uvBounds.slice(4)], [4 / 32, 5 / 32, 14 / 32, 18 / 32]); - assert.deepEqual([...storage.fillColors.slice(4)], paint.color); - assert.deepEqual([...storage.outlineColors.slice(4)], paint.outline.color); - assert.deepEqual([...storage.shadowColors.slice(4)], paint.shadow.color); - assert.equal(storage.outlineWidths[1], 0.125); - assert.equal(storage.pageIndices[1], 0); -}); - -test('portable MSDF storage rejects mismatched bindings and invalid ranges', () => { - const storage = msdf.createStorage(1); - assert.throws( - () => msdf.writeStorage(storage, { start: 0, count: 1 }, { data, binding: { ...binding }, glyphs: [glyph(1)] }), - /binding does not belong/, - ); - assert.throws( - () => msdf.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }), - /outside its capacity/, - ); -}); diff --git a/packages/text/tests/package/raster-lifecycle.test.mjs b/packages/text/tests/package/raster-lifecycle.test.mjs deleted file mode 100644 index ea5588fb..00000000 --- a/packages/text/tests/package/raster-lifecycle.test.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterBatchStage } from '@pmndrs/text'; - -test('raster stages publish once and transfer ownership before abort', () => { - let commits = 0; - let releases = 0; - const batch = { dispose() {} }; - const stage = defineRasterBatchStage( - batch, - () => { - commits += 1; - }, - () => { - releases += 1; - }, - ); - - assert.equal(stage.batch, batch); - stage.commit(); - stage.commit(); - stage.abort(); - assert.equal(commits, 1); - assert.equal(releases, 0); -}); - -test('raster-stage abort is idempotent and prevents later publication', () => { - let commits = 0; - let releases = 0; - const stage = defineRasterBatchStage( - { dispose() {} }, - () => { - commits += 1; - }, - () => { - releases += 1; - }, - ); - - stage.abort(); - stage.abort(); - stage.commit(); - assert.equal(commits, 0); - assert.equal(releases, 1); -}); diff --git a/packages/text/tests/package/raster-technique.test.mjs b/packages/text/tests/package/raster-technique.test.mjs index a810f73d..f1a77a1a 100644 --- a/packages/text/tests/package/raster-technique.test.mjs +++ b/packages/text/tests/package/raster-technique.test.mjs @@ -4,7 +4,6 @@ import test from 'node:test'; import { defineRasterResourceId, defineRasterTechnique } from '@pmndrs/text'; function technique(id) { - const resource = defineRasterResourceId('test/page/0'); return defineRasterTechnique({ id, kind: 'test', @@ -16,13 +15,6 @@ function technique(id) { async decode() { return {}; }, - select() { - return { resource, pipelineVariant: 0, binding: {} }; - }, - createStorage(capacity) { - return { glyphs: new Uint16Array(capacity) }; - }, - writeStorage() {}, dispose() {}, }); } diff --git a/packages/text/tests/package/slug-technique.test.mjs b/packages/text/tests/package/slug-technique.test.mjs deleted file mode 100644 index 958d2ec9..00000000 --- a/packages/text/tests/package/slug-technique.test.mjs +++ /dev/null @@ -1,93 +0,0 @@ -import assert from 'node:assert/strict'; -import test from 'node:test'; - -import { defineRasterResourceId } from '@pmndrs/text'; -import { slug } from '@pmndrs/text/raster/slug'; - -const records = new Uint8Array(80); -const view = new DataView(records.buffer); -view.setUint16(8, 0xffff, true); -view.setInt16(40, -2, true); -view.setInt16(42, -3, true); -view.setInt16(44, 8, true); -view.setInt16(46, 10, true); -view.setUint16(48, 0, true); -view.setUint16(50, 2, true); -view.setUint16(52, 3, true); -view.setUint32(56, 1, true); -view.setUint32(60, 2, true); -view.setUint32(64, 4, true); -view.setUint32(68, 6, true); -view.setUint32(72, 8, true); -view.setUint32(76, 5, true); - -const page = { - resource: defineRasterResourceId('test/slug/font/page/0'), - curveWidth: 8, - curveHeight: 8, - curveBytes: new Uint8Array(8 * 8 * 8), - headerCount: 16, - headerWidth: 4, - headerHeight: 4, - headerBytes: new Uint8Array(4 * 4 * 4), - referenceCount: 16, - referenceWidth: 4, - referenceHeight: 4, - referenceBytes: new Uint8Array(4 * 4 * 2), -}; -const binding = Object.freeze({ - page: 0, - curveWidth: 8, - curveHeight: 8, - headerWidth: 4, - headerHeight: 4, - referenceWidth: 4, - referenceHeight: 4, -}); -const data = { planeUnitsPerEm: 16, records, pages: [page], bindings: [binding] }; -const paint = { color: [1, 0.5, 0.25, 1] }; - -function glyph(glyphId) { - return { - data, - glyphId, - fontSize: 16, - originX: 100, - originY: 50, - rasterPixelRatio: 1, - paint, - }; -} - -test('portable Slug selection omits absent records and retains one analytic page binding', () => { - assert.equal(slug.select(glyph(0)), undefined); - assert.deepEqual(slug.select(glyph(1)), { - resource: page.resource, - pipelineVariant: 0, - binding, - }); -}); - -test('portable Slug storage packs positive-down geometry and exact analytic addresses', () => { - const storage = slug.createStorage(2); - slug.writeStorage(storage, { start: 1, count: 1 }, { data, binding, glyphs: [glyph(1)] }); - - assert.deepEqual([...storage.origins], [0, 0, 98, 40]); - assert.deepEqual([...storage.sizes], [0, 0, 10, 13]); - assert.equal(storage.inverseScales[1], 1 / 16); - assert.deepEqual([...storage.colors.slice(4)], paint.color); - assert.equal(storage.curveBases[1], 1); - assert.equal(storage.horizontalHeaderBases[1], 4); - assert.equal(storage.verticalHeaderBases[1], 6); - assert.equal(storage.referenceBases[1], 8); - assert.equal(storage.horizontalBandCounts[1], 2); - assert.equal(storage.verticalBandCounts[1], 3); -}); - -test('portable Slug storage rejects a binding from outside its decoded data', () => { - const storage = slug.createStorage(1); - assert.throws( - () => slug.writeStorage(storage, { start: 0, count: 1 }, { data, binding: { ...binding }, glyphs: [glyph(1)] }), - /binding does not belong/, - ); -}); diff --git a/packages/text/tests/types/builtin-raster-techniques-api.test.ts b/packages/text/tests/types/builtin-raster-techniques-api.test.ts index 7a8e54c0..3c8464ed 100644 --- a/packages/text/tests/types/builtin-raster-techniques-api.test.ts +++ b/packages/text/tests/types/builtin-raster-techniques-api.test.ts @@ -1,25 +1,13 @@ -import { - bitmap, - type BitmapBinding, - type BitmapData, - type BitmapGlyphBatchStorage, -} from '../../src/raster/bitmap-technique.js'; -import { msdf, type MsdfBinding, type MsdfData, type MsdfGlyphBatchStorage } from '../../src/raster/msdf.js'; -import { slug, type SlugBinding, type SlugData, type SlugGlyphBatchStorage } from '../../src/raster/slug-technique.js'; -import type { GlyphBatchStorageOf, RasterBindingOf, RasterDataOf } from '../../src/index.js'; +import { bitmap, type BitmapData, type BitmapTechniqueOptions } from '../../src/raster/bitmap-technique.js'; +import { msdf, type MsdfData } from '../../src/raster/msdf.js'; +import { slug, type SlugData } from '../../src/raster/slug-technique.js'; +import type { RasterDataOf, RasterOptionsOf } from '../../src/index.js'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; type Expect = Value; type _BitmapData = Expect, BitmapData>>; -type _BitmapBinding = Expect, BitmapBinding>>; -type _BitmapStorage = Expect, BitmapGlyphBatchStorage>>; - +type _BitmapOptions = Expect, BitmapTechniqueOptions>>; type _MsdfData = Expect, MsdfData>>; -type _MsdfBinding = Expect, MsdfBinding>>; -type _MtsdfStorage = Expect, MsdfGlyphBatchStorage>>; - type _SlugData = Expect, SlugData>>; -type _SlugBinding = Expect, SlugBinding>>; -type _SlugStorage = Expect, SlugGlyphBatchStorage>>; diff --git a/packages/text/tests/types/public-api.test.ts b/packages/text/tests/types/public-api.test.ts index 56776447..0e093f74 100644 --- a/packages/text/tests/types/public-api.test.ts +++ b/packages/text/tests/types/public-api.test.ts @@ -1,36 +1,29 @@ import { - defineRaster, - defineRasterBatchStage, - defineRasterBaker, defineFont, + defineRasterBaker, + defineRasterTechnique, FontLoader, FontRegistry, rasterBake, - type AnyRasterModule, + type AnyRasterTechnique, type FontInputOf, - type FontRasterModuleOf, - type GlyphPaint, - type RasterKey, - type RasterBatchOf, - type RasterCoverage, + type FontRasterTechniqueOf, type RasterBakeDescriptorOf, type RasterBakeRequest, + type RasterCoverage, + type RasterDataOf, + type RasterKey, type RasterKindOf, - type RasterObjectDrawBatch, type RasterOptionsOf, - type RasterResourceOf, type RasterResourceSource, - type RasterRuntime, type RasterSource, type RegisteredFont, type RegisteredRaster, type Sha256Hex, } from '../../src/index.js'; -import type { Object3D } from 'three/webgpu'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; - type Expect = Value; const resolverRasterSource: RasterSource = { type: 'external' }; @@ -60,73 +53,25 @@ interface MsdfResource { readonly texture: unknown; } -interface MsdfBatch { - readonly instances: number; - readonly object: Object3D; - dispose(): void; -} - -declare const rasterObject: Object3D; -const objectDrawBatch: RasterObjectDrawBatch = { - object: rasterObject, - setRenderOrderBase() {}, - dispose() {}, -}; -void objectDrawBatch; - -const msdf = defineRaster({ +const msdf = defineRasterTechnique({ + id: 'test.msdf', kind: 'msdf', extension: 'PMNDRS_font_distance_field', version: 0, descriptor() { - return { encoding: 'mtsdf' }; + return { encoding: 'mtsdf' } as const; }, - async decode(_font, _raster): Promise { + async decode(): Promise { return { texture: {} }; }, - async prepare() {}, - stageBatch(previous): import('../../src/index.js').RasterBatchStage { - const batch = previous ?? { instances: 0, object: rasterObject, dispose() {} }; - return defineRasterBatchStage( - batch, - () => undefined, - () => { - if (previous === undefined) batch.dispose(); - }, - ); - }, - dispose(_resource) {}, + dispose() {}, }); type _MsdfKind = Expect, 'msdf'>>; -type _MsdfResource = Expect, MsdfResource>>; -type _MsdfBatch = Expect, MsdfBatch>>; - -const external = defineRaster({ - kind: 'studio.custom-raster', - extension: 'STUDIO_font_custom', - version: 0, - descriptor() { - return {}; - }, - async decode() { - return { custom: true as const }; - }, - async prepare() {}, - stageBatch(previous) { - const batch = previous ?? { draws: 1, dispose() {} }; - return defineRasterBatchStage( - batch, - () => undefined, - () => { - if (previous === undefined) batch.dispose(); - }, - ); - }, - dispose() {}, -}); +type _MsdfResource = Expect, MsdfResource>>; -const configurable = defineRaster({ +const configurable = defineRasterTechnique({ + id: 'studio.configurable-raster', kind: 'studio.configurable-raster', extension: 'STUDIO_font_configurable', version: 0, @@ -136,69 +81,38 @@ const configurable = defineRaster({ async decode() { return { configured: true as const }; }, - async prepare() {}, - stageBatch(previous) { - const batch = previous ?? { draws: 1, object: rasterObject, dispose() {} }; - return defineRasterBatchStage( - batch, - () => undefined, - () => { - if (previous === undefined) batch.dispose(); - }, - ); - }, dispose() {}, }); type _ConfigurableOptions = Expect, { readonly quality: 'low' | 'high' }>>; -const acceptsExternal: AnyRasterModule = external; +const acceptsExternal: AnyRasterTechnique = configurable; void acceptsExternal; declare const font: RegisteredFont; -declare const runtime: RasterRuntime; declare const slugArtifact: RegisteredRaster<'slug'>; -declare const glyphPaint: GlyphPaint; -void glyphPaint; void slugArtifact.extensionData; const slugBytes: Uint8Array = slugArtifact.view(0); void slugBytes; -const loaded = runtime.load(font, { module: msdf }); -type _LoadedKind = Expect['artifact']['kind'], 'msdf'>>; -type _LoadedResource = Expect['resource'], MsdfResource>>; - -const loadedConfigured = runtime.load(font, { - module: configurable, - options: { quality: 'low' }, -}); -type _LoadedConfiguredKind = Expect< - Equal['artifact']['kind'], 'studio.configurable-raster'> ->; - -// @ts-expect-error Runtime loading retains required raster options. -runtime.load(font, { module: configurable }); - // @ts-expect-error An MSDF decoder cannot consume a Slug artifact. msdf.decode(font, slugArtifact); const titleFont = defineFont('/fonts/Inter-Regular.ttf', msdf); type _TitleInput = Expect, '/fonts/Inter-Regular.ttf'>>; -type _TitleRaster = Expect, typeof msdf>>; +type _TitleRaster = Expect, typeof msdf>>; const configuredFont = defineFont('/fonts/Inter-Regular.ttf', { - module: configurable, + technique: configurable, options: { quality: 'high' }, }); void configuredFont; -// @ts-expect-error A configurable raster module requires its options. +// @ts-expect-error A configurable raster technique requires its options. defineFont('/fonts/Inter-Regular.ttf', configurable); - // @ts-expect-error A configured raster request cannot omit its options. -defineFont('/fonts/Inter-Regular.ttf', { module: configurable }); - +defineFont('/fonts/Inter-Regular.ttf', { technique: configurable }); // @ts-expect-error Raster package option literals remain package-owned. -defineFont('/fonts/Inter-Regular.ttf', { module: configurable, options: { quality: 'ultra' } }); +defineFont('/fonts/Inter-Regular.ttf', { technique: configurable, options: { quality: 'ultra' } }); const relocatedFont = defineFont( { @@ -217,16 +131,11 @@ type _RelocatedInput = Expect< > >; -const bakedOnlyFont = defineFont({ baked: '/fonts/Inter.font.glb' }, msdf); -void bakedOnlyFont; - +void defineFont({ baked: '/fonts/Inter.font.glb' }, msdf); declare const sourceUrl: URL; -const urlFont = defineFont(sourceUrl, msdf); -void urlFont; - +void defineFont(sourceUrl, msdf); // @ts-expect-error A font input requires either source or baked bytes. defineFont({}, msdf); - // @ts-expect-error An optional forbidden source cannot be supplied as undefined. defineFont({ baked: '/fonts/Inter.font.glb', source: undefined }, msdf); @@ -242,7 +151,6 @@ void font.loadRaster( ); declare const registeredRaster: RegisteredRaster; void registeredRaster.resource(externalRasterResource); - // @ts-expect-error A kind is not a stable raster selection when options can differ. font.loadRaster({ kind: 'msdf' }); @@ -260,12 +168,7 @@ const msdfBaker = defineRasterBaker({ extension: 'PMNDRS_font_distance_field', version: 0, artifacts: [], - report: { - metadataBytes: 0, - serializedBytes: 0, - gpuBytes: 0, - pages: [], - }, + report: { metadataBytes: 0, serializedBytes: 0, gpuBytes: 0, pages: [] }, }; }, }); @@ -280,43 +183,20 @@ const nestedDescriptorBaker = defineRasterBaker({ descriptor(options: { readonly language: string }) { return { formatVersion: 0, - settings: { - language: options.language, - scripts: ['Latn', 'Hani'], - fallback: null, - }, + settings: { language: options.language, scripts: ['Latn', 'Hani'], fallback: null }, } as const; }, async bake(request) { - type _RequestDescriptor = Expect< - Equal< - typeof request.descriptor, - { - readonly formatVersion: 0; - readonly settings: { - readonly language: string; - readonly scripts: readonly ['Latn', 'Hani']; - readonly fallback: null; - }; - } - > - >; return { rasterKey: request.rasterKey, kind: 'nested-json', extension: 'PMNDRS_font_nested_json', version: 0, artifacts: [], - report: { - metadataBytes: 0, - serializedBytes: 0, - gpuBytes: 0, - pages: [], - }, + report: { metadataBytes: 0, serializedBytes: 0, gpuBytes: 0, pages: [] }, }; }, }); - type _NestedDescriptor = Expect< Equal< RasterBakeDescriptorOf, @@ -333,16 +213,12 @@ type _NestedDescriptor = Expect< // @ts-expect-error Raster descriptors cannot contain undefined. type _UndefinedDescriptor = RasterBakeRequest<{ readonly invalid: undefined }>; - // @ts-expect-error Raster descriptors cannot contain functions. type _FunctionDescriptor = RasterBakeRequest<{ readonly invalid: () => void }>; - // @ts-expect-error Raster descriptors cannot contain bigint values. type _BigIntDescriptor = RasterBakeRequest<{ readonly invalid: bigint }>; - // @ts-expect-error Raster descriptors cannot contain Date objects. type _DateDescriptor = RasterBakeRequest<{ readonly invalid: Date }>; - // @ts-expect-error Raster descriptors cannot contain Map objects. type _MapDescriptor = RasterBakeRequest<{ readonly invalid: Map }>; @@ -364,15 +240,3 @@ const proseCoverage: RasterCoverage = { glyphIds: [0, 43], }; void proseCoverage; - -declare const dynamicStrike: number; -declare const dynamicStrikes: number[]; - -// @ts-expect-error Bitmap strikes must be statically known numeric literals. -bitmap({ strikes: [dynamicStrike] }); - -// @ts-expect-error Bitmap strikes must be a non-empty tuple. -bitmap({ strikes: [] }); - -// @ts-expect-error A broad array cannot describe bake-time bitmap payloads. -bitmap({ strikes: dynamicStrikes }); diff --git a/packages/text/tests/types/raster-technique-api.test.ts b/packages/text/tests/types/raster-technique-api.test.ts index 698a311b..cb2e1dd4 100644 --- a/packages/text/tests/types/raster-technique-api.test.ts +++ b/packages/text/tests/types/raster-technique-api.test.ts @@ -2,19 +2,15 @@ import { defineRasterResourceId, defineRasterTechnique, type AnyRasterTechnique, - type GlyphBatchStorage, - type GlyphBatchStorageOf, - type RasterBindingOf, type RasterDataOf, - type RasterGlyphWriteInput, type RasterOptionsOf, type RasterTechniqueDescriptorOf, type RasterTechniqueId, + type RasterTechniqueRequest, } from '../../src/index.js'; type Equal = (() => Value extends Left ? 1 : 2) extends () => Value extends Right ? 1 : 2 ? true : false; - type Expect = Value; type IsAny = 0 extends 1 & Value ? true : false; @@ -22,16 +18,8 @@ interface TestData { readonly records: Uint16Array; } -interface TestBinding { - readonly page: number; -} - -interface TestStorage { - readonly origins: Float32Array; - readonly glyphs: Uint16Array; -} - const page = defineRasterResourceId('test/page/0'); +void page; const technique = defineRasterTechnique({ id: 'test.msdf', @@ -44,16 +32,6 @@ const technique = defineRasterTechnique({ async decode(): Promise { return { records: new Uint16Array() }; }, - select() { - return { resource: page, pipelineVariant: 0, binding: { page: 0 } as TestBinding }; - }, - createStorage(capacity): TestStorage { - return { - origins: new Float32Array(capacity * 2), - glyphs: new Uint16Array(capacity), - }; - }, - writeStorage() {}, dispose() {}, }); @@ -63,39 +41,29 @@ type _Descriptor = Expect< Equal, { readonly quality: 'small' | 'large' }> >; type _Data = Expect, TestData>>; -type _Binding = Expect, TestBinding>>; -type _Storage = Expect, TestStorage>>; -declare const writeInput: RasterGlyphWriteInput; -const writeOrigin: number = writeInput.glyphs[0]!.originX + writeInput.glyphs[0]!.originY; -const writePage: number = writeInput.binding.page; -void writeOrigin; -void writePage; +const request: RasterTechniqueRequest = { technique, options: { quality: 'small' } }; +void request; +// @ts-expect-error Required technique options cannot be omitted. +const missingOptions: RasterTechniqueRequest = { technique }; +void missingOptions; const erased: AnyRasterTechnique = technique; void erased; type _ErasedDataIsUnknown = Expect, unknown>>; -type _ErasedStorage = Expect, GlyphBatchStorage>>; type _ErasedDataIsNotAny = Expect>, false>>; defineRasterTechnique({ - id: 'test.invalid-storage', + id: 'test.invalid-descriptor', kind: 'test-invalid', extension: 'TEST_invalid', version: 0, + // @ts-expect-error Technique descriptors must remain JSON values. descriptor() { - return {}; + return { invalid: () => undefined }; }, async decode() { return {}; }, - select() { - return { resource: page, pipelineVariant: 0, binding: {} }; - }, - // @ts-expect-error Canonical storage fields must all be ArrayBufferView values. - createStorage() { - return { invalid: 1 }; - }, - writeStorage() {}, dispose() {}, }); From ced3f7b3a553a393fbf729c2e47f42654358834e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 21:40:39 -0400 Subject: [PATCH 110/128] fix(text): preserve exact render policy semantics --- .../check-paragraph-contract-fixtures.mts | 4 +- .../generate-paragraph-conformance-font.mts | 6 +- .../benchmarks/src/benchmark/fixtures.test.ts | 2 - apps/benchmarks/src/benchmark/scenarios.ts | 2 +- .../conformance/paragraph-contracts.ts | 21 ++- .../targets/conformance/rich-text-spans.ts | 14 +- .../benchmark/targets/product/react-text.ts | 53 +++++- .../src/benchmark/uikit-layout-fixture.ts | 11 +- packages/text/rust/shaper/src/abi_contract.rs | 27 +-- packages/text/rust/shaper/src/engine/frame.rs | 12 +- .../text/rust/shaper/src/engine/policy.rs | 10 +- .../rust/shaper/src/engine/policy_gather.rs | 160 ++++++++++++++++-- .../rust/shaper/src/engine/positioning.rs | 98 ++++++++--- .../rust/shaper/src/engine/shaping_state.rs | 123 +++++++++++--- packages/text/rust/shaper/src/engine/state.rs | 10 +- .../rust/shaper/src/engine/style_state.rs | 26 +++ .../support/render-technique-proof.mjs | 37 ++-- .../text/src/generated/text-shaper-abi.ts | 12 +- .../text/src/internal/font-binding-wire.ts | 4 +- .../text/src/internal/render-policy-wire.ts | 41 ++++- packages/text/src/three/text.ts | 30 ++-- .../integration/font-binding-wire.test.mjs | 6 +- .../render-plan-frame-abi.test.mjs | 12 +- 23 files changed, 552 insertions(+), 169 deletions(-) diff --git a/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts b/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts index 884091a8..9b62dd5a 100644 --- a/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts +++ b/apps/benchmarks/scripts/check-paragraph-contract-fixtures.mts @@ -3,8 +3,8 @@ import { readFile } from 'node:fs/promises'; import { FontRegistry } from '@pmndrs/text'; -const arguments_ = process.argv.slice(2); -if (arguments_.length !== 0) throw new Error('usage: check-paragraph-contract-fixtures.mts'); +const args = process.argv.slice(2); +if (args.length !== 0) throw new Error('usage: check-paragraph-contract-fixtures.mts'); const fixtures = new URL('../fixtures/', import.meta.url); const bidiUrl = new URL('contracts/paragraph-bidi-layout-v0.json', fixtures); diff --git a/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts b/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts index cde427d2..6642c342 100644 --- a/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts +++ b/apps/benchmarks/scripts/generate-paragraph-conformance-font.mts @@ -8,9 +8,9 @@ import { bitmapBaker } from '@pmndrs/text/bakers/bitmap'; import { paragraphCjkCoverageText } from '../src/benchmark/paragraph-contract-corpus.ts'; const output = resolve('fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb'); -const arguments_ = process.argv.slice(2); -const check = arguments_.includes('--check'); -if (arguments_.some((argument) => argument !== '--check') || arguments_.length > 1) { +const args = process.argv.slice(2); +const check = args.includes('--check'); +if (args.some((argument) => argument !== '--check') || args.length > 1) { throw new Error('usage: generate-paragraph-conformance-font.mts [--check]'); } const temporaryDirectory = check ? await mkdtemp(join(tmpdir(), 'pmndrs-text-cjk-contract-')) : undefined; diff --git a/apps/benchmarks/src/benchmark/fixtures.test.ts b/apps/benchmarks/src/benchmark/fixtures.test.ts index 85df78a6..f083348b 100644 --- a/apps/benchmarks/src/benchmark/fixtures.test.ts +++ b/apps/benchmarks/src/benchmark/fixtures.test.ts @@ -92,7 +92,6 @@ describe('canonical Inter fixtures', () => { expect(image.byteLength).toBe(metadata.image.bytes); expect(createHash('sha256').update(image).digest('hex')).toBe(metadata.image.sha256); }); - }); describe('advanced-shaping result', () => { @@ -288,5 +287,4 @@ describe('canonical Noto Sans CJK fixtures', () => { ); expect(harfrust.cases).toEqual(harfbuzz.cases); }); - }); diff --git a/apps/benchmarks/src/benchmark/scenarios.ts b/apps/benchmarks/src/benchmark/scenarios.ts index 749b046a..20ba1752 100644 --- a/apps/benchmarks/src/benchmark/scenarios.ts +++ b/apps/benchmarks/src/benchmark/scenarios.ts @@ -359,7 +359,7 @@ function advancedShapingValidation(values: readonly import('./contracts').Benchm * shaper's font selection; and the `.notdef` pin proves the fallback span is what resolved the Devanagari at all. */ const RICH_TEXT_SPAN_EVIDENCE = { - hash: '87c41664', + hash: 'f73e324f', glyphCount: 175, renderedGlyphCount: 149, drawCount: 7, diff --git a/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts b/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts index 3e0412c5..2d30b208 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/paragraph-contracts.ts @@ -1,10 +1,4 @@ -import type { - LoadedFont, - ParagraphContentBox, - ParagraphLayout, - ParagraphLayoutInspection, - ParagraphStyle, -} from '@pmndrs/text'; +import type { LoadedFont, ParagraphContentBox, ParagraphLayoutInspection, ParagraphStyle } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; import { FontLoader, Text, TextGroup, type TextUpdate } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; @@ -54,7 +48,9 @@ interface BidiContract { readonly policies: { readonly text: string; readonly style: ParagraphStyle; - readonly cases: Readonly>; + readonly cases: Readonly< + Record + >; }; readonly uikit: { readonly input: { readonly text: string; readonly style: ParagraphStyle }; @@ -206,7 +202,8 @@ function runContracts(state: Extract, signal: const layouts = texts.map((text, index) => { const layout = text.inspectLayout(); const contract = expected[index]; - if (layout === undefined || contract === undefined) throw new Error('paragraph contract layout was not published'); + if (layout === undefined || contract === undefined) + throw new Error('paragraph contract layout was not published'); assertObject(contract.id, paragraphLayoutContract(layout, contract.full), narrowLayoutGolden(contract.golden)); return layout; }); @@ -367,7 +364,8 @@ function firstDifference(actual: unknown, expected: unknown, path = '$'): string if (Array.isArray(actual) && Array.isArray(expected)) { if (actual.length !== expected.length) return `${path}.length: ${actual.length} !== ${expected.length}`; for (let index = 0; index < actual.length; index += 1) { - if (!exactValue(actual[index], expected[index])) return firstDifference(actual[index], expected[index], `${path}[${index}]`); + if (!exactValue(actual[index], expected[index])) + return firstDifference(actual[index], expected[index], `${path}[${index}]`); } } if (isRecord(actual) && isRecord(expected)) { @@ -388,7 +386,8 @@ function isRecord(value: unknown): value is Readonly> { } function assertArray(label: string, actual: ArrayLike, expected: readonly number[]): void { - if (actual.length !== expected.length) throw new Error(`${label} length differs from its retained paragraph contract`); + if (actual.length !== expected.length) + throw new Error(`${label} length differs from its retained paragraph contract`); for (let index = 0; index < expected.length; index += 1) { if (actual[index] !== expected[index]) { throw new Error( diff --git a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts index 5819cfb5..a20ecd70 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/rich-text-spans.ts @@ -192,16 +192,19 @@ export function createRichTextSpansConformanceTarget(): BenchmarkTarget { const hashes = CASE_IDS.map((caseId) => { const value = required(evidence, caseId); + // Glyph selection and topology remain exact. Positions are public f32 values, so the semantic digest quantizes + // below a visible hundredth of a pixel; paint is explicitly a multiset because resource batching may reorder + // draws without changing the paragraph's resolved colors. return [ caseId, value.glyphIds.join(','), value.clusters.join(','), value.glyphFontSlots.join(','), value.fontSizes.map((size) => size.toFixed(4)).join(','), - value.x.map((origin) => origin.toFixed(4)).join(','), + value.x.map((origin) => origin.toFixed(2)).join(','), value.lineTextEnds.join(','), - value.contentWidth.toFixed(4), - value.colors.join(','), + value.contentWidth.toFixed(2), + [...value.colors].sort().join(','), ].join('|'); }); @@ -298,7 +301,7 @@ function measureCase( } const layout = text.inspectLayout(); if (layout === undefined) throw new Error(`${caseId} has no layout`); - return readEvidence(text, layout); + return readEvidence(group, layout); } finally { text.removeFromParent(); text.dispose(); @@ -320,7 +323,8 @@ function readEvidence(text: THREE.Object3D, layout: ParagraphLayout): CaseEviden text.traverse((child) => { if (!(child instanceof THREE.Mesh) || !(child.geometry instanceof THREE.InstancedBufferGeometry)) return; drawCount += 1; - const attribute = child.geometry.getAttribute('_pmndrsTextColors'); + const attribute = child.geometry.getAttribute('_pmndrsText_5'); + if (attribute === undefined) throw new Error('Bitmap draw is missing command-buffer color lane 5'); const start = (child.userData.pmndrsTextRunStart as number | undefined) ?? 0; const count = child.geometry.instanceCount; renderedGlyphCount += count; diff --git a/apps/benchmarks/src/benchmark/targets/product/react-text.ts b/apps/benchmarks/src/benchmark/targets/product/react-text.ts index c69746ec..1401352c 100644 --- a/apps/benchmarks/src/benchmark/targets/product/react-text.ts +++ b/apps/benchmarks/src/benchmark/targets/product/react-text.ts @@ -266,16 +266,58 @@ function exactContentBox(size: number): ParagraphContentBox { return { width: { mode: 'exact', size } }; } +function matchesFrameFloat(actual: number, oracle: number): boolean { + if (!Number.isFinite(actual) || !Number.isFinite(oracle)) return actual === oracle; + return float32UlpDistance(actual, oracle) <= 1; +} + +const float32Scratch = new Float32Array(1); +const float32Bits = new Uint32Array(float32Scratch.buffer); + +function float32UlpDistance(left: number, right: number): number { + float32Scratch[0] = left; + const leftBits = orderedFloat32Bits(float32Bits[0] ?? 0); + float32Scratch[0] = right; + const rightBits = orderedFloat32Bits(float32Bits[0] ?? 0); + return Math.abs(leftBits - rightBits); +} + +function orderedFloat32Bits(bits: number): number { + return (bits & 0x8000_0000) === 0 ? (bits ^ 0x8000_0000) >>> 0 : ~bits >>> 0; +} + +function hashWithOracleLineOrigins(layout: ParagraphLayout, oracleBaselines: readonly number[]): string | undefined { + if (layout.lineBaselines.length !== oracleBaselines.length) return undefined; + const y = layout.y.slice(); + const lineBaselines = layout.lineBaselines.slice(); + for (let line = 0; line < lineBaselines.length; line += 1) { + const actual = lineBaselines[line]; + const expected = oracleBaselines[line]; + if (actual === undefined || expected === undefined || !matchesFrameFloat(actual, expected)) return undefined; + const delta = expected - actual; + const glyphStart = layout.lineGlyphStarts[line] ?? 0; + const glyphEnd = glyphStart + (layout.lineGlyphCounts[line] ?? 0); + for (let glyph = glyphStart; glyph < glyphEnd; glyph += 1) { + const position = y[glyph]; + if (position === undefined) return undefined; + y[glyph] = Math.fround(position + delta); + } + lineBaselines[line] = Math.fround(expected); + } + return hashParagraphLayout({ ...layout, y, lineBaselines }); +} + function assertOracleLayout(layout: ParagraphLayout, state: 'natural' | 'narrow'): void { const oracle = canonicalParagraphLayout.goldens[state]; const hash = hashParagraphLayout(layout); + const compatibleHash = hashWithOracleLineOrigins(layout, oracle.layout.lineBaselines); const expectedWidth = state === 'narrow' ? NARROW_WIDTH : oracle.measurement.width; if ( - hash !== oracle.layout.hash || + (hash !== oracle.layout.hash && compatibleHash !== oracle.layout.hash) || layout.glyphIds.length !== oracle.layout.glyphCount || - layout.width !== expectedWidth || - layout.contentWidth !== oracle.measurement.contentWidth || - layout.height !== oracle.measurement.height + !matchesFrameFloat(layout.width, expectedWidth) || + !matchesFrameFloat(layout.contentWidth, oracle.measurement.contentWidth) || + !matchesFrameFloat(layout.height, oracle.measurement.height) ) { throw new Error( `React Text ${state} layout differs from the pinned paragraph oracle: ` + @@ -309,7 +351,8 @@ function countUniquePaints(object: BitmapTextObject): number { const paints = new Set(); object.traverse((child) => { if (!(child instanceof THREE.Mesh)) return; - const colors = child.geometry.getAttribute('_pmndrsTextColors'); + // Bitmap policy buffer 5 is the command buffer's packed RGBA instance lane. + const colors = child.geometry.getAttribute('_pmndrsText_5'); if (colors === undefined) return; // One physical batch backs every run of a paragraph, so a draw reads its own window of the shared paint buffer. const start = (child.userData.pmndrsTextRunStart as number | undefined) ?? 0; diff --git a/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts b/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts index 7100c347..8a7939f5 100644 --- a/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts +++ b/apps/benchmarks/src/benchmark/uikit-layout-fixture.ts @@ -1,9 +1,4 @@ -import type { - ParagraphAxisConstraint, - ParagraphContentBox, - ParagraphLayout, - ParagraphMeasurement, -} from '@pmndrs/text'; +import type { ParagraphAxisConstraint, ParagraphContentBox, ParagraphLayout, ParagraphMeasurement } from '@pmndrs/text'; export const YogaMeasureMode = Object.freeze({ Undefined: 0, Exactly: 1, AtMost: 2 }); @@ -107,8 +102,8 @@ export function createUikitLayoutFixture( ); const layout = measuredParagraph.layout({ ...currentPolicy, - width: { mode: 'exact', size: contentWidth }, - height: { mode: 'exact', size: contentHeight }, + width: { mode: 'exact', size: contentWidth }, + height: { mode: 'exact', size: contentHeight }, }); const contentLeft = -outerWidth / 2 + borderLeft + paddingLeft; const contentTop = outerHeight / 2 - borderTop - paddingTop; diff --git a/packages/text/rust/shaper/src/abi_contract.rs b/packages/text/rust/shaper/src/abi_contract.rs index 9a7282ab..bd845b7e 100644 --- a/packages/text/rust/shaper/src/abi_contract.rs +++ b/packages/text/rust/shaper/src/abi_contract.rs @@ -12,18 +12,19 @@ use crate::engine::frame::{ EXCLUSION_WRAP_INLINE_END, EXCLUSION_WRAP_INLINE_START, EXCLUSION_WRAP_LARGEST, ORIENTATION_MIXED, ORIENTATION_SIDEWAYS, ORIENTATION_UPRIGHT, OVERFLOW_CLIP, OVERFLOW_ELLIPSIS, OVERFLOW_VISIBLE, PARAGRAPH_MUTATION_REMOVE, PARAGRAPH_MUTATION_UPSERT, RESULT_FLAG_CHECKPOINT, - SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_START, SEMANTIC_F32_FONT_SIZE, - SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, SEMANTIC_F32_FOREGROUND_GREEN, - SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, SEMANTIC_F32_INLINE_START, - SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, - SEMANTIC_U32_FLOW_THREAD_ID, SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, - SEMANTIC_U32_STABLE_GLYPH_ID, SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_LAYOUT_INSPECTION, - SEMANTIC_VIEW_MASK, SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, SHAPE_RECTANGLE, - STYLE_FIELD_BASELINE_SHIFT, STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, - STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, - STYLE_FIELD_LANGUAGE, STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, - STYLE_FIELD_MATERIAL, STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, - STYLE_FLAG_ROOT, STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, + SEMANTIC_F32_BLOCK_EXTENT, SEMANTIC_F32_BLOCK_ORIGIN, SEMANTIC_F32_BLOCK_START, + SEMANTIC_F32_FONT_SIZE, SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, + SEMANTIC_F32_FOREGROUND_GREEN, SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_EXTENT, + SEMANTIC_F32_INLINE_ORIGIN, SEMANTIC_F32_INLINE_START, SEMANTIC_F32_INVERSE_FONT_SIZE, + SEMANTIC_F32_RASTER_PIXEL_RATIO, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FLOW_THREAD_ID, + SEMANTIC_U32_FOREGROUND_RGBA, SEMANTIC_U32_REGION_ID, SEMANTIC_U32_STABLE_GLYPH_ID, + SEMANTIC_U32_TRANSFORM_INDEX, SEMANTIC_VIEW_LAYOUT_INSPECTION, SEMANTIC_VIEW_MASK, + SEMANTIC_VIEW_MEASUREMENT, SHAPE_POLYGON, SHAPE_RECTANGLE, STYLE_FIELD_BASELINE_SHIFT, + STYLE_FIELD_DECORATION, STYLE_FIELD_DIRECTION, STYLE_FIELD_FEATURES, STYLE_FIELD_FONT_SIZE, + STYLE_FIELD_FONT_STACK, STYLE_FIELD_FOREGROUND, STYLE_FIELD_LANGUAGE, + STYLE_FIELD_LETTER_SPACING, STYLE_FIELD_LINE_HEIGHT, STYLE_FIELD_MASK, STYLE_FIELD_MATERIAL, + STYLE_FIELD_RASTER_PIXEL_RATIO, STYLE_FIELD_WORD_SPACING, STYLE_FLAG_ROOT, + STYLE_MUTATION_REMOVE, STYLE_MUTATION_UPSERT, TEXT_ENCODING_UTF16_LE, TEXT_MUTATION_REPLACE_UTF16, WRAP_CHARACTER, WRAP_NONE, WRAP_WORD, WRITING_HORIZONTAL_TB, WRITING_VERTICAL_LR, WRITING_VERTICAL_RL, }; @@ -2354,6 +2355,8 @@ pub fn json() -> String { "blockExtent": SEMANTIC_F32_BLOCK_EXTENT, "fontSize": SEMANTIC_F32_FONT_SIZE, "rasterPixelRatio": SEMANTIC_F32_RASTER_PIXEL_RATIO, + "inlineOrigin": SEMANTIC_F32_INLINE_ORIGIN, + "blockOrigin": SEMANTIC_F32_BLOCK_ORIGIN, "foregroundRed": SEMANTIC_F32_FOREGROUND_RED, "foregroundGreen": SEMANTIC_F32_FOREGROUND_GREEN, "foregroundBlue": SEMANTIC_F32_FOREGROUND_BLUE, diff --git a/packages/text/rust/shaper/src/engine/frame.rs b/packages/text/rust/shaper/src/engine/frame.rs index 93f9afcf..9962317a 100644 --- a/packages/text/rust/shaper/src/engine/frame.rs +++ b/packages/text/rust/shaper/src/engine/frame.rs @@ -75,11 +75,13 @@ pub(crate) const SEMANTIC_F32_INLINE_EXTENT: u8 = 2; pub(crate) const SEMANTIC_F32_BLOCK_EXTENT: u8 = 3; pub(crate) const SEMANTIC_F32_FONT_SIZE: u8 = 4; pub(crate) const SEMANTIC_F32_RASTER_PIXEL_RATIO: u8 = 5; -pub(crate) const SEMANTIC_F32_FOREGROUND_RED: u8 = 6; -pub(crate) const SEMANTIC_F32_FOREGROUND_GREEN: u8 = 7; -pub(crate) const SEMANTIC_F32_FOREGROUND_BLUE: u8 = 8; -pub(crate) const SEMANTIC_F32_FOREGROUND_ALPHA: u8 = 9; -pub(crate) const SEMANTIC_F32_INVERSE_FONT_SIZE: u8 = 10; +pub(crate) const SEMANTIC_F32_INLINE_ORIGIN: u8 = 6; +pub(crate) const SEMANTIC_F32_BLOCK_ORIGIN: u8 = 7; +pub(crate) const SEMANTIC_F32_FOREGROUND_RED: u8 = 8; +pub(crate) const SEMANTIC_F32_FOREGROUND_GREEN: u8 = 9; +pub(crate) const SEMANTIC_F32_FOREGROUND_BLUE: u8 = 10; +pub(crate) const SEMANTIC_F32_FOREGROUND_ALPHA: u8 = 11; +pub(crate) const SEMANTIC_F32_INVERSE_FONT_SIZE: u8 = 12; pub(crate) const SEMANTIC_U32_FOREGROUND_RGBA: u8 = 0; pub(crate) const SEMANTIC_U32_CLUSTER_ID: u8 = 1; pub(crate) const SEMANTIC_U32_REGION_ID: u8 = 2; diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 619a9537..4b66da1a 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -947,16 +947,16 @@ fn f32_input_dependency(source: InputSource) -> u16 { return 0; } match source.field { - 0..=5 => 1 << source.field, - 6..=9 => 1 << 6, - 10 => 1 << 4, + 0..=7 => 1 << source.field, + 8..=11 => 1 << 8, + 12 => 1 << 4, _ => 0, } } fn u32_input_dependency(source: InputSource) -> u16 { - if source.scope == InputScope::Semantic && source.field < 5 { - 1 << (6 + source.field) + if source.scope == InputScope::Semantic && source.field < 6 { + 1 << (8 + source.field) } else { 0 } diff --git a/packages/text/rust/shaper/src/engine/policy_gather.rs b/packages/text/rust/shaper/src/engine/policy_gather.rs index 0609eed5..2abe732b 100644 --- a/packages/text/rust/shaper/src/engine/policy_gather.rs +++ b/packages/text/rust/shaper/src/engine/policy_gather.rs @@ -5,11 +5,13 @@ use alloc::vec::Vec; use super::{ font_binding::{FontRenderBinding, SelectedGlyphBinding}, frame::{ - SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, SEMANTIC_F32_FOREGROUND_GREEN, - SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_U32_FOREGROUND_RGBA, + SEMANTIC_F32_BLOCK_ORIGIN, SEMANTIC_F32_FOREGROUND_ALPHA, SEMANTIC_F32_FOREGROUND_BLUE, + SEMANTIC_F32_FOREGROUND_GREEN, SEMANTIC_F32_FOREGROUND_RED, SEMANTIC_F32_INLINE_ORIGIN, + SEMANTIC_F32_INVERSE_FONT_SIZE, SEMANTIC_U32_CLUSTER_ID, SEMANTIC_U32_FOREGROUND_RGBA, }, plan_input::{PlanGlyph, PlanInput}, policy::{CapabilitySetId, InputScope, MAX_REGISTERS, ProgramDescriptor, ValidatedPolicy}, + positioning::SemanticGlyph, }; // `fontSize` and `rasterPixelRatio` can select a different baked resource. @@ -17,16 +19,54 @@ use super::{ // the policy dependency mask would omit unchanged fields from an in-place patch. const RESOURCE_SELECTION_CHANGES: u16 = (1 << 4) | (1 << 5); +// Exact Float32 results of the IEC 61966-2-1 sRGB transfer for every byte value. +// Foreground colors stay one compact u32 per glyph while policy output reproduces +// the renderer's prior linear Float32 instance records without a hot-path powf. +const SRGB8_TO_LINEAR_BITS: [u32; 256] = [ + 0x00000000, 0x399f22b4, 0x3a1f22b4, 0x3a6eb40e, 0x3a9f22b4, 0x3ac6eb61, 0x3aeeb40e, 0x3b0b3e5d, + 0x3b1f22b4, 0x3b33070a, 0x3b46eb61, 0x3b5b518e, 0x3b70f18f, 0x3b83e1c6, 0x3b8fe616, 0x3b9c87fd, + 0x3ba9c9b6, 0x3bb7ad6f, 0x3bc6354a, 0x3bd56360, 0x3be539c1, 0x3bf5ba71, 0x3c0373b6, 0x3c0c6153, + 0x3c15a705, 0x3c1f45be, 0x3c293e6b, 0x3c3391f7, 0x3c3e4149, 0x3c494d44, 0x3c54b6c9, 0x3c607eb4, + 0x3c6ca5df, 0x3c792d22, 0x3c830aa9, 0x3c89af9f, 0x3c9085dc, 0x3c978dc6, 0x3c9ec7c2, 0x3ca63433, + 0x3cadd37d, 0x3cb5a602, 0x3cbdac21, 0x3cc5e63a, 0x3cce54ac, 0x3cd6f7d5, 0x3cdfd010, 0x3ce8ddba, + 0x3cf2212d, 0x3cfb9ac3, 0x3d02a56a, 0x3d0798dd, 0x3d0ca7e6, 0x3d11d2af, 0x3d171964, 0x3d1c7c30, + 0x3d21fb3c, 0x3d2796b2, 0x3d2d4ebb, 0x3d332381, 0x3d39152b, 0x3d3f23e4, 0x3d454fd2, 0x3d4b991d, + 0x3d51ffec, 0x3d588468, 0x3d5f26b6, 0x3d65e6fd, 0x3d6cc563, 0x3d73c20e, 0x3d7add24, 0x3d810b65, + 0x3d84b793, 0x3d88732e, 0x3d8c3e48, 0x3d9018f4, 0x3d940344, 0x3d97fd49, 0x3d9c0715, 0x3da020ba, + 0x3da44a4a, 0x3da883d6, 0x3daccd6f, 0x3db12727, 0x3db5910f, 0x3dba0b38, 0x3dbe95b3, 0x3dc33090, + 0x3dc7dbe0, 0x3dcc97b4, 0x3dd1641d, 0x3dd6412b, 0x3ddb2eee, 0x3de02d76, 0x3de53cd4, 0x3dea5d18, + 0x3def8e51, 0x3df4d090, 0x3dfa23e5, 0x3dff885e, 0x3e027f06, 0x3e05427f, 0x3e080ea2, 0x3e0ae377, + 0x3e0dc104, 0x3e10a753, 0x3e13966a, 0x3e168e51, 0x3e198f0f, 0x3e1c98ac, 0x3e1fab30, 0x3e22c6a1, + 0x3e25eb07, 0x3e29186a, 0x3e2c4ed0, 0x3e2f8e42, 0x3e32d6c5, 0x3e362862, 0x3e39831f, 0x3e3ce703, + 0x3e405417, 0x3e43ca60, 0x3e4749e6, 0x3e4ad2af, 0x3e4e64c3, 0x3e520029, 0x3e55a4e7, 0x3e595305, + 0x3e5d0a89, 0x3e60cb7a, 0x3e6495df, 0x3e6869be, 0x3e6c471f, 0x3e702e07, 0x3e741e7e, 0x3e78188b, + 0x3e7c1c33, 0x3e8014bf, 0x3e822039, 0x3e84308b, 0x3e8645b8, 0x3e885fc3, 0x3e8a7eb0, 0x3e8ca281, + 0x3e8ecb3b, 0x3e90f8df, 0x3e932b72, 0x3e9562f6, 0x3e979f6f, 0x3e99e0e0, 0x3e9c274c, 0x3e9e72b6, + 0x3ea0c321, 0x3ea31890, 0x3ea57307, 0x3ea7d288, 0x3eaa3716, 0x3eaca0b6, 0x3eaf0f68, 0x3eb18332, + 0x3eb3fc15, 0x3eb67a14, 0x3eb8fd34, 0x3ebb8576, 0x3ebe12de, 0x3ec0a56e, 0x3ec33d2a, 0x3ec5da14, + 0x3ec87c30, 0x3ecb2380, 0x3ecdd008, 0x3ed081ca, 0x3ed338c9, 0x3ed5f508, 0x3ed8b68a, 0x3edb7d52, + 0x3ede4963, 0x3ee11abf, 0x3ee3f169, 0x3ee6cd65, 0x3ee9aeb5, 0x3eec955b, 0x3eef815c, 0x3ef272b8, + 0x3ef56974, 0x3ef86593, 0x3efb6716, 0x3efe6e00, 0x3f00bd2b, 0x3f02460c, 0x3f03d1a5, 0x3f055ff7, + 0x3f06f104, 0x3f0884cd, 0x3f0a1b54, 0x3f0bb499, 0x3f0d509f, 0x3f0eef65, 0x3f1090ef, 0x3f12353d, + 0x3f13dc50, 0x3f15862a, 0x3f1732cc, 0x3f18e237, 0x3f1a946e, 0x3f1c4970, 0x3f1e0140, 0x3f1fbbde, + 0x3f21794d, 0x3f23398c, 0x3f24fc9f, 0x3f26c285, 0x3f288b41, 0x3f2a56d2, 0x3f2c253c, 0x3f2df67f, + 0x3f2fca9c, 0x3f31a194, 0x3f337b6a, 0x3f35581d, 0x3f3737b0, 0x3f391a24, 0x3f3aff7a, 0x3f3ce7b2, + 0x3f3ed2cf, 0x3f40c0d2, 0x3f42b1bc, 0x3f44a58e, 0x3f469c49, 0x3f4895ef, 0x3f4a9280, 0x3f4c91ff, + 0x3f4e946c, 0x3f5099c9, 0x3f52a216, 0x3f54ad56, 0x3f56bb88, 0x3f58ccaf, 0x3f5ae0cc, 0x3f5cf7df, + 0x3f5f11ea, 0x3f612eef, 0x3f634eee, 0x3f6571e9, 0x3f6797e0, 0x3f69c0d5, 0x3f6becca, 0x3f6e1bbf, + 0x3f704db5, 0x3f7282ae, 0x3f74baab, 0x3f76f5ae, 0x3f7933b6, 0x3f7b74c6, 0x3f7db8de, 0x3f800000, +]; + pub const DEFAULT_GATHER_RECORD_CAPACITY: usize = 32_768; #[derive(Clone, Copy, Debug, PartialEq)] pub struct LayoutGlyph { pub stable_id: u32, pub content_revision: u32, + pub semantic_glyph_index: u32, pub binding_handle: u32, pub font_handle: u32, pub glyph_id: u32, - pub semantic_id: u32, pub material_id: u32, pub clip_id: u32, pub depth_key: u32, @@ -42,6 +82,7 @@ pub struct LayoutGlyph { pub struct LayoutPlanInput<'a> { pub transform_id: u32, pub glyphs: &'a [LayoutGlyph], + pub(crate) semantic_glyphs: &'a [SemanticGlyph], pub semantic_change_masks: &'a [u16], pub semantic_f32: &'a [&'a [f32]], pub semantic_u32: &'a [&'a [u32]], @@ -252,7 +293,7 @@ impl PolicyGatherWorkspace { program } }; - let next = plan_glyph(input, glyph, binding, selected)?; + let next = plan_glyph(input, glyph_index, glyph, binding, selected)?; let Some(previous) = self.glyphs.get(cursor).copied() else { self.retained_cursor = cursor; self.retained_source_cursor = source_cursor - 1; @@ -422,7 +463,7 @@ impl PolicyGatherWorkspace { u32_inputs, )?; self.glyphs - .push(plan_glyph(input, glyph, binding, selected)?); + .push(plan_glyph(input, glyph_index, glyph, binding, selected)?); self.semantic_change_masks.push( input .semantic_change_masks @@ -650,6 +691,13 @@ impl GatheredPlanInput<'_> { fn validate_semantic_shape(input: LayoutPlanInput<'_>) -> Result<(), GatherError> { if (!input.semantic_change_masks.is_empty() && input.semantic_change_masks.len() != input.glyphs.len()) + || (!input.semantic_glyphs.is_empty() + && input.glyphs.iter().any(|glyph| { + usize::try_from(glyph.semantic_glyph_index) + .ok() + .and_then(|index| input.semantic_glyphs.get(index)) + .is_none_or(|semantic| semantic.stable_id != glyph.stable_id) + })) || input.semantic_f32.iter().any(|field| { field.len() != input.glyphs.len() || field.iter().any(|value| !value.is_finite()) }) @@ -665,6 +713,7 @@ fn validate_semantic_shape(input: LayoutPlanInput<'_>) -> Result<(), GatherError fn plan_glyph( input: LayoutPlanInput<'_>, + glyph_index: usize, glyph: LayoutGlyph, binding: &FontRenderBinding, selected: SelectedGlyphBinding, @@ -673,6 +722,16 @@ fn plan_glyph( .resources() .get(usize::try_from(selected.resource).map_err(|_| GatherError::ResourceBindingMissing)?) .ok_or(GatherError::ResourceBindingMissing)?; + let semantic_id = match input + .semantic_u32 + .get(usize::from(SEMANTIC_U32_CLUSTER_ID)) + .and_then(|values| values.get(glyph_index)) + .copied() + { + Some(value) => value, + None if input.semantic_glyphs.is_empty() => glyph.stable_id, + None => return Err(GatherError::SourceFieldMissing), + }; Ok(PlanGlyph { stable_id: glyph.stable_id, content_revision: glyph.content_revision, @@ -682,7 +741,7 @@ fn plan_glyph( resource_generation: resource.generation, resource_kind: resource.kind, resource_reference: resource.reference, - semantic_id: glyph.semantic_id, + semantic_id, transform_id: input.transform_id, material_id: glyph.material_id, clip_id: glyph.clip_id, @@ -746,6 +805,22 @@ fn derived_semantic_f32( input: LayoutPlanInput<'_>, glyph_index: usize, ) -> Result, GatherError> { + if field == SEMANTIC_F32_INLINE_ORIGIN || field == SEMANTIC_F32_BLOCK_ORIGIN { + let semantic_index = input + .glyphs + .get(glyph_index) + .and_then(|glyph| usize::try_from(glyph.semantic_glyph_index).ok()) + .ok_or(GatherError::SourceFieldMissing)?; + let glyph = input + .semantic_glyphs + .get(semantic_index) + .ok_or(GatherError::SourceFieldMissing)?; + return Ok(Some(if field == SEMANTIC_F32_INLINE_ORIGIN { + glyph.inline_origin + } else { + glyph.block_origin + })); + } if field == SEMANTIC_F32_INVERSE_FONT_SIZE { let font_size = input .glyphs @@ -754,11 +829,11 @@ fn derived_semantic_f32( .ok_or(GatherError::SourceFieldMissing)?; return Ok(Some(1.0 / font_size)); } - let shift = match field { - SEMANTIC_F32_FOREGROUND_RED => 24, - SEMANTIC_F32_FOREGROUND_GREEN => 16, - SEMANTIC_F32_FOREGROUND_BLUE => 8, - SEMANTIC_F32_FOREGROUND_ALPHA => 0, + let (shift, srgb) = match field { + SEMANTIC_F32_FOREGROUND_RED => (0, true), + SEMANTIC_F32_FOREGROUND_GREEN => (8, true), + SEMANTIC_F32_FOREGROUND_BLUE => (16, true), + SEMANTIC_F32_FOREGROUND_ALPHA => (24, false), _ => return Ok(None), }; let packed = input @@ -768,7 +843,11 @@ fn derived_semantic_f32( .copied() .ok_or(GatherError::SourceFieldMissing)?; let channel = (packed >> shift) & 0xff; - Ok(Some((f64::from(channel) / 255.0) as f32)) + Ok(Some(if srgb { + f32::from_bits(SRGB8_TO_LINEAR_BITS[channel as usize]) + } else { + (f64::from(channel) / 255.0) as f32 + })) } fn source_u32( @@ -850,26 +929,51 @@ mod tests { #[test] fn derives_policy_color_channels_and_inverse_font_size_without_retained_arrays() { - let glyphs = [layout_glyph(1, 0)]; - let foreground = [0x8040_20ff]; + let mut glyphs = [layout_glyph(1, 0)]; + glyphs[0].semantic_glyph_index = 1; + let semantic_glyphs = [ + SemanticGlyph { + stable_id: 99, + font_handle: 1, + cluster: 0, + glyph_id: 99, + flags: 0, + font_size: 16.0, + inline_origin: 500.0, + block_origin: 500.0, + }, + SemanticGlyph { + stable_id: 1, + font_handle: 1, + cluster: 0, + glyph_id: 1, + flags: 0, + font_size: 16.0, + inline_origin: 12.5, + block_origin: -3.25, + }, + ]; + let foreground = [0xff20_4080]; let input = LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &semantic_glyphs, semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[&foreground], }; + assert_eq!(validate_semantic_shape(input), Ok(())); assert_eq!( derived_semantic_f32(SEMANTIC_F32_FOREGROUND_RED, input, 0), - Ok(Some((128.0_f64 / 255.0) as f32)) + Ok(Some(f32::from_bits(SRGB8_TO_LINEAR_BITS[128]))) ); assert_eq!( derived_semantic_f32(SEMANTIC_F32_FOREGROUND_GREEN, input, 0), - Ok(Some((64.0_f64 / 255.0) as f32)) + Ok(Some(f32::from_bits(SRGB8_TO_LINEAR_BITS[64]))) ); assert_eq!( derived_semantic_f32(SEMANTIC_F32_FOREGROUND_BLUE, input, 0), - Ok(Some((32.0_f64 / 255.0) as f32)) + Ok(Some(f32::from_bits(SRGB8_TO_LINEAR_BITS[32]))) ); assert_eq!( derived_semantic_f32(SEMANTIC_F32_FOREGROUND_ALPHA, input, 0), @@ -879,6 +983,14 @@ mod tests { derived_semantic_f32(SEMANTIC_F32_INVERSE_FONT_SIZE, input, 0), Ok(Some(1.0 / 16.0)) ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_INLINE_ORIGIN, input, 0), + Ok(Some(12.5)) + ); + assert_eq!( + derived_semantic_f32(SEMANTIC_F32_BLOCK_ORIGIN, input, 0), + Ok(Some(-3.25)) + ); } #[test] @@ -898,6 +1010,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &[], semantic_change_masks: &[], semantic_f32: &[&semantic_x], semantic_u32: &[&semantic_kind], @@ -968,6 +1081,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &[], semantic_change_masks: &[], semantic_f32: &[&initial_x], semantic_u32: &[&semantic_kind], @@ -990,6 +1104,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &changed_glyphs, + semantic_glyphs: &[], semantic_change_masks: &[0, 1], semantic_f32: &[&changed_x], semantic_u32: &[&semantic_kind], @@ -1017,6 +1132,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &changed_topology, + semantic_glyphs: &[], semantic_change_masks: &[ 0, super::super::positioning::ALL_SEMANTIC_CHANGES @@ -1037,6 +1153,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &changed_topology, + semantic_glyphs: &[], semantic_change_masks: &[0, crate::engine::positioning::ALL_SEMANTIC_CHANGES], semantic_f32: &[&changed_x], semantic_u32: &[&semantic_kind], @@ -1069,6 +1186,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &first, + semantic_glyphs: &[], semantic_change_masks: &[], semantic_f32: &[&first_x], semantic_u32: &[&first_kind], @@ -1076,6 +1194,7 @@ mod tests { LayoutPlanInput { transform_id: 2, glyphs: &second, + semantic_glyphs: &[], semantic_change_masks: &[], semantic_f32: &[&second_x], semantic_u32: &[&second_kind], @@ -1125,6 +1244,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &[], semantic_change_masks: &[1, 1], semantic_f32: &[&semantic_x], semantic_u32: &[&semantic_kind], @@ -1159,6 +1279,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &[], semantic_change_masks: &[ RESOURCE_SELECTION_CHANGES, RESOURCE_SELECTION_CHANGES, @@ -1190,6 +1311,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &[], semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[], @@ -1206,6 +1328,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &[], semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[], @@ -1222,6 +1345,7 @@ mod tests { LayoutPlanInput { transform_id: 1, glyphs: &glyphs, + semantic_glyphs: &[], semantic_change_masks: &[], semantic_f32: &[], semantic_u32: &[], @@ -1247,10 +1371,10 @@ mod tests { LayoutGlyph { stable_id, content_revision: 1, + semantic_glyph_index: glyph_id, binding_handle: 9, font_handle: 9, glyph_id, - semantic_id: stable_id, material_id: 6, clip_id: 0, depth_key: 0, diff --git a/packages/text/rust/shaper/src/engine/positioning.rs b/packages/text/rust/shaper/src/engine/positioning.rs index f78b40d3..2d39f259 100644 --- a/packages/text/rust/shaper/src/engine/positioning.rs +++ b/packages/text/rust/shaper/src/engine/positioning.rs @@ -16,9 +16,10 @@ use super::{ }; pub(crate) const SEMANTIC_F32_FIELD_COUNT: usize = 6; +pub(crate) const SEMANTIC_F32_CHANGE_FIELD_COUNT: usize = 8; pub(crate) const SEMANTIC_U32_FIELD_COUNT: usize = 6; pub(crate) const ALL_SEMANTIC_CHANGES: u16 = - (1 << (SEMANTIC_F32_FIELD_COUNT + SEMANTIC_U32_FIELD_COUNT)) - 1; + (1 << (SEMANTIC_F32_CHANGE_FIELD_COUNT + SEMANTIC_U32_FIELD_COUNT)) - 1; const BIDI_BN: u8 = 9; const BIDI_B: u8 = 10; @@ -253,12 +254,23 @@ impl PositionedGlyphArena { .push(u32::try_from(self.glyphs.len()).map_err(|_| EngineError::ResultTooLarge)?); self.line_glyph_counts .push(u32::try_from(glyph_count).map_err(|_| EngineError::ResultTooLarge)?); - self.glyphs.extend_from_slice( - previous - .glyphs - .get(glyph_start..glyph_end) - .ok_or(EngineError::InvalidRequest)?, - ); + let next_semantic_start = self.semantic_glyphs.len(); + for previous_glyph in previous + .glyphs + .get(glyph_start..glyph_end) + .ok_or(EngineError::InvalidRequest)? + { + let previous_semantic_index = usize::try_from(previous_glyph.semantic_glyph_index) + .map_err(|_| EngineError::InvalidRequest)?; + let line_semantic_index = previous_semantic_index + .checked_sub(semantic_start) + .filter(|index| *index < semantic_count) + .ok_or(EngineError::InvalidRequest)?; + let mut glyph = *previous_glyph; + glyph.semantic_glyph_index = u32::try_from(next_semantic_start + line_semantic_index) + .map_err(|_| EngineError::ResultTooLarge)?; + self.glyphs.push(glyph); + } for (target, source) in self.semantic_f32.iter_mut().zip(&previous.semantic_f32) { target.extend_from_slice( source @@ -517,6 +529,8 @@ impl PositionedGlyphArena { block_origin: finite_f32(origin_block)?, }); if let Some(extents) = extents_for(font_handle, glyph_id) { + let semantic_glyph_index = u32::try_from(self.semantic_glyphs.len() - 1) + .map_err(|_| EngineError::ResultTooLarge)?; let inline_start = origin_inline + f64::from(extents.x_min) * scale; let block_start = origin_block - f64::from(extents.y_max) * scale; let inline_extent = f64::from(extents.x_max - extents.x_min) * scale; @@ -525,10 +539,10 @@ impl PositionedGlyphArena { LayoutGlyph { stable_id, content_revision: 0, + semantic_glyph_index, binding_handle, font_handle, glyph_id, - semantic_id: clusters.stable_ids[cluster], material_id: style.material_id, clip_id: line.clip_id, depth_key: 0, @@ -562,6 +576,7 @@ impl PositionedGlyphArena { text, clusters, runs, + styles, boundary_shape, metrics_for, extents_for, @@ -580,14 +595,13 @@ impl PositionedGlyphArena { text: &[u16], clusters: &ClusterArena, runs: &[ShapingRun], + styles: &[StyleSegment], arena: &BoundaryShapeArena, metrics_for: impl Fn(u32) -> Option + Copy, extents_for: impl Fn(u32, u32) -> Option + Copy, ) -> Result { - let run = *runs - .get(usize::try_from(boundary.source_run).map_err(|_| EngineError::InvalidRequest)?) + runs.get(usize::try_from(boundary.source_run).map_err(|_| EngineError::InvalidRequest)?) .ok_or(EngineError::InvalidRequest)?; - let style = run.style; let source_cluster = usize::try_from(boundary.cluster_start).map_err(|_| EngineError::InvalidRequest)?; let ellipsis_cluster = usize::try_from(boundary.cluster_end) @@ -605,10 +619,10 @@ impl PositionedGlyphArena { boundary.source_font_handle, None, source_cluster, - style, arena, text, clusters, + styles, metrics_for, extents_for, )?; @@ -622,10 +636,10 @@ impl PositionedGlyphArena { boundary.ellipsis_font_handle, Some(boundary.text_end), ellipsis_cluster, - style, arena, text, clusters, + styles, metrics_for, extents_for, ) @@ -643,10 +657,10 @@ impl PositionedGlyphArena { font_handle: u32, cluster_override: Option, fallback_cluster: usize, - style: super::style_state::ResolvedStyle, arena: &BoundaryShapeArena, text: &[u16], clusters: &ClusterArena, + styles: &[StyleSegment], metrics_for: impl Fn(u32) -> Option + Copy, extents_for: impl Fn(u32, u32) -> Option + Copy, ) -> Result { @@ -654,7 +668,6 @@ impl PositionedGlyphArena { if font_handle == 0 || metrics.units_per_em == 0 { return Err(EngineError::InvalidRequest); } - let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); let start = usize::try_from(glyph_start).map_err(|_| EngineError::InvalidRequest)?; let end = start .checked_add(usize::try_from(glyph_count).map_err(|_| EngineError::InvalidRequest)?) @@ -681,6 +694,18 @@ impl PositionedGlyphArena { .binary_search(&shaped_cluster) .unwrap_or(fallback_cluster) }; + let style_index = usize::try_from( + *clusters + .style_indexes + .get(cluster_index) + .ok_or(EngineError::InvalidRequest)?, + ) + .map_err(|_| EngineError::InvalidRequest)?; + let style = styles + .get(style_index) + .ok_or(EngineError::InvalidRequest)? + .style; + let scale = f64::from(style.font_size) / f64::from(metrics.units_per_em); let semantic_id = *clusters .stable_ids .get(cluster_index) @@ -733,6 +758,8 @@ impl PositionedGlyphArena { block_origin: finite_f32(origin_block)?, }); if let Some(extents) = extents_for(font_handle, glyph_id) { + let semantic_glyph_index = u32::try_from(self.semantic_glyphs.len() - 1) + .map_err(|_| EngineError::ResultTooLarge)?; let inline_start = origin_inline + f64::from(extents.x_min) * scale; let block_start = origin_block - f64::from(extents.y_max) * scale; let inline_extent = f64::from(extents.x_max - extents.x_min) * scale; @@ -741,10 +768,10 @@ impl PositionedGlyphArena { LayoutGlyph { stable_id, content_revision: 0, + semantic_glyph_index, binding_handle, font_handle, glyph_id, - semantic_id, material_id: style.material_id, clip_id: line.clip_id, depth_key: 0, @@ -943,7 +970,6 @@ impl PositionedGlyphArena { || next.font_handle != old.font_handle || next.binding_handle != old.binding_handle || next.glyph_id != old.glyph_id - || next.semantic_id != old.semantic_id || next.material_id != old.material_id || next.clip_id != old.clip_id || next.depth_key != old.depth_key @@ -958,9 +984,18 @@ impl PositionedGlyphArena { mask |= 1 << field; } } + let next_semantic = self.semantic_glyphs[self.glyphs[slot].semantic_glyph_index as usize]; + let previous_semantic = + previous.semantic_glyphs[previous.glyphs[previous_slot].semantic_glyph_index as usize]; + if next_semantic.inline_origin.to_bits() != previous_semantic.inline_origin.to_bits() { + mask |= 1 << 6; + } + if next_semantic.block_origin.to_bits() != previous_semantic.block_origin.to_bits() { + mask |= 1 << 7; + } for field in 0..SEMANTIC_U32_FIELD_COUNT { if self.semantic_u32[field][slot] != previous.semantic_u32[field][previous_slot] { - mask |= 1 << (SEMANTIC_F32_FIELD_COUNT + field); + mask |= 1 << (SEMANTIC_F32_CHANGE_FIELD_COUNT + field); } } mask @@ -1401,6 +1436,13 @@ mod tests { assert_eq!(active.glyphs[0].inline_start, 4.0); assert_eq!(active.glyphs[1].inline_start, 10.0); assert_eq!(active.glyphs[0].block_start, 1.0); + assert_eq!(active.semantic_glyphs[0].inline_origin, 4.0); + assert_eq!(active.semantic_glyphs[1].inline_origin, 10.0); + assert_eq!(active.semantic_glyphs[0].block_origin, 8.0); + assert_ne!( + active.semantic_f32[1][0], + active.semantic_glyphs[0].block_origin + ); assert_eq!(active.semantic_u32[0], [u32::MAX, u32::MAX]); assert_eq!(next_revision, 3); @@ -1451,6 +1493,12 @@ mod tests { let mut reordered = PositionedGlyphArena::default(); reordered.glyphs.extend(active.glyphs.iter().rev().copied()); + reordered + .semantic_glyphs + .extend(active.semantic_glyphs.iter().rev().copied()); + for (index, glyph) in reordered.glyphs.iter_mut().enumerate() { + glyph.semantic_glyph_index = index as u32; + } for field in 0..SEMANTIC_F32_FIELD_COUNT { reordered.semantic_f32[field].extend(active.semantic_f32[field].iter().rev().copied()); } @@ -1471,10 +1519,10 @@ mod tests { let glyph = |stable_id, revision| LayoutGlyph { stable_id, content_revision: revision, + semantic_glyph_index: stable_id - 1, binding_handle: 1, font_handle: 1, glyph_id: stable_id, - semantic_id: stable_id, material_id: 0, clip_id: 0, depth_key: 0, @@ -1490,6 +1538,18 @@ mod tests { glyphs: vec![glyph(1, 10), glyph(2, 20), glyph(3, 30)], ..PositionedGlyphArena::default() }; + arena + .semantic_glyphs + .extend([1, 2, 3].map(|stable_id| SemanticGlyph { + stable_id, + font_handle: 1, + cluster: stable_id, + glyph_id: stable_id as u16, + flags: 0, + font_size: 16.0, + inline_origin: stable_id as f32, + block_origin: 0.0, + })); for field in &mut arena.semantic_f32 { field.extend([1.0, 2.0, 3.0]); } diff --git a/packages/text/rust/shaper/src/engine/shaping_state.rs b/packages/text/rust/shaper/src/engine/shaping_state.rs index 096a8a8b..d61af55f 100644 --- a/packages/text/rust/shaper/src/engine/shaping_state.rs +++ b/packages/text/rust/shaper/src/engine/shaping_state.rs @@ -7,7 +7,7 @@ use crate::{ use super::{ EngineError, - style_state::{ResolvedStyle, StyleSegment}, + style_state::{ResolvedStyle, StyleArena, StyleSegment}, }; #[derive(Clone, Copy, Debug, PartialEq)] @@ -86,6 +86,7 @@ impl ShapingRunArena { &mut self, text: &[u16], styles: &[StyleSegment], + style_storage: &StyleArena, unicode: &UnicodeAnalysis, bidi: &BidiAnalysis, ) -> Result<(), EngineError> { @@ -101,14 +102,17 @@ impl ShapingRunArena { .or_else(|| bidi.paragraph_levels.first()) .copied() .unwrap_or(0); - self.push(ShapingRun { - text_start: segment.text_start, - text_end: segment.text_start, - script: COMMON_SCRIPT, - direction: direction(segment.style, level), - bidi_level: forced_level(segment.style, level), - style: segment.style, - })?; + self.push( + ShapingRun { + text_start: segment.text_start, + text_end: segment.text_start, + script: COMMON_SCRIPT, + direction: direction(segment.style, level), + bidi_level: forced_level(segment.style, level), + style: segment.style, + }, + style_storage, + )?; } return Ok(()); } @@ -141,6 +145,7 @@ impl ShapingRunArena { bidi_level: forced_level(style.style, bidi_run.level), style: style.style, }, + style_storage, )?; } let boundary = style.text_end.min(script.text_end).min(bidi_run.text_end); @@ -181,6 +186,7 @@ impl ShapingRunArena { start: u32, end: u32, template: ShapingRun, + style_storage: &StyleArena, ) -> Result<(), EngineError> { let mut fragment_start = usize::try_from(start).map_err(|_| EngineError::InvalidRequest)?; let mut offset = fragment_start; @@ -190,12 +196,16 @@ impl ShapingRunArena { let hard_break = matches!(unit, 0x0a | 0x0b | 0x0c | 0x0d | 0x85 | 0x2028 | 0x2029); if hard_break { if fragment_start < offset { - self.push(ShapingRun { - text_start: u32::try_from(fragment_start) - .map_err(|_| EngineError::ResultTooLarge)?, - text_end: u32::try_from(offset).map_err(|_| EngineError::ResultTooLarge)?, - ..template - })?; + self.push( + ShapingRun { + text_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + text_end: u32::try_from(offset) + .map_err(|_| EngineError::ResultTooLarge)?, + ..template + }, + style_storage, + )?; } offset += 1; fragment_start = offset; @@ -208,23 +218,26 @@ impl ShapingRunArena { } } if fragment_start < end { - self.push(ShapingRun { - text_start: u32::try_from(fragment_start) - .map_err(|_| EngineError::ResultTooLarge)?, - text_end: u32::try_from(end).map_err(|_| EngineError::ResultTooLarge)?, - ..template - })?; + self.push( + ShapingRun { + text_start: u32::try_from(fragment_start) + .map_err(|_| EngineError::ResultTooLarge)?, + text_end: u32::try_from(end).map_err(|_| EngineError::ResultTooLarge)?, + ..template + }, + style_storage, + )?; } Ok(()) } - fn push(&mut self, run: ShapingRun) -> Result<(), EngineError> { + fn push(&mut self, run: ShapingRun, style_storage: &StyleArena) -> Result<(), EngineError> { if let Some(previous) = self.runs.last_mut() && previous.text_end == run.text_start && previous.script == run.script && previous.direction == run.direction && previous.bidi_level == run.bidi_level - && previous.style == run.style + && style_storage.same_layout_style(previous.style, run.style) { previous.text_end = run.text_end; return Ok(()); @@ -532,8 +545,10 @@ mod tests { }, ]; let mut runs = ShapingRunArena::default(); + let style_storage = StyleArena::default(); runs.reserve(16).unwrap(); - runs.build(&text, &styles, &unicode, &bidi).unwrap(); + runs.build(&text, &styles, &style_storage, &unicode, &bidi) + .unwrap(); assert_eq!( runs.runs() .iter() @@ -543,6 +558,66 @@ mod tests { ); } + #[test] + fn paint_only_style_boundaries_do_not_split_shaping_runs() { + let text: Vec = "a b".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let bidi = analyze(&text, DIRECTION_LTR).unwrap(); + let base = ResolvedStyle::default(); + let mut painted = base; + painted.material_id = 7; + painted.raster_pixel_ratio = 2.0; + painted.foreground_rgba = 0xff00ffff; + painted.decoration_flags = 1; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 1, + style: painted, + }, + StyleSegment { + text_start: 1, + text_end: text.len() as u32, + style: base, + }, + ]; + let mut runs = ShapingRunArena::default(); + runs.build(&text, &styles, &StyleArena::default(), &unicode, &bidi) + .unwrap(); + + assert_eq!(runs.runs().len(), 1); + assert_eq!((runs.runs()[0].text_start, runs.runs()[0].text_end), (0, 3)); + } + + #[test] + fn metric_style_boundaries_still_split_shaping_runs() { + let text: Vec = "ab".encode_utf16().collect(); + let mut unicode = UnicodeAnalysis::default(); + unicode.analyze(&text).unwrap(); + let bidi = analyze(&text, DIRECTION_LTR).unwrap(); + let base = ResolvedStyle::default(); + let mut larger = base; + larger.font_size = 24.0; + let styles = [ + StyleSegment { + text_start: 0, + text_end: 1, + style: base, + }, + StyleSegment { + text_start: 1, + text_end: 2, + style: larger, + }, + ]; + let mut runs = ShapingRunArena::default(); + runs.build(&text, &styles, &StyleArena::default(), &unicode, &bidi) + .unwrap(); + + assert_eq!(runs.runs().len(), 2); + } + #[test] fn copies_and_rebases_one_contiguous_text_range_in_shaping_order() { let source = ShapeArena { diff --git a/packages/text/rust/shaper/src/engine/state.rs b/packages/text/rust/shaper/src/engine/state.rs index 4e40c7e3..fea5fcbb 100644 --- a/packages/text/rust/shaper/src/engine/state.rs +++ b/packages/text/rust/shaper/src/engine/state.rs @@ -1059,6 +1059,7 @@ fn append_session_gather( let input = LayoutPlanInput { transform_id: ordered.id, glyphs: positioned.glyphs(), + semantic_glyphs: positioned.semantic_glyphs(), semantic_change_masks, semantic_f32: &semantic_f32, semantic_u32: &semantic_u32, @@ -1798,6 +1799,11 @@ impl ParagraphState { } else { self.resolved_styles.segments() }; + let style_storage = if self.styles_prepared { + &self.pending_styles + } else { + &self.styles + }; let unicode = if self.unicode_prepared { &self.pending_unicode } else { @@ -1809,7 +1815,7 @@ impl ParagraphState { &self.bidi }; self.pending_shaping_runs - .build(text, styles, unicode, bidi)?; + .build(text, styles, style_storage, unicode, bidi)?; self.shaping_runs_prepared = true; Ok(()) } @@ -2876,7 +2882,7 @@ fn same_shaping_properties(left: ShapingRun, right: ShapingRun) -> bool { left.script == right.script && left.direction == right.direction && left.bidi_level == right.bidi_level - && left.style == right.style + && left.style.same_layout_sources(right.style) } fn containing_run(runs: &[ShapingRun], start: usize, end: usize) -> Option { diff --git a/packages/text/rust/shaper/src/engine/style_state.rs b/packages/text/rust/shaper/src/engine/style_state.rs index 38b89f27..b96fd304 100644 --- a/packages/text/rust/shaper/src/engine/style_state.rs +++ b/packages/text/rust/shaper/src/engine/style_state.rs @@ -143,6 +143,26 @@ impl Default for ResolvedStyle { } } +impl ResolvedStyle { + fn same_layout_scalars(self, other: Self) -> bool { + self.font_stack_handle == other.font_stack_handle + && self.font_size.to_bits() == other.font_size.to_bits() + && self.line_height.to_bits() == other.line_height.to_bits() + && self.has_line_height == other.has_line_height + && self.letter_spacing.to_bits() == other.letter_spacing.to_bits() + && self.word_spacing.to_bits() == other.word_spacing.to_bits() + && self.baseline_shift.to_bits() == other.baseline_shift.to_bits() + && self.direction == other.direction + && self.bidi_override == other.bidi_override + } + + pub(crate) fn same_layout_sources(self, other: Self) -> bool { + self.same_layout_scalars(other) + && self.language_source == other.language_source + && self.features_source == other.features_source + } +} + #[cfg(test)] impl ResolvedStyle { pub(crate) fn test_typography(font_size: f32, letter_spacing: f32, word_spacing: f32) -> Self { @@ -508,6 +528,12 @@ impl StyleArena { .map_or(&[], |source| self.features(*source)) } + pub(crate) fn same_layout_style(&self, left: ResolvedStyle, right: ResolvedStyle) -> bool { + left.same_layout_scalars(right) + && self.resolved_language(left) == self.resolved_language(right) + && self.resolved_features(left) == self.resolved_features(right) + } + fn same_resolved(&self, left: ResolvedStyle, right: ResolvedStyle) -> bool { let same_scalars = ResolvedStyle { language_source: NO_STYLE_SOURCE, diff --git a/packages/text/scripts/support/render-technique-proof.mjs b/packages/text/scripts/support/render-technique-proof.mjs index 16328af9..26744e68 100644 --- a/packages/text/scripts/support/render-technique-proof.mjs +++ b/packages/text/scripts/support/render-technique-proof.mjs @@ -27,19 +27,21 @@ function bitmapProof(abi, raster, allocation) { function mtsdfProof(abi, raster, allocation) { const extension = raster.document.extensions.PMNDRS_font_distance_field; const view = recordView(raster.records); - const fields = denseAtlasFields(view, raster.glyphCount, extension.planeUnitsPerEm, raster.pages); + const binding = { + width: Math.max(...raster.pages.map((page) => page.width)), + height: Math.max(...raster.pages.map((page) => page.height)), + }; + const fields = denseAtlasFields(view, raster.glyphCount, extension.planeUnitsPerEm, raster.pages, binding); fields.push( field(raster.glyphCount, (record) => { const page = view.getUint16(record + 16, true); if (page === ABSENT_PAGE) return 0; - const width = raster.pages[page].width; - return view.getUint16(record + 12, true) / width; + return view.getUint16(record + 12, true) / binding.width; }), field(raster.glyphCount, (record) => { const page = view.getUint16(record + 16, true); if (page === ABSENT_PAGE) return 0; - const height = raster.pages[page].height; - return view.getUint16(record + 14, true) / height; + return view.getUint16(record + 14, true) / binding.height; }), ); return proof(abi, mtsdfProgram(abi), allocation, { @@ -194,8 +196,8 @@ function programContext(abi, bindingScope, bindingF32Count, bindingU32Count, inv const operations = []; const semantic = abi.engine.semanticF32Fields; const inputs = [ - { scope: 'semantic', field: semantic.inlineStart }, - { scope: 'semantic', field: semantic.blockStart }, + { scope: 'semantic', field: semantic.inlineOrigin }, + { scope: 'semantic', field: semantic.blockOrigin }, { scope: 'semantic', field: semantic.fontSize }, { scope: 'semantic', field: semantic.foregroundRed }, { scope: 'semantic', field: semantic.foregroundGreen }, @@ -273,29 +275,32 @@ function uintBuffers(abi, widths, firstId) { return widths.map((vectorWidth, index) => ({ id: firstId + index, scalar: abi.policy.scalarTypes.u32, vectorWidth })); } -function denseAtlasFields(view, glyphCount, units, pages) { +function denseAtlasFields(view, glyphCount, units, pages, binding) { return [ field(glyphCount, (record) => view.getInt16(record, true) / units), field(glyphCount, (record) => view.getInt16(record + 6, true) / units), field(glyphCount, (record) => (view.getInt16(record + 4, true) - view.getInt16(record, true)) / units), field(glyphCount, (record) => (view.getInt16(record + 6, true) - view.getInt16(record + 2, true)) / units), - field(glyphCount, (record) => atlasValue(view, record, pages, 8, 'width')), - field(glyphCount, (record) => atlasValue(view, record, pages, 10, 'height')), - field(glyphCount, (record) => atlasSpan(view, record, pages, 8, 12, 'width')), - field(glyphCount, (record) => atlasSpan(view, record, pages, 10, 14, 'height')), + field(glyphCount, (record) => atlasValue(view, record, pages, binding, 8, 'width')), + field(glyphCount, (record) => atlasValue(view, record, pages, binding, 10, 'height')), + field(glyphCount, (record) => atlasSpan(view, record, pages, binding, 8, 12, 'width')), + field(glyphCount, (record) => atlasSpan(view, record, pages, binding, 10, 14, 'height')), ]; } -function atlasValue(view, record, pages, offset, dimension) { +function atlasValue(view, record, pages, binding, offset, dimension) { const page = view.getUint16(record + 16, true); - return page === ABSENT_PAGE ? 0 : view.getUint16(record + offset, true) / pages[page][dimension]; + return page === ABSENT_PAGE + ? 0 + : view.getUint16(record + offset, true) / (binding?.[dimension] ?? pages[page][dimension]); } -function atlasSpan(view, record, pages, start, end, dimension) { +function atlasSpan(view, record, pages, binding, start, end, dimension) { const page = view.getUint16(record + 16, true); return page === ABSENT_PAGE ? 0 - : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / pages[page][dimension]; + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / + (binding?.[dimension] ?? pages[page][dimension]); } function pageIndices(view, glyphCount, arrayResource = false, stride = 20) { diff --git a/packages/text/src/generated/text-shaper-abi.ts b/packages/text/src/generated/text-shaper-abi.ts index 36696562..fda8662b 100644 --- a/packages/text/src/generated/text-shaper-abi.ts +++ b/packages/text/src/generated/text-shaper-abi.ts @@ -102,15 +102,17 @@ export const textShaperAbi = { }, "semanticF32Fields": { "blockExtent": 3, + "blockOrigin": 7, "blockStart": 1, "fontSize": 4, - "foregroundAlpha": 9, - "foregroundBlue": 8, - "foregroundGreen": 7, - "foregroundRed": 6, + "foregroundAlpha": 11, + "foregroundBlue": 10, + "foregroundGreen": 9, + "foregroundRed": 8, "inlineExtent": 2, + "inlineOrigin": 6, "inlineStart": 0, - "inverseFontSize": 10, + "inverseFontSize": 12, "rasterPixelRatio": 5 }, "semanticKinds": { diff --git a/packages/text/src/internal/font-binding-wire.ts b/packages/text/src/internal/font-binding-wire.ts index 64ad07e0..16e9730e 100644 --- a/packages/text/src/internal/font-binding-wire.ts +++ b/packages/text/src/internal/font-binding-wire.ts @@ -188,14 +188,14 @@ function compileMsdf( const pageAt = (row: number): number => view.getUint16(rowRecord(row) + 16, true); const atlas = (row: number, offset: number, dimension: 'width' | 'height'): number => { const page = pageAt(row); - return page === ABSENT_PAGE ? 0 : view.getUint16(rowRecord(row) + offset, true) / data.pages[page]![dimension]; + return page === ABSENT_PAGE ? 0 : view.getUint16(rowRecord(row) + offset, true) / data.binding[dimension]; }; const span = (row: number, start: number, end: number, dimension: 'width' | 'height'): number => { const page = pageAt(row); const record = rowRecord(row); return page === ABSENT_PAGE ? 0 - : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / data.pages[page]![dimension]; + : (view.getUint16(record + end, true) - view.getUint16(record + start, true)) / data.binding[dimension]; }; return compileFontBinding({ techniqueId, diff --git a/packages/text/src/internal/render-policy-wire.ts b/packages/text/src/internal/render-policy-wire.ts index df9cfa06..229840d9 100644 --- a/packages/text/src/internal/render-policy-wire.ts +++ b/packages/text/src/internal/render-policy-wire.ts @@ -101,6 +101,7 @@ export interface FirstPartyTechniqueWireIds { } export type ThreeTransformMode = 'direct' | 'indexed'; +export type ThreeAllocationMode = 'ordered' | 'stable'; export interface ThreeTechniqueTransformModes { readonly bitmap: ThreeTransformMode; @@ -119,6 +120,7 @@ export function firstPartyThreeRenderPolicyBytes( identities: RenderWireIdentityRegistry = new RenderWireIdentityRegistry(), transformMode: ThreeTransformMode | ThreeTechniqueTransformModes = 'indexed', additionalPrograms: readonly PolicyProgram[] = [], + allocationMode: ThreeAllocationMode = 'ordered', ): Uint8Array { const bitmap = identities.resolve('pmndrs.bitmap'); const msdf = identities.resolve('pmndrs.msdf'); @@ -128,9 +130,9 @@ export function firstPartyThreeRenderPolicyBytes( ? { bitmap: transformMode, msdf: transformMode, slug: transformMode } : transformMode; const programs: PolicyProgram[] = [ - bitmapProgram(bitmap, 1, modes.bitmap), - msdfProgram(msdf, 2, modes.msdf), - slugProgram(slug, 3, modes.slug), + bitmapProgram(bitmap, 1, modes.bitmap, allocationMode), + msdfProgram(msdf, 2, modes.msdf, allocationMode), + slugProgram(slug, 3, modes.slug, allocationMode), ...additionalPrograms, ]; if (new Set(programs.map((program) => program.techniqueId)).size !== programs.length) { @@ -156,7 +158,12 @@ function threeCapabilitySet(): PolicyCapabilitySet { }; } -function bitmapProgram(techniqueId: number, programId: number, transformMode: ThreeTransformMode): PolicyProgram { +function bitmapProgram( + techniqueId: number, + programId: number, + transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, +): PolicyProgram { const context = programContext('strike', 8, 0); const { loadF32, loadU32, binary, storeF32, storeU32 } = context; loadF32(15); @@ -185,10 +192,16 @@ function bitmapProgram(techniqueId: number, programId: number, transformMode: Th ? [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer(), transformIndexBuffer()] : [...floatBuffers([2, 2, 2, 2, 4]), stableGlyphIdBuffer()], transformMode, + allocationMode, ); } -function msdfProgram(techniqueId: number, programId: number, transformMode: ThreeTransformMode): PolicyProgram { +function msdfProgram( + techniqueId: number, + programId: number, + transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, +): PolicyProgram { const context = programContext('glyph', 10, 1); const { operations, loadF32, loadU32, binary, constantF32, storeF32, storeU32 } = context; loadF32(17); @@ -224,10 +237,16 @@ function msdfProgram(techniqueId: number, programId: number, transformMode: Thre ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), ], transformMode, + allocationMode, ); } -function slugProgram(techniqueId: number, programId: number, transformMode: ThreeTransformMode): PolicyProgram { +function slugProgram( + techniqueId: number, + programId: number, + transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, +): PolicyProgram { const context = programContext('glyph', 8, 6, true); const { loadF32, loadU32, binary, constantF32, constantU32, storeF32, storeU32 } = context; loadF32(16); @@ -266,6 +285,7 @@ function slugProgram(techniqueId: number, programId: number, transformMode: Thre ...(transformMode === 'indexed' ? [transformIndexBuffer()] : []), ], transformMode, + allocationMode, ); } @@ -298,8 +318,8 @@ function programContext( const semantic = textShaperAbi.engine.semanticF32Fields; const semanticU32 = textShaperAbi.engine.semanticU32Fields; const inputs: PolicyInput[] = [ - { scope: 'semantic', field: semantic.inlineStart }, - { scope: 'semantic', field: semantic.blockStart }, + { scope: 'semantic', field: semantic.inlineOrigin }, + { scope: 'semantic', field: semantic.blockOrigin }, { scope: 'semantic', field: semantic.fontSize }, { scope: 'semantic', field: semantic.foregroundRed }, { scope: 'semantic', field: semantic.foregroundGreen }, @@ -358,6 +378,7 @@ function createProgram( context: ProgramContext, buffers: readonly PolicyBuffer[], transformMode: ThreeTransformMode, + allocationMode: ThreeAllocationMode, ): PolicyProgram { const batch = textShaperAbi.policy.batchFields; return { @@ -368,6 +389,10 @@ function createProgram( inputs: context.inputs, buffers, operations: context.operations, + allocationStrategy: + allocationMode === 'stable' + ? textShaperAbi.policy.allocationStrategies.stableIndirect + : textShaperAbi.policy.allocationStrategies.orderedDirect, storageKeyMask: batch.technique | batch.program | batch.resource, drawKeyMask: batch.technique | diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index e37f86af..e562c997 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -1057,22 +1057,32 @@ function packedForeground(paint: GlyphPaintInput): number { throw new RangeError('opacity must be in [0, 1]'); } const input = paint.color ?? '#ffffff'; - const rgba = typeof input === 'string' ? parseHexColor(input) : input; - const channel = (value: number): number => Math.round(Math.min(1, Math.max(0, value)) * 255); - return ( - (channel(rgba[0]) | (channel(rgba[1]) << 8) | (channel(rgba[2]) << 16) | (channel(rgba[3] * opacity) << 24)) >>> 0 - ); + const rgba = typeof input === 'string' ? parseHexColorBytes(input) : linearColorBytes(input); + const alpha = Math.round(rgba[3] * opacity); + return (rgba[0] | (rgba[1] << 8) | (rgba[2] << 16) | (alpha << 24)) >>> 0; } -function parseHexColor(value: string): readonly [number, number, number, number] { +function parseHexColorBytes(value: string): readonly [number, number, number, number] { const match = /^#([0-9a-f]{6}|[0-9a-f]{8})$/iu.exec(value); if (match === null) throw new TypeError('colors must be #rrggbb, #rrggbbaa, or linear RGBA'); const hex = match[1]!; - const linear = (at: number): number => { - const srgb = Number.parseInt(hex.slice(at, at + 2), 16) / 255; - return srgb <= 0.04045 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4; + return [ + Number.parseInt(hex.slice(0, 2), 16), + Number.parseInt(hex.slice(2, 4), 16), + Number.parseInt(hex.slice(4, 6), 16), + hex.length === 8 ? Number.parseInt(hex.slice(6), 16) : 255, + ]; +} + +function linearColorBytes(color: readonly number[]): readonly [number, number, number, number] { + if (color.length !== 4 || color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { + throw new TypeError('linear RGBA colors must contain four finite channels in [0, 1]'); + } + const srgbByte = (value: number): number => { + const srgb = value <= 0.003_130_8 ? value * 12.92 : 1.055 * value ** (1 / 2.4) - 0.055; + return Math.round(srgb * 255); }; - return [linear(0), linear(2), linear(4), hex.length === 8 ? Number.parseInt(hex.slice(6), 16) / 255 : 1]; + return [srgbByte(color[0]!), srgbByte(color[1]!), srgbByte(color[2]!), Math.round(color[3]! * 255)]; } function releaseStackLeases(leases: readonly ThreeTextEngineStackLease[]): void { diff --git a/packages/text/tests/integration/font-binding-wire.test.mjs b/packages/text/tests/integration/font-binding-wire.test.mjs index a2727b3e..f53ce004 100644 --- a/packages/text/tests/integration/font-binding-wire.test.mjs +++ b/packages/text/tests/integration/font-binding-wire.test.mjs @@ -79,7 +79,11 @@ async function fixture(name) { const extension = raster.document.extensions.PMNDRS_font_distance_field; const data = { resource: defineRasterResourceId('test.mtsdf'), - binding: {}, + binding: { + width: Math.max(...raster.pages.map((page) => page.width)), + height: Math.max(...raster.pages.map((page) => page.height)), + layers: raster.pages.length, + }, emSize: extension.emSize, pixelRange: extension.pixelRange, planeUnitsPerEm: extension.planeUnitsPerEm, diff --git a/packages/text/tests/integration/render-plan-frame-abi.test.mjs b/packages/text/tests/integration/render-plan-frame-abi.test.mjs index 2ea78c8b..d142aceb 100644 --- a/packages/text/tests/integration/render-plan-frame-abi.test.mjs +++ b/packages/text/tests/integration/render-plan-frame-abi.test.mjs @@ -83,15 +83,17 @@ test('publishes retained frame transactions through aligned A/B Wasm arenas', as assert.equal(abi.engine.defaultSessionTextCapacity, 1024); assert.deepEqual(abi.engine.semanticF32Fields, { blockExtent: 3, + blockOrigin: 7, blockStart: 1, fontSize: 4, - foregroundAlpha: 9, - foregroundBlue: 8, - foregroundGreen: 7, - foregroundRed: 6, + foregroundAlpha: 11, + foregroundBlue: 10, + foregroundGreen: 9, + foregroundRed: 8, inlineExtent: 2, + inlineOrigin: 6, inlineStart: 0, - inverseFontSize: 10, + inverseFontSize: 12, rasterPixelRatio: 5, }); assert.deepEqual(abi.engine.semanticU32Fields, { From 355f9eaefbaefb1f8cf0e98210dca228f7cb28d8 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 21:40:58 -0400 Subject: [PATCH 111/128] feat(text): realize stable indirect plans in three --- .../targets/conformance/tsl-baseline.ts | 17 +- .../rust/shaper/src/engine/stable_plan.rs | 7 +- .../rust/shaper/src/engine/stable_pool.rs | 116 ++++++++++++- packages/text/src/three/engine-plan-target.ts | 158 +++++++++++++----- .../integration/three-engine-runtime.test.mjs | 94 +++++++++++ 5 files changed, 334 insertions(+), 58 deletions(-) diff --git a/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts b/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts index 848e6c13..ea951613 100644 --- a/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts +++ b/apps/benchmarks/src/benchmark/targets/conformance/tsl-baseline.ts @@ -1,6 +1,5 @@ import * as THREE from 'three/webgpu'; -import type { Node } from 'three/webgpu'; -import { float, mul, vec3 } from 'three/tsl'; +import { instanceIndex, storage } from 'three/tsl'; import { compactRgba8Readback } from '../../low-level/raster/rgba-readback'; import type { BenchmarkTarget, TargetRunOutput } from '../../contracts'; @@ -31,7 +30,7 @@ export function createTslBaselineTarget(backend: RendererBackend): BenchmarkTarg return { id: `tsl-${backend}-baseline`, label: backend === 'webgpu' ? 'TSL WebGPU baseline' : 'TSL WebGL baseline', - detail: 'WebGPURenderer · TSL · deterministic readback', + detail: 'WebGPURenderer · nested logical-to-physical storage lookup · deterministic readback', color: backend === 'webgpu' ? 'cyan' : 'amber', capabilities: new Set(['deterministic', 'raster']), status: () => 'ready', @@ -83,14 +82,16 @@ async function createResources(backend: RendererBackend, dpr: number): Promise = float(0.5); - const redChannel: Node<'float'> = mul(half, 2); - const red: Node<'vec3'> = vec3(redChannel, 0, 0); - material.colorNode = red; + const physicalRecord = storage(order, 'uint', order.count).setPBO(true).element(instanceIndex); + material.colorNode = storage(records, 'vec4', records.count).setPBO(true).element(physicalRecord).rgb; const scene = new THREE.Scene(); - scene.add(new THREE.Mesh(geometry, material)); + scene.add(new THREE.InstancedMesh(geometry, material, 1)); return { backend, dpr, diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 7302dc9c..2bd00fd8 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -698,7 +698,12 @@ impl StablePlanCompiler { .ok_or(StablePlanError::InvalidIdentity)? }; let batch = &mut self.batches[batch_index]; - batch.slots.prepare(identities, publication_generation)?; + if !batch + .slots + .prepare_retained(identities, publication_generation)? + { + batch.slots.prepare(identities, publication_generation)?; + } let assignments = batch.slots.assignments()?; self.order_entries.clear(); reserve(&mut self.order_entries, assignments.len())?; diff --git a/packages/text/rust/shaper/src/engine/stable_pool.rs b/packages/text/rust/shaper/src/engine/stable_pool.rs index b20b3a98..4a0b99b1 100644 --- a/packages/text/rust/shaper/src/engine/stable_pool.rs +++ b/packages/text/rust/shaper/src/engine/stable_pool.rs @@ -53,6 +53,8 @@ pub struct StableSlotPool { seen_slots: Vec, seen_epoch: u32, pending_slot_count: u32, + committed_live_count: u32, + pending_live_count: u32, pending_publication_generation: u32, prepared: bool, } @@ -100,12 +102,7 @@ impl StableSlotPool { if publication_generation == 0 || u32::try_from(desired.len()).is_err() { return Err(StablePoolError::InvalidIdentity); } - self.assignments.clear(); - self.retired_slots.clear(); - self.allocated_free_slots.clear(); - self.pending_slot_count = - u32::try_from(self.slots.len()).map_err(|_| StablePoolError::ArithmeticOverflow)?; - self.pending_publication_generation = publication_generation; + self.begin_prepare(desired.len(), publication_generation)?; let result = (|| { self.prepare_identity_map(desired.len())?; self.prepare_seen_slots()?; @@ -169,6 +166,66 @@ impl StableSlotPool { result } + /// Reuses the committed identity index when the desired set contains exactly the same live + /// identities. Reordering and content-revision changes are allowed; insertion, replacement, + /// or deletion falls back to [`Self::prepare`]. + /// + /// This keeps the common retained update to one desired-identity pass. The complete path must + /// rebuild the index because structural updates can leave acknowledgment-gated stale mappings + /// behind after commit. + pub fn prepare_retained( + &mut self, + desired: &[SlotIdentity], + publication_generation: u32, + ) -> Result { + if self.prepared { + return Err(StablePoolError::AlreadyPrepared); + } + if publication_generation == 0 || u32::try_from(desired.len()).is_err() { + return Err(StablePoolError::InvalidIdentity); + } + if desired.len() != self.committed_live_count as usize { + return Ok(false); + } + + self.begin_prepare(desired.len(), publication_generation)?; + self.prepare_seen_slots()?; + reserve(&mut self.assignments, desired.len())?; + + for identity in desired.iter().copied() { + if identity.stable_id == 0 || identity.content_revision == 0 { + self.finish_transaction(); + return Err(StablePoolError::InvalidIdentity); + } + let Some(slot) = self.find_identity(identity.stable_id) else { + self.finish_transaction(); + return Ok(false); + }; + let Some(state) = self.slots.get(slot as usize).copied() else { + self.finish_transaction(); + return Ok(false); + }; + if state.stable_id != identity.stable_id { + self.finish_transaction(); + return Ok(false); + } + if self.slot_seen(slot)? { + self.finish_transaction(); + return Err(StablePoolError::DuplicateIdentity); + } + self.mark_slot_seen(slot)?; + self.assignments.push(SlotAssignment { + stable_id: identity.stable_id, + content_revision: identity.content_revision, + slot, + changed: state.content_revision != identity.content_revision, + }); + } + + self.prepared = true; + Ok(true) + } + pub fn assignments(&self) -> Result<&[SlotAssignment], StablePoolError> { if !self.prepared { return Err(StablePoolError::NotPrepared); @@ -216,6 +273,7 @@ impl StableSlotPool { content_revision: assignment.content_revision, }; } + self.committed_live_count = self.pending_live_count; self.finish_transaction(); Ok(()) } @@ -251,6 +309,22 @@ impl StableSlotPool { self.prepared = false; } + fn begin_prepare( + &mut self, + desired_count: usize, + publication_generation: u32, + ) -> Result<(), StablePoolError> { + self.assignments.clear(); + self.retired_slots.clear(); + self.allocated_free_slots.clear(); + self.pending_slot_count = + u32::try_from(self.slots.len()).map_err(|_| StablePoolError::ArithmeticOverflow)?; + self.pending_live_count = + u32::try_from(desired_count).map_err(|_| StablePoolError::ArithmeticOverflow)?; + self.pending_publication_generation = publication_generation; + Ok(()) + } + fn prepare_identity_map(&mut self, desired_count: usize) -> Result<(), StablePoolError> { let live_count = self .slots @@ -406,6 +480,36 @@ mod tests { assert_eq!(changed_slots(&pool), vec![1]); } + #[test] + fn retained_prepare_reuses_slots_for_revision_changes_and_reordering() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1), (3, 1)]), 1) + .unwrap(); + pool.commit().unwrap(); + + assert!( + pool.prepare_retained(&identities(&[(3, 1), (1, 2), (2, 1)]), 2) + .unwrap() + ); + assert_eq!(slots(&pool), vec![2, 0, 1]); + assert_eq!(changed_slots(&pool), vec![0]); + } + + #[test] + fn retained_prepare_rejects_structural_changes_without_poisoning_complete_prepare() { + let mut pool = StableSlotPool::default(); + pool.prepare(&identities(&[(1, 1), (2, 1)]), 1).unwrap(); + pool.commit().unwrap(); + + assert!( + !pool + .prepare_retained(&identities(&[(1, 1), (3, 1)]), 2) + .unwrap() + ); + pool.prepare(&identities(&[(1, 1), (3, 1)]), 2).unwrap(); + assert_eq!(slots(&pool), vec![0, 2]); + } + #[test] fn abort_returns_reclaimed_allocations_without_committing_identity() { let mut pool = StableSlotPool::default(); diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index bc267036..2a3b1a64 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -50,6 +50,7 @@ interface MaterialRealization { interface OriginSegment { readonly origins: RetainedBuffer; readonly stableIds: RetainedBuffer; + readonly order: RetainedBuffer | undefined; readonly start: number; readonly count: number; } @@ -65,6 +66,10 @@ type TransformRealization = | Readonly<{ kind: 'direct'; transformId: number }> | Readonly<{ kind: 'indexed'; indices: RetainedBuffer }>; +interface RecordAddressing { + readonly order: RetainedBuffer | undefined; +} + export interface ThreeTextEnginePlanOwner { readonly drawRoot: THREE.Object3D; objectForTransform(transformId: number): THREE.Object3D; @@ -424,17 +429,24 @@ export class ThreeTextRenderPlanExecutor { if (resource === undefined) throw new Error('draw references an unknown retained resource'); const materialId = plan.u32(draw + drawLayout.materialId); const transformId = plan.u32(draw + drawLayout.transformId); - const transform = this.#transformRealization(byPolicyId, transformId); - const material = this.#material(resource, byPolicyId, materialId, transform); const recordIndex = plan.u32(primitive + primitiveLayout.recordIndex); const recordCount = plan.u16(primitive + primitiveLayout.recordCount); + const addressing = recordAddressing(plan, draw, primitive, byPolicyId); + const transform = this.#transformRealization(byPolicyId, transformId); + const material = this.#material(resource, byPolicyId, materialId, transform, addressing); const origins = byPolicyId.get(1); const stableIds = byPolicyId.get(FIRST_PARTY_STABLE_GLYPH_BUFFER_ID); if (origins !== undefined && stableIds !== undefined) { if (!(origins.array instanceof Float32Array) || !(stableIds.array instanceof Uint32Array)) { throw new TypeError('glyph-origin augmentation buffers have invalid scalar types'); } - nextOriginSegments.push({ origins, stableIds, start: recordIndex, count: recordCount }); + nextOriginSegments.push({ + origins, + stableIds, + order: addressing.order, + start: recordIndex, + count: recordCount, + }); } const key = drawRealizationKey( plan.u32(draw + drawLayout.programId), @@ -518,14 +530,15 @@ export class ThreeTextRenderPlanExecutor { if (!(segment.origins.array instanceof Float32Array) || !(segment.stableIds.array instanceof Uint32Array)) continue; for (let index = segment.start; index < segment.start + segment.count; index += 1) { - const stableId = segment.stableIds.array[index]; + const recordIndex = physicalRecordIndex(segment.order, index); + const stableId = segment.stableIds.array[recordIndex]; if (stableId === undefined || stableId === 0) throw new Error('origin augmentation references an invalid glyph'); - const offset = index * segment.origins.vectorWidth; + const offset = recordIndex * segment.origins.vectorWidth; if (this.#originRecords.has(stableId)) throw new Error('origin augmentation repeats a stable glyph identity'); this.#originRecords.set(stableId, { buffer: segment.origins, - index, + index: recordIndex, targetX: segment.origins.array[offset]!, targetY: segment.origins.array[offset + 1]!, }); @@ -559,18 +572,21 @@ export class ThreeTextRenderPlanExecutor { const bufferStart = plan.u32(draw + drawLayout.bufferStart); const bufferEnd = bufferStart + plan.u32(draw + drawLayout.bufferCount); let transformBuffer: RetainedBuffer | undefined; + const byPolicyId = new Map(); for (let bufferIndex = bufferStart; bufferIndex < bufferEnd; bufferIndex += 1) { const record = plan.record(buffers, bufferIndex); const candidate = this.#buffer(plan.u32(record + bufferLayout.id), plan.u32(record + bufferLayout.generation)); + byPolicyId.set(candidate.policyBufferId, candidate); if (candidate.policyBufferId === FIRST_PARTY_TRANSFORM_BUFFER_ID) transformBuffer = candidate; } if (transformBuffer === undefined || !(transformBuffer.array instanceof Uint32Array)) { throw new Error('indexed Three draw is missing its u32 transform-index buffer'); } + const addressing = recordAddressing(plan, draw, primitive, byPolicyId); const start = plan.u32(primitive + primitiveLayout.recordIndex); const end = start + plan.u16(primitive + primitiveLayout.recordCount); for (let recordIndex = start; recordIndex < end; recordIndex += 1) { - const transformIndex = transformBuffer.array[recordIndex]; + const transformIndex = transformBuffer.array[physicalRecordIndex(addressing.order, recordIndex)]; if (transformIndex === undefined || transformIndex === 0) { throw new Error('indexed Three draw references an invalid transform slot'); } @@ -598,6 +614,7 @@ export class ThreeTextRenderPlanExecutor { buffers: ReadonlyMap, materialId: number, transform: TransformRealization, + addressing: RecordAddressing, ): THREE.NodeMaterial { const resolved = this.#coordinator.resolveResource(resource.referenceId); if (resolved.technique !== bitmap.id) { @@ -611,14 +628,14 @@ export class ThreeTextRenderPlanExecutor { }); const key = `${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) - .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}`; + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; const cached = this.#materials.get(key); if (cached !== undefined) return cached.material; const texture = this.#bitmapTexture(resource.referenceId, page); const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); - const instance = TSL.instanceIndex.add(runStart); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); const shader = bitmapShader( { origin: TSL.storage(required[0]!.attribute, 'vec2', required[0]!.attribute.count) @@ -645,12 +662,7 @@ export class ThreeTextRenderPlanExecutor { position, createDefaultMaterial: () => bitmapMaterial(shader, position), }); - this.#retainMaterial( - key, - material, - resource, - transform.kind === 'indexed' ? [...required, transform.indices] : required, - ); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing)); return material; } @@ -659,12 +671,15 @@ export class ThreeTextRenderPlanExecutor { buffers: ReadonlyMap, materialId: number, transform: TransformRealization, + addressing: RecordAddressing, ): THREE.NodeMaterial { const resolved = this.#coordinator.resolveResource(resource.referenceId); - if (resolved.technique === bitmap.id) return this.#bitmapMaterial(resource, buffers, materialId, transform); - if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId, transform); - if (resolved.technique === slug.id) return this.#slugMaterial(resource, buffers, materialId, transform); - if ('program' in resolved) return this.#planProgramMaterial(resource, resolved, buffers, materialId, transform); + if (resolved.technique === bitmap.id) + return this.#bitmapMaterial(resource, buffers, materialId, transform, addressing); + if (resolved.technique === msdf.id) return this.#msdfMaterial(resource, buffers, materialId, transform, addressing); + if (resolved.technique === slug.id) return this.#slugMaterial(resource, buffers, materialId, transform, addressing); + if ('program' in resolved) + return this.#planProgramMaterial(resource, resolved, buffers, materialId, transform, addressing); throw new Error('this Three plan target does not recognize the draw technique'); } @@ -674,17 +689,18 @@ export class ThreeTextRenderPlanExecutor { buffers: ReadonlyMap, materialId: number, transform: TransformRealization, + addressing: RecordAddressing, ): THREE.NodeMaterial { const required = [...buffers.values()]; const key = `external:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.policyBufferId}:${buffer.id}:${buffer.generation}`) - .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}`; + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; const cached = this.#materials.get(key); if (cached !== undefined) return cached.material; const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); - const instance = TSL.instanceIndex.add(runStart); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); const publicBuffers = new Map( [...buffers].map(([id, buffer]) => [ id, @@ -708,12 +724,7 @@ export class ThreeTextRenderPlanExecutor { : position, }), ); - this.#retainMaterial( - key, - material, - resource, - transform.kind === 'indexed' ? [...required, transform.indices] : required, - ); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing)); return material; } @@ -722,6 +733,7 @@ export class ThreeTextRenderPlanExecutor { buffers: ReadonlyMap, materialId: number, transform: TransformRealization, + addressing: RecordAddressing, ): THREE.NodeMaterial { const data = msdfData(this.#coordinator.resolveResource(resource.referenceId)); const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { @@ -731,13 +743,13 @@ export class ThreeTextRenderPlanExecutor { }); const key = `msdf:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) - .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}`; + .join(',')}:${transformProgramKey(transform, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; const cached = this.#materials.get(key); if (cached !== undefined) return cached.material; const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); - const instance = TSL.instanceIndex.add(runStart); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); const fields = required.map((buffer) => TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance), ); @@ -772,12 +784,7 @@ export class ThreeTextRenderPlanExecutor { position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#retainMaterial( - key, - material, - resource, - transform.kind === 'indexed' ? [...required, transform.indices] : required, - ); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing)); return material; } @@ -824,6 +831,7 @@ export class ThreeTextRenderPlanExecutor { buffers: ReadonlyMap, materialId: number, transformRealization: TransformRealization, + addressing: RecordAddressing, ): THREE.NodeMaterial { const page = slugPage(this.#coordinator.resolveResource(resource.referenceId)); const required = [1, 2, 3, 4, 5, 6, 7].map((id) => { @@ -833,13 +841,15 @@ export class ThreeTextRenderPlanExecutor { }); const key = `slug:${resource.id}:${resource.generation}:${materialId}:${required .map((buffer) => `${buffer.id}:${buffer.generation}`) - .join(',')}:${transformProgramKey(transformRealization, this.#transformGeneration)}`; + .join( + ',', + )}:${transformProgramKey(transformRealization, this.#transformGeneration)}:${addressingProgramKey(addressing)}`; const cached = this.#materials.get(key); if (cached !== undefined) return cached.material; const runStart = TSL.uniform(0, 'uint').onObjectUpdate( ({ object }) => (object?.userData.pmndrsTextRunStart as number | undefined) ?? 0, ); - const instance = TSL.instanceIndex.add(runStart); + const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); const floatFields = required .slice(0, 5) .map((buffer) => TSL.storage(buffer.attribute, 'vec4', buffer.attribute.count).setPBO(true).element(instance)); @@ -889,12 +899,7 @@ export class ThreeTextRenderPlanExecutor { position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#retainMaterial( - key, - material, - resource, - transformRealization.kind === 'indexed' ? [...required, transformRealization.indices] : required, - ); + this.#retainMaterial(key, material, resource, materialBuffers(required, transformRealization, addressing)); return material; } @@ -1056,6 +1061,73 @@ function drawRealizationKey( return `${programId}:${resource.id}:${resource.generation}:${materialId}:${clipId}:${depthKey}:${transformKey}:${bufferKey}`; } +function recordAddressing( + plan: TextEngineRenderPlanView, + draw: number, + primitive: number, + buffers: ReadonlyMap, +): RecordAddressing { + const drawLayout = textShaperAbi.layouts.engineDraw; + const primitiveLayout = textShaperAbi.layouts.enginePrimitive; + const indirectBufferId = plan.u32(draw + drawLayout.indirectBufferId); + const order = buffers.get(textShaperAbi.engine.internalBufferBindings.order); + if (indirectBufferId === 0) { + if (order !== undefined) throw new Error('ordered-direct draw unexpectedly contains an indirection buffer'); + return { order: undefined }; + } + if ( + order === undefined || + order.id !== indirectBufferId || + !(order.array instanceof Uint32Array) || + order.vectorWidth !== 1 + ) { + throw new Error('stable-indirect draw is missing its scalar u32 order buffer'); + } + const indirectOffset = plan.u32(draw + drawLayout.indirectOffset); + const recordIndex = plan.u32(primitive + primitiveLayout.recordIndex); + if ( + indirectOffset % Uint32Array.BYTES_PER_ELEMENT !== 0 || + indirectOffset / Uint32Array.BYTES_PER_ELEMENT !== recordIndex || + plan.u32(primitive + primitiveLayout.bufferId) !== indirectBufferId + ) { + throw new Error('stable-indirect draw and primitive disagree about logical record addressing'); + } + return { order }; +} + +function physicalInstance(logical: THREE.Node<'uint'>, addressing: RecordAddressing): THREE.Node<'uint'> { + const order = addressing.order; + return order === undefined + ? logical + : TSL.storage(order.attribute, 'uint', order.attribute.count).setPBO(true).element(logical); +} + +function physicalRecordIndex(order: RetainedBuffer | undefined, logical: number): number { + if (order === undefined) return logical; + if (!(order.array instanceof Uint32Array) || order.vectorWidth !== 1) { + throw new TypeError('stable-indirect order buffer must contain scalar u32 records'); + } + const physical = order.array[logical]; + if (physical === undefined) throw new RangeError('stable-indirect logical record exceeds its order buffer'); + return physical; +} + +function addressingProgramKey(addressing: RecordAddressing): string { + const order = addressing.order; + return order === undefined ? 'ordered' : `stable:${order.id}:${order.generation}`; +} + +function materialBuffers( + required: readonly RetainedBuffer[], + transform: TransformRealization, + addressing: RecordAddressing, +): readonly RetainedBuffer[] { + const buffers = new Map(required.map((buffer) => [buffer.id, buffer])); + if (transform.kind === 'indexed') buffers.set(transform.indices.id, transform.indices); + if (addressing.order !== undefined) buffers.set(addressing.order.id, addressing.order); + return [...buffers.values()]; +} + function transformProgramKey(transform: TransformRealization, generation: number): string { return transform.kind === 'direct' ? 'direct' diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index a7996a4b..a3b48f4b 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -775,6 +775,100 @@ test('Three coordinator shares shaping data across technique bindings and refere hybridTarget.dispose(); hybridSession.dispose(); + const stablePolicyHandle = 4; + coordinator.host.registerPolicy( + stablePolicyHandle, + firstPartyThreeRenderPolicyBytes(coordinator.host.wireIdentities, 'indexed', [], 'stable'), + ); + const stableSession = coordinator.createSession({ + requestCapacity: 4_096, + resultCapacity: 1024 * 1024, + textCapacity: 16, + }); + const stableRequest = initialRequest.slice(); + const stableRequestView = new DataView(stableRequest.buffer, stableRequest.byteOffset, stableRequest.byteLength); + stableRequestView.setUint32(requestLayout.sessionId, stableSession.handle, true); + stableRequestView.setUint32(requestLayout.policyHandle, stablePolicyHandle, true); + const stableInitialPublication = stableSession.update(stableRequest); + const stablePlan = plan.bind(stableInitialPublication); + const stableBuffers = stablePlan.table('buffers'); + const bufferLayout = textShaperAbi.layouts.engineBuffer; + const orderBinding = textShaperAbi.engine.internalBufferBindings.order; + const orderRecord = Array.from({ length: stableBuffers.count }, (_, index) => + stablePlan.record(stableBuffers, index), + ).find((record) => stablePlan.u16(record + bufferLayout.policyBufferId) === orderBinding); + assert.ok(orderRecord !== undefined, 'stable policy must publish its logical-order buffer'); + const orderBufferId = stablePlan.u32(orderRecord + bufferLayout.id); + const stableDraws = stablePlan.table('draws'); + assert.ok( + Array.from({ length: stableDraws.count }, (_, index) => + stablePlan.u32(stablePlan.record(stableDraws, index) + drawLayout.indirectBufferId), + ).every((id) => id === orderBufferId), + 'every stable draw must address physical records through the published order buffer', + ); + const stableTarget = new ThreeTextRenderPlanExecutor(coordinator, { + drawRoot, + renderOrderBase: 40, + objectForTransform(transformId) { + const object = paragraphObjects.get(transformId); + if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); + return object; + }, + }); + stableTarget.apply(stableInitialPublication); + const stableOrder = stableTarget.draws[0].geometry.getAttribute(`_pmndrsText_${orderBinding}`); + const stableIds = stableTarget.draws[0].geometry.getAttribute('_pmndrsText_14'); + assert.ok(stableOrder.array instanceof Uint32Array); + assert.ok(stableIds.array instanceof Uint32Array); + const physicalIds = stableIds.array.slice(); + const initialFirstPhysicalSlot = stableOrder.array[stableTarget.draws[0].userData.pmndrsTextRunStart]; + const stablePreviousDraws = [...stableTarget.draws]; + const stableReorderedPublication = stableSession.update( + compileTextEngineFrameUpdate({ + sessionId: stableSession.handle, + policyHandle: stablePolicyHandle, + capabilitySet: 1, + expectedEngineRevision: stableInitialPublication.engineRevision, + consumedPlanRevision: stableInitialPublication.planRevision, + acknowledgedPublicationGeneration: 0, + limits: { + maxParagraphs: 2, + maxClusters: 16, + maxLines: 8, + maxRegions: 2, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + paragraphMutations: [ + { opcode: 'upsert', paragraphId: 1, order: 1 }, + { opcode: 'upsert', paragraphId: 2, order: 0 }, + ], + }), + ); + const stableReorderedPlan = plan.bind(stableReorderedPublication); + const stablePatches = stableReorderedPlan.table('patches'); + const patchLayout = textShaperAbi.layouts.enginePatch; + assert.ok(stablePatches.count > 0); + assert.ok( + Array.from({ length: stablePatches.count }, (_, index) => + stableReorderedPlan.u32(stableReorderedPlan.record(stablePatches, index) + patchLayout.bufferId), + ).every((id) => id === orderBufferId), + 'lifecycle-only reorder must leave stable physical glyph records untouched', + ); + stableTarget.apply(stableReorderedPublication); + assert.deepEqual(stableIds.array, physicalIds, 'Three retains the stable physical glyph table across reorder'); + assert.equal(stableTarget.draws[0], stablePreviousDraws[1]); + assert.equal(stableTarget.draws[1], stablePreviousDraws[0]); + assert.notEqual( + stableOrder.array[stableTarget.draws[0].userData.pmndrsTextRunStart], + initialFirstPhysicalSlot, + 'the reordered logical draw begins at a different retained physical slot', + ); + stableTarget.dispose(); + stableSession.dispose(); + target.dispose(); session.dispose(); first.release(); From 7e46e707ca4d3e3f206a61ef10a0fdd0210055c1 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 21:44:40 -0400 Subject: [PATCH 112/128] docs(text): record render plan cutover evidence --- .../src/benchmark/package-size-budgets.ts | 26 ++-- .../src/generated/package-sizes.json | 140 +++++++++--------- docs/log.md | 41 +++++ docs/packages/benchmarks.md | 4 +- docs/packages/text.md | 75 +++++++--- docs/planning/api-shapes.md | 4 + docs/planning/decision-register.md | 2 + docs/planning/engine-integration-contract.md | 6 +- docs/planning/raster-technique-api.md | 6 +- docs/planning/rust-layout-engine.md | 38 ++++- 10 files changed, 234 insertions(+), 108 deletions(-) diff --git a/apps/benchmarks/src/benchmark/package-size-budgets.ts b/apps/benchmarks/src/benchmark/package-size-budgets.ts index 2c946886..ae2cb4cf 100644 --- a/apps/benchmarks/src/benchmark/package-size-budgets.ts +++ b/apps/benchmarks/src/benchmark/package-size-budgets.ts @@ -23,17 +23,19 @@ export const packageSizeBudgets = { gzipBytes: 3_200, brotliBytes: 2_850, }, + // Complete Rust shaping, layout, policy, and command-plan publication. The aggregate ceilings below add only their + // independently measured JavaScript graph and leave narrow reviewed headroom for cross-architecture tool output. 'text-shaper-wasm': { - rawBytes: 1_125_000, - minifiedBytes: 1_125_000, - gzipBytes: 430_000, - brotliBytes: 340_000, + rawBytes: 1_165_000, + minifiedBytes: 1_165_000, + gzipBytes: 445_000, + brotliBytes: 350_000, }, 'renderer-neutral-core-total': { - rawBytes: 1_230_000, - minifiedBytes: 1_200_000, - gzipBytes: 450_000, - brotliBytes: 360_000, + rawBytes: 1_265_000, + minifiedBytes: 1_235_000, + gzipBytes: 465_000, + brotliBytes: 367_000, }, 'three-runtime-js': { rawBytes: 350_000, @@ -42,10 +44,10 @@ export const packageSizeBudgets = { brotliBytes: 51_000, }, 'three-renderer-total': { - rawBytes: 1_480_000, - minifiedBytes: 1_360_000, - gzipBytes: 490_000, - brotliBytes: 390_000, + rawBytes: 1_515_000, + minifiedBytes: 1_395_000, + gzipBytes: 505_000, + brotliBytes: 400_000, }, 'font-inter-bitmap-16-32': { rawBytes: 3_200_000, diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 36730eac..7740eae4 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -10,55 +10,55 @@ "label": "Renderer-neutral core JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "fb01be01980997372c14749801f751b6e59590210a22593b61eac0b7a9c7e43b", - "rawBytes": 98156, - "minifiedBytes": 70185, - "gzipBytes": 18847, - "brotliBytes": 16527 + "sha256": "e0c1574d31cee981f61c20ff9685cc64523c91dec320cafcc0860189856c5e7a", + "rawBytes": 88398, + "minifiedBytes": 64157, + "gzipBytes": 17846, + "brotliBytes": 15469 }, { "id": "text-shaper-wasm", "label": "Text engine Wasm", "status": "measured", "format": "wasm", - "sha256": "7f31107c429517908fdab679a28f2234818ecac1cfb2353e98ed0e07a893f1d6", - "rawBytes": 1126383, - "minifiedBytes": 1126383, - "gzipBytes": 428274, - "brotliBytes": 337459 + "sha256": "1f8e820b08aeb8e83cd113a2613b8f1aef45f3b2a65303af06e8405f7ece5e7c", + "rawBytes": 1160543, + "minifiedBytes": 1160543, + "gzipBytes": 443055, + "brotliBytes": 348784 }, { "id": "renderer-neutral-core-total", "label": "Renderer-neutral core total (JS + Wasm)", "status": "measured", "format": "aggregate", - "sha256": "37fdecb656bd7faf8f317bd96b8ca6bf4fe2ec31c156d034adce52a57b8874b7", - "rawBytes": 1224539, - "minifiedBytes": 1196568, - "gzipBytes": 447121, - "brotliBytes": 353986 + "sha256": "3498f8e1044af251b1b4856126b26343f2253b202ac855569975e363a2df66fe", + "rawBytes": 1248941, + "minifiedBytes": 1224700, + "gzipBytes": 460901, + "brotliBytes": 364253 }, { "id": "three-runtime-js", "label": "Complete Three adapter JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "d04f4b48331c2c96c8d1e419f56cfda2ed1b2aba41fbbf8443706d65a290f6b3", - "rawBytes": 340067, - "minifiedBytes": 224436, - "gzipBytes": 57590, - "brotliBytes": 48471 + "sha256": "3b88b8b6072b7855730479cf27291fdf36d779bd187ca4a623cc991e419246b3", + "rawBytes": 327026, + "minifiedBytes": 215670, + "gzipBytes": 55867, + "brotliBytes": 47002 }, { "id": "three-renderer-total", "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)", "status": "measured", "format": "aggregate", - "sha256": "4b14680e26d2453d265c84995ecf9c3a019616f5ed06d72427154b6b55c553ef", - "rawBytes": 1466450, - "minifiedBytes": 1350819, - "gzipBytes": 485864, - "brotliBytes": 385930 + "sha256": "d3bc862e2085388da122bd41eb21c93575fb820d2ac13eb9d9ba725d21eea86e", + "rawBytes": 1487569, + "minifiedBytes": 1376213, + "gzipBytes": 498922, + "brotliBytes": 395786 }, { "id": "font-inter-bitmap-16-32", @@ -131,66 +131,66 @@ "label": "Three + engine + Inter Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "6bc20871902e1aa07ba9def761beacb06ff9ee8b3204759894be373ddd0366ce", - "rawBytes": 4616098, - "minifiedBytes": 4500467, - "gzipBytes": 1044172, - "brotliBytes": 806520 + "sha256": "d4c76716d543aa8aaf9a7407955ee874542bae490d847b6d617d87c8b1b564f7", + "rawBytes": 4637217, + "minifiedBytes": 4525861, + "gzipBytes": 1057230, + "brotliBytes": 816376 }, { "id": "delivery-three-inter-mtsdf", "label": "Three + engine + Inter MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "b266591a454b9b69794bf60e2e9c62ef17b0ba4b61ed7f8c76a259646ca30cd7", - "rawBytes": 40814162, - "minifiedBytes": 40698531, - "gzipBytes": 7284276, - "brotliBytes": 3626302 + "sha256": "1fd846dedac5fbe45066dae32611aac869f52358202e89a9159f93714a9d58fa", + "rawBytes": 40835281, + "minifiedBytes": 40723925, + "gzipBytes": 7297334, + "brotliBytes": 3636158 }, { "id": "delivery-three-inter-slug", "label": "Three + engine + Inter Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "e1f7ea599e497bb36fe82d9922f4eff257942afd8b2eee0dc46b43afad5dcb16", - "rawBytes": 4911366, - "minifiedBytes": 4795735, - "gzipBytes": 1104351, - "brotliBytes": 795965 + "sha256": "e38722542434828c543275913899e8e9f169b1c4778de583229326c47463198c", + "rawBytes": 4932485, + "minifiedBytes": 4821129, + "gzipBytes": 1117409, + "brotliBytes": 805821 }, { "id": "delivery-three-icons-bitmap", "label": "Three + engine + Font Awesome Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "d2eaf07d4ed7a489803d6f092004bd7aa6e817099d36629da70954919bdfe3ce", - "rawBytes": 3848182, - "minifiedBytes": 3732551, - "gzipBytes": 935911, - "brotliBytes": 741479 + "sha256": "19b75efa511f46f97002bd3b5e16b13489862808ad51dcebc407faa321935eb1", + "rawBytes": 3869301, + "minifiedBytes": 3757945, + "gzipBytes": 948969, + "brotliBytes": 751335 }, { "id": "delivery-three-icons-mtsdf", "label": "Three + engine + Font Awesome MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "0b655715eaf050247809441da3b7bee444f2c78952661a81046b097b839d54ee", - "rawBytes": 34047350, - "minifiedBytes": 33931719, - "gzipBytes": 7713688, - "brotliBytes": 3710833 + "sha256": "654bdbbde7c4885cb63fe20a0df0dfc264d7b1fe58123e8bd21c076639d5ae50", + "rawBytes": 34068469, + "minifiedBytes": 33957113, + "gzipBytes": 7726746, + "brotliBytes": 3720689 }, { "id": "delivery-three-icons-slug", "label": "Three + engine + Font Awesome Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "b3d7564d5b35e5d9548c9f2eee43ae23213dd59b2615a7c6413569cd5c8ab625", - "rawBytes": 4411862, - "minifiedBytes": 4296231, - "gzipBytes": 1143876, - "brotliBytes": 870928 + "sha256": "17a90f2032fd34f69c48cb60ee8ed7e813bcde3d2c831b7c517782961085a909", + "rawBytes": 4432981, + "minifiedBytes": 4321625, + "gzipBytes": 1156934, + "brotliBytes": 880784 }, { "id": "font-validator-js", @@ -230,33 +230,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "c1d113645cea47473d6f3c6351263ea4547a1bdbbbd23f5154dfe44908bd1fe0", - "rawBytes": 330120, - "minifiedBytes": 217861, - "gzipBytes": 55713, - "brotliBytes": 46918 + "sha256": "752e550807a2ea06badf83009c434354253d25ef867ae818feede223fd592340", + "rawBytes": 317079, + "minifiedBytes": 209006, + "gzipBytes": 53995, + "brotliBytes": 45526 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "9a40a98b32dbff7512311811836c1de50e49404a461a5cbbfeae80d8fc55ca70", - "rawBytes": 330116, - "minifiedBytes": 217932, - "gzipBytes": 55705, - "brotliBytes": 46922 + "sha256": "4b6438182124e2cf4a8046a6185202e20115d974cd2ed5a50d7d9c19b0ee730b", + "rawBytes": 317075, + "minifiedBytes": 209010, + "gzipBytes": 53994, + "brotliBytes": 45525 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "86c04a6e441fef872b724433163352c01a53130d507ab22be61a0303b7d43da8", - "rawBytes": 330118, - "minifiedBytes": 217854, - "gzipBytes": 55684, - "brotliBytes": 46900 + "sha256": "8018a5058326569d544e98d10d2a1d445f8d3d33722311282a0250fc1f00bd23", + "rawBytes": 317077, + "minifiedBytes": 209005, + "gzipBytes": 53930, + "brotliBytes": 45471 }, { "id": "bitmap-baker-wasm", diff --git a/docs/log.md b/docs/log.md index 1ca3e3e2..b7a1d04d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,47 @@ ## 2026-08-09 +- **Deleted the duplicate TypeScript raster packing and lifecycle path** — Raster techniques now stop at identity, + artifact decoding, retained CPU resource ownership, and disposal; Rust policy programs remain the only production + instance packers and dirty-range publishers. Removed `RasterRuntime`, candidate/commit staging, glyph selection, + storage allocation, record writers, their obsolete public types, and tests that reconstructed the deleted packers. + A Mori 0.19.1 production scan corroborated the parallel path and separated it from the live ordered-direct and + stable-indirect planners, whose shared draw-emission shape has distinct allocation and retirement semantics. All 154 + Rust engine tests, all 161 package integration tests, Unicode 17 conformance, TypeScript, lint, formatting, and OKF + validation pass. The cleanup leaves Wasm unchanged and reduces core JS + Wasm from 461,917 to 460,901 gzip bytes and + complete Three + Wasm from 501,815 to 498,922 gzip bytes, with renderer peers external. + +- **Made raster policy origins exact without widening retained glyph storage** — The first-party policy had treated + positioned ink-box starts as baseline origins, then subtracted the baked raster plane a second time. The mapping was + dormant while the legacy TypeScript renderer remained authoritative and became visible only after the single-path + Rust cutover. The independent Bitmap CPU oracle exposed a 12 px vertical displacement and 33,492 differing channel + bytes; no tolerance or fixture changed. Rust now exposes explicit origin policy fields and maps each renderable glyph + to its existing semantic-glyph record with one `u32` index. The already-retained cluster-ID lane supplies plan semantic + identity, so the hot render glyph record does not grow. The public WebGL2 Bitmap target passes 32/32 exact frames with + zero differing bytes and pinned SHA-256 `a47930d3…15e893`; the complete paragraph matrix passes 32/32. The 22k direct + benchmark returns to the pre-fix 107.56 MiB retained high-water mark. Optimized Wasm is 1,159,121 raw / 441,811 gzip / + 347,554 Brotli bytes, 818 / 451 / 415 bytes above the prior checkpoint. + +- **Regenerated package-size and edit-latency truth from the final stable-addressing artifact** — The renderer-neutral + core is 1,257,322 raw / 460,673 gzip / 364,097 Brotli bytes, including the 1,159,121 raw / 441,811 gzip / + 347,554 Brotli shaper Wasm. The complete Three adapter plus engine is 1,505,897 / 500,509 / 396,903 bytes; Three, + React, and React Three Fiber remain external peers. A sequential eight-warmup/31-sample 22k Bitmap run measures the + ordered-direct equal-length edit at 1.330/6.328 ms and middle splice at 8.369/8.473 ms median/p95. Stable-indirect + middle splice measures 10.683/11.149 ms and writes only 452 B. The earlier 51.067 ms stable figure was the maximum of + an 11-sample run (the benchmark's percentile index selects the maximum at that sample count), did not reproduce, and + is not retained as ordinary latency evidence. A stricter stable equal-length run detected late Wasm growth before it + could publish a report, so stable-indirect remains a correctness capability rather than the first-party default. + +- **Completed stable-indirect Three record addressing without changing the default** — The Three executor now resolves + one validated logical-to-physical record address for Bitmap, MSDF, Slug, custom programs, indexed transforms, and + origin augmentation. A product integration regression proves a paragraph reorder patches only the Rust order buffer, + retains physical glyph storage, and reuses the existing draw objects. The shared nested-storage oracle renders the + red record behind a green decoy exactly on forced WebGL2 and hardware WebGPU (16/16 pixels, identical SHA-256), while + the complete ordered Bitmap/MSDF/Slug/custom-material matrix remains green on both backends. Stable slot lookup reuses + the committed identity index for revision-only/reorder updates, improving two short 22k localized-edit medians from + 2.903 to 2.446 and 2.376 ms. Those runs are implementation checkpoints, not final tail evidence; the stricter current + measurement and growth result are recorded above. Optimized Wasm grows 992 bytes from 1,157,311 to 1,158,303 raw bytes. + - **Added the missing middle-splice workload before choosing edit storage** — The unchanged replacement case remains the canonical comparison, while a new `localized-splice` case alternates one UTF-16 insertion and deletion in the middle of the same 22,000-glyph fixture. Ordered-direct measures 9.119 ms median / 10.016 ms p95 and writes 511.3 KiB because diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 78a3fd9c..dc5503d7 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:8f0e2494abe2325efb08911365a2cdde50e324cd452a6743078ea4fcd417105f' +source_digest: 'sha256:4a244a0109c229d660d5206d39478e22299c5e4a625d065e97e9d51fca7f1c1a' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -382,7 +382,7 @@ Live CPU, FPS, and GPU telemetry share frame timestamps, one RAF-driven presenta The responsive shell reserves the three-column rail/main/control layout for viewports at least 1,200 CSS pixels wide. The wordmark toggles the workload rail at desktop size and opens the workload drawer below desktop. Main mode keeps technique selection above independently scrolling workloads and presents font fixtures as buttons below them. The fixture panel remains content-height when its buttons fit, grows only as they require, and caps at half of the post-Technique rail region; only then does its button list scroll with the same contained overscroll and directional edge fades as the workload list. Compact Main controls use the same fixture buttons, while Presentation keeps its intentionally compact custom dropdown. Tablet and phone keep the live scene mounted, carry the shared Bitmap/MSDF/Slug switcher in a compact header with explicit separation between techniques, and present controls as a scrollable floating panel capped at 60% of viewport height; they do not replace the scene with a bottom-tab flow. Form controls inherit only the design-system font family, leaving their explicit Tailwind size and line-height utilities authoritative. The scene header stacks before its chips on narrow widths, and control labels stay at or below 13 CSS pixels without clipping while interactive controls retain at least 28 CSS pixels of height. Shared range controls remove text-field padding and draw their visible rail exactly one half-thumb radius inside each edge, so the minimum and maximum rail endpoints equal the native thumb-center travel. An accent segment fills from the minimum to the controlled value over the remaining neutral rail. Component tests distinguish range and text geometry, normalize and clamp fill progress, and the live product probe verifies zero horizontal padding, the explicit inset, visible fill/rail layers, and both numeric endpoints. The responsive product probe owns technique switching, drawer and panel interaction, retained-scene visibility, the 60%-height panel cap, and horizontal-overflow gates at 390, 1,024, and 1,280 CSS pixels; its in-process Vite lifecycle resolves the compiler from the application workspace, selects an available local port, and closes both browser and server after success or failure. -This application owns the shared target/scenario runner, responsive Figma-backed interface, URL state, validation/report/export views, deterministic synthetic target, real portable-baker target, real public loader/Worker-fallback target, real HarfRust shaping-conformance target, real paragraph measurement/positioned-layout/policy/CJK targets, the dual-backend TSL shader baseline, the public `Text` bitmap target, and the public React `Text` reconciler target. The runner disposes partial target state when loading fails, and the UI retains typed WebGPU availability through label and tone rendering. The interactive UI, browser headless CLI, Vitest, Vitexec, and Playwright all call the same strict registry execution module. Inter 4.1 remains the default fixture; Amiri 1.002 owns complex-script evidence; Noto Sans CJK JP 2.004 owns the maximum-cardinality universality lane. Each is immutable, licensed, hash-authenticated, and paired with checked HarfRust/HarfBuzz evidence. Chromium 149 runs the bounded conformance suite through forced WebGL2, including exact bitmap readback and deterministic React reconciliation; the maintainer-local lane repeats the bitmap frame on hardware WebGPU. The CJK result fixes thirteen corpus cases, four paragraphs, twelve layouts, eight plans, one direct shape call, four paragraph shape calls, zero reshapes, 10,622 output bytes, and the exact composite hash `a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68`; Vitexec repeats it with WebGPU active. +This application owns the shared target/scenario runner, responsive Figma-backed interface, URL state, validation/report/export views, deterministic synthetic target, real portable-baker target, real public loader/Worker-fallback target, real HarfRust shaping-conformance target, real paragraph measurement/positioned-layout/policy/CJK targets, the dual-backend TSL shader baseline, the public `Text` bitmap target, and the public React `Text` reconciler target. The TSL baseline now uses a green decoy record plus a u32 logical-order table selecting a red physical record, so its exact readback proves the nested storage lookup used by stable-indirect plans rather than only proving a constant node graph. Forced WebGL2 and hardware WebGPU both return 16/16 red pixels with hash `fec0f57d…c77`. The runner disposes partial target state when loading fails, and the UI retains typed WebGPU availability through label and tone rendering. The interactive UI, browser headless CLI, Vitest, Vitexec, and Playwright all call the same strict registry execution module. Inter 4.1 remains the default fixture; Amiri 1.002 owns complex-script evidence; Noto Sans CJK JP 2.004 owns the maximum-cardinality universality lane. Each is immutable, licensed, hash-authenticated, and paired with checked HarfRust/HarfBuzz evidence. Chromium 149 runs the bounded conformance suite through forced WebGL2, including exact bitmap readback and deterministic React reconciliation; the maintainer-local lane repeats the bitmap frame on hardware WebGPU. The CJK result fixes thirteen corpus cases, four paragraphs, twelve layouts, eight plans, one direct shape call, four paragraph shape calls, zero reshapes, 10,622 output bytes, and the exact composite hash `a1a833f2:fbe2aa07:922f9a2e:8c977f4d:85a2f640:fd42b9f7:53d8ec89:8cb3050c:bbfd039d:837a2b43:2f450f5e:9900b4af:c49f3e68`; Vitexec repeats it with WebGPU active. Milestone 9 adds deterministic package-owned Slug GLB fixtures for all seven visual families and one copied-and-adapted Three.js/TSL benchmark adapter. The fixtures compose the shared core artifact with embedded analytic curve, exact header, and exact reference resources, authenticate raw and gzip identities, and publish bake plus decoded-GPU totals in one checked manifest. The adapter supports baked and serial runtime delivery, reports curve/header/reference allocations separately from the framebuffer, and exercises the public Slug raster through the same retained `Text`, dual-backend renderer, frame readback, and live telemetry boundaries as the established raster targets. Slug is a genuine third technique across all seven live comparison workloads, the independent CPU sampling comparison, source-outline fidelity, and baked/runtime parity surfaces. Its imports remain dynamic so selecting Bitmap or MTSDF does not load Slug. Deterministic WebGPU and forced-WebGL2 product, sampling, and source-outline probes pass. The shared hardware-WebGPU product probe covers Bitmap, MTSDF, and Slug over Text Ladder, Zoom Text, Icon Grid, Off-axis / 3D, Dynamic Layout, Paragraph Stress, and Paint & Effects. Paint & Effects limits Slug to animated fill and opacity and disables both outline and shadow controls; MTSDF retains both effects. The retained quality and performance matrices use authored Latin, Arabic, Devanagari, and Japanese specimens instead of sending every font through Latin fallback. The packed-hull experiment retains exact dual-backend quality, timing, payload, and residency evidence but is rejected and removed from the shipping tree because no source clears the performance gate on both backends. The root-contribution experiment likewise retains its precommitted manifest, complete 28-cell quality result, final-program identities, and 140 paired sessions while removing its temporary graph selector and probes. All pixels and resources remain exact. Final Three programs prove that the baseline already lowers `select` to eight root-condition branches across both axes, while the candidate coalesces the same work into four but adds 304 generated bytes on each backend. No source clears 5% on both backends: median paired deltas across the seven sources are +0.84% on WebGPU and -1.56% on WebGL2, with an 8.43% CJK WebGPU regression and 11.22% Inter WebGL2 regression. The applicable older-fork baseline and retained challenger queue are complete for Milestone 9; new hypotheses remain future measured research. diff --git a/docs/packages/text.md b/docs/packages/text.md index 95066573..89f15047 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:45bd229154c46ae55792637f90932ef600fa7ad524fa3b680338cf13f4f28161' +source_digest: 'sha256:1ed5591cfb6b3ccef72987d9c9cac3b4f7dfa8deee20b46bf82498992f879c57' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -125,8 +125,12 @@ compiled policy; it never invokes a JavaScript callback in shaping, layout, or p The first-party policy can select indexed transform batching, direct per-draw transforms, or a hybrid. Indexed mode adds a stable transform-table ID to each rendered glyph so compatible paragraphs may collapse into one draw. Direct mode splits -draws by transform for integrations that prefer ordinary object matrices. `TextGroup.compositing` determines whether -Rust must preserve authored ordering or may reorder independent work. +draws by transform for integrations that prefer ordinary object matrices. Policy programs may use ordered-direct or +stable-indirect physical storage. Stable draws carry one reserved u32 order buffer; Three validates its draw/primitive +addressing once, then uses the same logical-to-physical mapping for technique records, transform indices, explicit origin +queries, and third-party program material contexts. `TextGroup.compositing` determines whether Rust must preserve authored +ordering or may reorder independent work. Ordered-direct remains the first-party default until stable planning meets the +same tail-latency target. `materialId` is explicit through the frame ABI and render plan. Three maps it to a `defineTextMaterial()` factory. Material identity may split draws without forcing a second copy of the canonical glyph buffers. @@ -172,18 +176,23 @@ There are no instance-ignoring runtime ABI readers. Package builds isolate the d `artifact-baker` feature sets from kernel-only test targets and reject an optimized module missing any contract-declared artifact export, preventing Cargo's shared top-level artifact path from silently publishing a smaller test variant. -Asynchronous Worker execution is a follow-on host concern. Transfer buffers must return to the Worker when retired so -their final collection occurs in the owning realm. It does not restore the deleted TypeScript shaping Worker. +The renderer-neutral core owns the completed asynchronous Worker transfer contract: it copies opaque frame bytes once +into a bounded worker-owned transferable pool, applies explicit backpressure, and requires root to transfer each retired +buffer back so reuse or final collection occurs in the owning realm. Adopting that mode in the Three host is deferred; +the synchronous Three path does not restore the deleted TypeScript shaping Worker. TypeGPU is likewise a later adapter +slice built directly against the Rust render plan. ## Current correctness evidence The foundation currently has: -- 150 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and +- 154 passing Rust engine tests, including exact retained-cluster, revision-range, immediate line-convergence, and later cursor-convergence regressions; - the package JavaScript/integration gate passing through the single-path public exports; - exact retained Amiri bidi, policy, ellipsis, clipping, UIKit-layout, and CJK contracts exercised by the browser `paragraph-contracts` target through public `FontLoader`, `Text`, `TextGroup`, `measureLayout()`, and `inspectLayout()`; +- 32/32 pixel-exact public Bitmap WebGL2 frames against the independent CPU oracle, including resize and clipping, with + zero differing channel bytes and pinned SHA-256 `a47930d3…15e893`; - source-font SHA-256, registered shaping hashes, and HarfRust/HarfBuzz oracle identities authenticated independently of the browser behavior check; - byte-identical Bitmap, MSDF, and Slug packing/consumer gates retained elsewhere in the benchmark suite. @@ -194,22 +203,40 @@ publication. The retained engine deliberately receives that style scalar as f32, narrows published values once. An independent calculation from the f32 line box reproduces the corrected final baseline, centered glyph row, content height, and complete layout hash exactly; no runtime precision or tolerance changed. +## Legacy-path and duplication audit + +The Rust command buffer is the only glyph-packing implementation. The former TypeScript `RasterRuntime`, raster +candidate/commit transaction, `select`, `createStorage`, and `writeStorage` surfaces are deleted from production source +and public exports. Current raster techniques own identity, artifact decoding, retained CPU resource data, and disposal; +Rust policy programs own instance packing and dirty-range publication. The package gate retains production render-plan, +font-binding, Three execution, artifact-validation, and Unicode conformance coverage instead of test-only TypeScript +packers. + +A Mori 0.19.1 production-source scan (review profile, same-language threshold 0.85, minimum 40 tokens) corroborated the +deleted parallel path and identified smaller repeated validation helpers. It also highlighted similar draw emission in +`ordered_plan.rs` and `stable_plan.rs`; those modules are not duplicate implementations of one behavior. Ordered-direct +compacts physical records in draw order, while stable-indirect preserves slots, publishes an order buffer, and quarantines +retirements until renderer acknowledgement. Shared emission logic may be extracted only if compiled-size and benchmark +evidence show a benefit; a generic source refactor that monomorphizes twice is not assumed to shrink or accelerate Wasm. + ## Current size and performance evidence The latest checked package-size record after the baker ABI cleanup reports: | Graph | Raw | gzip | Brotli | | --------------------------------------- | ----------: | --------: | --------: | -| Core JavaScript plus shaper Wasm | 1,224,539 B | 447,121 B | 353,986 B | -| Three adapter plus core and shaper Wasm | 1,466,450 B | 485,864 B | 385,930 B | +| Core JavaScript plus shaper Wasm | 1,248,941 B | 460,901 B | 364,253 B | +| Three adapter plus core and shaper Wasm | 1,487,569 B | 498,922 B | 395,786 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. -Relative to the preceding checked record, browser-core JavaScript is effectively flat (+96 raw, +7 gzip, -5 Brotli), -the Three adapter shrinks by 1,381 raw / 238 gzip / 255 Brotli bytes, and the shaper Wasm grows by 13,270 raw / 6,239 -gzip / 4,288 Brotli bytes. The corrected complete MTSDF baker is 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; -the earlier 52 KiB observation was a kernel-only test artifact that reused the distributable Cargo target directory. +The optimized shaper is 1,160,543 raw / 443,055 gzip / 348,784 Brotli bytes. The renderer-neutral JavaScript graph is +88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 327,026 raw / 55,867 gzip / +47,002 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 +to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; Wasm did not change in that cleanup. +The corrected complete MTSDF baker remains 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; the earlier 52 KiB +observation was a kernel-only test artifact that reused the distributable Cargo target directory. The public Three benchmark now supports an outside-only mode that leaves the internal phase collector disabled and wraps one `updateMatrixWorld()` call with a host timer. An eight-warmup/31-sample run over 25,515 positioned glyphs measured @@ -245,12 +272,24 @@ identity; the first storage mismatch falls back to complete batch discovery. Thr shaper is 1,157,311 raw bytes, a 4,189-byte increase, and retained high-water memory is 79.81 MiB. The repeated median gain is established; the roughly 5.74 ms p95 and 81.4–81.6% RSD still fail the tail-latency gate. -The direct benchmark now also keeps an independent middle-splice lane. Alternating one UTF-16 insertion/deletion through -ordered-direct storage measures 9.119 ms median / 10.016 ms p95 and writes 511.3 KiB because following physical records -move. Stable-indirect reduces that publication to 452 B, but its current compiler measures 10.776/51.067 ms; even its -equal-length replacement path measures 2.903/19.312 ms. This establishes the storage-policy tradeoff without changing the -default: stable planning and Three shader indirection remain optimization/correctness work, and chunk-local text storage -cannot be claimed as the dominant splice fix while the physical plan has this cost. +The direct benchmark also keeps an independent middle-splice lane. On the current optimized artifact, a sequential +eight-warmup/31-sample run measures ordered-direct insertion/deletion at 8.369/8.473 ms median/p95 and 511.3 KiB written +because following physical records move. Stable-indirect reduces that publication to 452 B but measures 10.683/11.149 ms. +The earlier 51.067 ms figure was the maximum selected as p95 from only 11 samples and did not reproduce. This establishes +the storage-policy tradeoff without changing the default: stable planning remains optimization/correctness work, and +chunk-local text storage cannot be claimed as the dominant splice fix while the physical plan has this cost. + +Three now consumes stable-indirect plans through one shared record-addressing abstraction rather than technique-specific +branches. A Rust/Three integration regression proves lifecycle reorder mutates only the order table and preserves physical +glyph bytes and draw objects. A two-record GPU oracle makes slot zero green and slot one red, then renders logical slot zero +through `order[0] = 1`: forced WebGL2 and hardware WebGPU both return 16/16 exact red pixels and the same readback hash. +The complete ordered Bitmap/MSDF/Slug/custom-material matrix remains green on both backends. Reusing the stable pool's +committed identity index improved two short 22k equal-length runs to 2.446/6.862 and 2.376/6.704 ms median/p95, but a +stricter 31-sample run detected late Wasm growth before producing a report. Stable-indirect is therefore renderer-proven +but not the first-party default or a closed performance lane. The optimized shaper is 1,159,121 raw / 441,811 gzip / +347,554 Brotli bytes. The explicit raster-origin correction adds 818 raw bytes without increasing the measured retained +high-water mark: one render-to-semantic `u32` index replaces a duplicate hot-record identity, while origins remain in the +existing semantic glyph record rather than two additional retained float lanes. ## Merge gates still open diff --git a/docs/planning/api-shapes.md b/docs/planning/api-shapes.md index a6ca6974..1dbf531c 100644 --- a/docs/planning/api-shapes.md +++ b/docs/planning/api-shapes.md @@ -34,6 +34,10 @@ generated: at: '2026-08-07T05:01:15Z' --- +> Historical merged-v0 fixture. Names such as `RasterRuntime`, `RasterModule`, and the TypeScript paragraph target below +> are retained only for migration archaeology; they are not current exports. Use +> [the current package reference](../packages/text.md) for the single Rust render-plan path. + # Merged v0 runtime and bake API fixture Status: merged v0 surfaces are implemented but unreleased; sections labeled deferred remain proposals diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index 75413206..a5881a0c 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -320,6 +320,8 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-233 | Three classifies desired-state changes by the retained Rust semantic section they can invalidate: text replacement sends text/style/geometry, style-family changes send style, content-box changes send geometry, and an empty update is a no-op. A requested semantic view mask rides on a pending mutation frame so layout measurement/inspection and render-plan publication use one `text_update`; one returned sidecar populates all retained paragraphs, while cached committed queries remain crossing-free. On identical old Rust, Chromium 149/WebGPU/DPR-2 Paragraph Stress changes from 14.295 ms committed-baseline median to 13.615 ms with measurement piggyback alone and 7.450 ms after semantic dirty tiers, at 11,510 glyphs and one draw. The full candidate measures 6.885 ms; these 13–16-sample telemetry histories support isolation and direction, not a portable threshold. Focused integration proves zero calls for empty updates, exactly one call for mutation plus measurement, and exact command-buffer output. | Accepted | | D-234 | The Rust render-plan cutover has one blessed executable path. The TypeScript paragraph engine, paragraph batch/attachment transaction, preparation worker, direct shaping/bidi/reshape ABI and readback, and first-generation `/typegpu` target are deleted rather than retained as compatibility implementations. Public `TextRuntime` owns the internal shaper; package-owned hosts alone access its direct-memory engine exports. Three and R3F consume the Rust command buffer. TypeGPU is a later from-scratch consumer of the same render-plan/policy contract and may not restore renderer-side layout or candidate/current target state. After cleanup, optimized SIMD Wasm is 1,113,113 raw / 422,035 gzip / 333,171 Brotli bytes. With `three`, React, and R3F external as optional peers, renderer-neutral JS + Wasm totals 1,211,173 / 440,875 / 349,703 raw/gzip/Brotli bytes and the complete Three adapter JS + Wasm totals 1,454,561 / 479,863 / 381,897. | Accepted | +| D-235 | Raster techniques stop at identity, artifact decoding, retained CPU resource ownership, and disposal. The obsolete TypeScript `RasterRuntime`, candidate/commit raster transaction, glyph `select`, storage allocation, and record writers are deleted; Rust policy programs are the only production instance packers and dirty-range publishers. A Mori 0.19.1 structural scan corroborates the removed parallel path and flags similar ordered-direct/stable-indirect draw emission for evidence-gated extraction, not deletion: the strategies have distinct slot, order-buffer, and retirement semantics. All 154 Rust engine tests, all 161 package integration tests, Unicode 17 bidi/line-break conformance, both TypeScript projects, lint, and formatting pass. The cleanup leaves Wasm unchanged and reduces measured renderer-neutral JS + Wasm from 461,917 to 460,901 gzip bytes and complete Three + Wasm from 501,815 to 498,922 gzip bytes, with Three, React, and R3F external. | Accepted | + The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. ## Verification and optimization diff --git a/docs/planning/engine-integration-contract.md b/docs/planning/engine-integration-contract.md index e4da02e5..d0b9e6c6 100644 --- a/docs/planning/engine-integration-contract.md +++ b/docs/planning/engine-integration-contract.md @@ -4,7 +4,7 @@ title: Engine integration contract description: Exact storage, batching, ordering, transform, publication, and lifetime boundary between core text preparation and an engine renderer. documentation_type: reference tags: [api, engine, rendering, batching, storage, revisions] -status: stable +status: deprecated sources: - id: core-api resource: core-api.md @@ -38,6 +38,10 @@ generated: at: '2026-08-07T03:25:58Z' --- +> Historical design record. The Rust command buffer superseded the target preparation and TypeScript storage contract +> described below. Use [the current package reference](../packages/text.md) and +> [Rust layout-engine design](rust-layout-engine.md) for the blessed integration boundary. + # Engine integration contract An engine integration receives already partitioned glyph storage and ordered, variant-bearing glyph runs. diff --git a/docs/planning/raster-technique-api.md b/docs/planning/raster-technique-api.md index 0e5d4e5f..22840fdb 100644 --- a/docs/planning/raster-technique-api.md +++ b/docs/planning/raster-technique-api.md @@ -4,7 +4,7 @@ title: Raster technique and engine resource API description: Canonical boundary between portable raster baking and decoding, core glyph packing, reusable shader backends, and engine-specific GPU targets. documentation_type: reference tags: [api, raster, baking, resources, shaders, engines, typegpu, tsl] -status: stable +status: deprecated sources: - id: core-api resource: core-api.md @@ -44,6 +44,10 @@ generated: at: '2026-08-07T04:49:05Z' --- +> Historical design record. The Rust render-plan cutover superseded the TypeScript glyph binding, storage, and packing +> interfaces described below. Use [the current package reference](../packages/text.md), +> [core API](core-api.md), and [Three API](three-api.md) for the blessed surface. + # Raster technique and engine resource API One raster should not be one engine plugin. The stable split is: diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 106da0bb..eb985554 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -892,6 +892,33 @@ derivable starts. Optimized Wasm measures 964,019 / 360,765 / 288,742 raw/gzip/B 895,593 / 335,396 / 264,355 checkpoint. This is shared runtime data and code, not per-font shaping payload. The number does not claim layout or shaping latency because neither has consumed these products yet. +#### Future optimized-runtime delivery research + +The complete optimized shaper currently contains 939,488 bytes of Wasm code and 218,958 bytes of initialized data before +transfer compression. The repository-owned generated Unicode 17 script, bidi, and line-break Rust sources total 150,408 +bytes, but source size is not binary contribution and the data section also contains unrelated constants. Consequently, +external tables alone cannot be claimed to reduce the runtime to a small basic-font-parser footprint; a feature-stripped +build must measure the executable floor. + +Two independent experiments are reserved for a later stack: + +1. Replace compiled Unicode property arrays with a versioned, little-endian, aligned data-pack format. The core reserves + one retained region, validates the pack version, section directory, lengths, and digest once, and thereafter reads + bounded slices directly. The initial implementation should fetch a plain binary blob and copy it once into the + reserved Wasm region. A data-only Wasm side module that imports the core memory and initializes the region with active + data segments is a benchmark candidate, not an assumed improvement. +2. Measure an EFIGS/Cyrillic/default-shaper executable profile separately from optional complex-script profiles. HarfRust + 0.12 has no script-level Cargo feature gates, so isolating Arabic/Indic/Khmer and related specialized shaper code would + require an upstream feature design or a narrowly maintained fork. CJK/default OpenType shaping, Unicode line breaking, + emoji grapheme behavior, and font-local GSUB/GPOS data must not be conflated with those specialized code paths. + +Font-local OpenType payloads remain in each baked font or fallback font; applications already control their glyph and +font-stack subsets. Data-pack discovery should therefore be driven before font registration by declared Unicode/script +coverage. A cold unexpected script may report a stable missing-pack identifier and retry after asynchronous loading, but +steady-state `text_update` remains one synchronous call and never initiates I/O. Native consumers may memory-map the same +pack format, while browsers retain the initialized bytes in Wasm linear memory. Each experiment must report raw, gzip, +Brotli, compile/startup, retained-memory, cold-load, and hot-path results before changing the default package. + Retained bidi and run-itemization now consume those products inside the same transaction. UAX #9 output is copied into reusable active/pending level, class, paragraph, and equal-level-run arrays. Text or root base-direction changes re-run bidi; unchanged text and style do not. Root direction selects paragraph base level, while a nested stated LTR/RTL value @@ -1001,8 +1028,10 @@ Horizontal positioning now completes the first nonempty production frame path. R #9 L1 resets before L2 reorders logical cluster slices into visual order. Each editorial slot positions independently, including direction-aware alignment and non-final-line space justification. HarfRust offsets and actual fallback-font metrics accumulate in `f64`; baked glyph extents become positive-down primitive bounds after one `f32` narrowing, while -non-rendering glyphs advance the cursor without producing instances. Six F32 semantic lanes carry bounds, font size, -and raster ratio; four U32 lanes carry foreground, cluster, region, and flow-thread identity. Exact float bits plus all +non-rendering glyphs advance the cursor without producing instances. Six retained F32 semantic lanes carry bounds, font +size, and raster ratio; explicit baseline-origin policy fields resolve through each renderable glyph's packed index into +the existing semantic-glyph records instead of duplicating two float arrays. Six U32 lanes carry foreground, cluster, +region, flow-thread, transform, and stable-glyph identity. Exact float bits plus all integer and semantic fields determine a monotonic transactional `content_revision`. A unit fixture retains revisions `[1,2]` across an identical rebuild and advances to `[3,4]` after shifting the slot one pixel. A compiled real-Inter `text_update` publishes nonzero resource/buffer/patch/primitive/draw tables; the identical next call keeps the same Wasm @@ -1244,8 +1273,9 @@ GPU schemas: five Bitmap buffers totaling 48 bytes per instance, seven MTSDF vec Slug float vec4 plus two unsigned vec4 buffers totaling 112 bytes. The same stress text positions 25,515 glyphs and selects 21,805 renderable raster instances, matching the portable techniques' deliberate omission of absent records. Policy validation now propagates semantic input dependencies through the straight-line program once and stores a mask -per physical output. Positioning records exact six-F32/four-U32 change bits in a compact side lane without enlarging the -60-byte `PlanGlyph`; ordered-direct and stable-indirect planning intersect the two masks. New or rebound records remain +per physical output. Positioning records exact eight-F32/six-U32 dependency change bits in a compact side lane without +enlarging the 64-byte `PlanGlyph`; ordered-direct and stable-indirect planning intersect the two masks. New or rebound +records remain conservative full writes. For 21,805 renderable instances, full-column resize now emits one position/geometry patch: 170.4 KiB for Bitmap and 340.7 KiB for MTSDF or Slug, down from cold-plan writes of 1,022.1, 2,384.9, and 2,384.9 KiB. Font-size emits 340.7/340.7/681.4 KiB because Bitmap size and Slug inverse scale also change. Static UV, color, bounds, From a417a082c557fb07d00a62e27a3798f1dbb06dcd Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 22:17:47 -0400 Subject: [PATCH 113/128] perf(text): share plan emission and bound stable scans --- .../src/generated/package-sizes.json | 90 ++++---- docs/log.md | 10 + docs/packages/benchmarks.md | 4 +- docs/packages/text.md | 39 ++-- docs/planning/decision-register.md | 3 +- docs/planning/rust-layout-engine.md | 18 +- packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/ordered_plan.rs | 179 +++++++-------- .../text/rust/shaper/src/engine/plan_draw.rs | 111 +++++++++ .../rust/shaper/src/engine/stable_plan.rs | 213 +++++++++--------- 10 files changed, 387 insertions(+), 281 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/plan_draw.rs diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 7740eae4..e5365a13 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -21,22 +21,22 @@ "label": "Text engine Wasm", "status": "measured", "format": "wasm", - "sha256": "1f8e820b08aeb8e83cd113a2613b8f1aef45f3b2a65303af06e8405f7ece5e7c", - "rawBytes": 1160543, - "minifiedBytes": 1160543, - "gzipBytes": 443055, - "brotliBytes": 348784 + "sha256": "540db0e6eb37c3135f40b0a4ec40c0a5b544080d329bef722d3069a88ff9808e", + "rawBytes": 1160323, + "minifiedBytes": 1160323, + "gzipBytes": 442570, + "brotliBytes": 348361 }, { "id": "renderer-neutral-core-total", "label": "Renderer-neutral core total (JS + Wasm)", "status": "measured", "format": "aggregate", - "sha256": "3498f8e1044af251b1b4856126b26343f2253b202ac855569975e363a2df66fe", - "rawBytes": 1248941, - "minifiedBytes": 1224700, - "gzipBytes": 460901, - "brotliBytes": 364253 + "sha256": "58d2185070062f36a2c230abc2960945a6d685c1de5e8190827e8c876ab20163", + "rawBytes": 1248721, + "minifiedBytes": 1224480, + "gzipBytes": 460416, + "brotliBytes": 363830 }, { "id": "three-runtime-js", @@ -54,11 +54,11 @@ "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)", "status": "measured", "format": "aggregate", - "sha256": "d3bc862e2085388da122bd41eb21c93575fb820d2ac13eb9d9ba725d21eea86e", - "rawBytes": 1487569, - "minifiedBytes": 1376213, - "gzipBytes": 498922, - "brotliBytes": 395786 + "sha256": "a2241aa7e70b625264c7c5a98dfe4ddefa4a11790558bd3c941e3be1a3eaec40", + "rawBytes": 1487349, + "minifiedBytes": 1375993, + "gzipBytes": 498437, + "brotliBytes": 395363 }, { "id": "font-inter-bitmap-16-32", @@ -131,66 +131,66 @@ "label": "Three + engine + Inter Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "d4c76716d543aa8aaf9a7407955ee874542bae490d847b6d617d87c8b1b564f7", - "rawBytes": 4637217, - "minifiedBytes": 4525861, - "gzipBytes": 1057230, - "brotliBytes": 816376 + "sha256": "59d04c9b022855ab6ec0cdbebc3606f734216afde578ee216871ad02355238f4", + "rawBytes": 4636997, + "minifiedBytes": 4525641, + "gzipBytes": 1056745, + "brotliBytes": 815953 }, { "id": "delivery-three-inter-mtsdf", "label": "Three + engine + Inter MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "1fd846dedac5fbe45066dae32611aac869f52358202e89a9159f93714a9d58fa", - "rawBytes": 40835281, - "minifiedBytes": 40723925, - "gzipBytes": 7297334, - "brotliBytes": 3636158 + "sha256": "63fc9b945f732839a48a7bb011d2e6d246e749445097e0f5a1fcc45c01e082b3", + "rawBytes": 40835061, + "minifiedBytes": 40723705, + "gzipBytes": 7296849, + "brotliBytes": 3635735 }, { "id": "delivery-three-inter-slug", "label": "Three + engine + Inter Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "e38722542434828c543275913899e8e9f169b1c4778de583229326c47463198c", - "rawBytes": 4932485, - "minifiedBytes": 4821129, - "gzipBytes": 1117409, - "brotliBytes": 805821 + "sha256": "6256468889c21d6e3ad94e4c5c3ce8f6c42a5b36af4b8c4fbbf0c048362100a9", + "rawBytes": 4932265, + "minifiedBytes": 4820909, + "gzipBytes": 1116924, + "brotliBytes": 805398 }, { "id": "delivery-three-icons-bitmap", "label": "Three + engine + Font Awesome Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "19b75efa511f46f97002bd3b5e16b13489862808ad51dcebc407faa321935eb1", - "rawBytes": 3869301, - "minifiedBytes": 3757945, - "gzipBytes": 948969, - "brotliBytes": 751335 + "sha256": "7f5bccf5142cac9a26003bdcbb8c57b39695fb087eb5f825f3de39e2e8e10007", + "rawBytes": 3869081, + "minifiedBytes": 3757725, + "gzipBytes": 948484, + "brotliBytes": 750912 }, { "id": "delivery-three-icons-mtsdf", "label": "Three + engine + Font Awesome MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "654bdbbde7c4885cb63fe20a0df0dfc264d7b1fe58123e8bd21c076639d5ae50", - "rawBytes": 34068469, - "minifiedBytes": 33957113, - "gzipBytes": 7726746, - "brotliBytes": 3720689 + "sha256": "1368c4b698500316e45208bb004138a2bebb33d6d94c6c2d40aaddcd733248e0", + "rawBytes": 34068249, + "minifiedBytes": 33956893, + "gzipBytes": 7726261, + "brotliBytes": 3720266 }, { "id": "delivery-three-icons-slug", "label": "Three + engine + Font Awesome Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "17a90f2032fd34f69c48cb60ee8ed7e813bcde3d2c831b7c517782961085a909", - "rawBytes": 4432981, - "minifiedBytes": 4321625, - "gzipBytes": 1156934, - "brotliBytes": 880784 + "sha256": "675dfb1a1c294921af36f3672bd4f0d3a09014649fc12fb23ac82b548e1579a3", + "rawBytes": 4432761, + "minifiedBytes": 4321405, + "gzipBytes": 1156449, + "brotliBytes": 880361 }, { "id": "font-validator-js", diff --git a/docs/log.md b/docs/log.md index b7a1d04d..39db5cd1 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-09 +- **Shared the compiled draw emitter and removed a quadratic stable-plan scan** — A symbol-bearing `-Oz` build attributes + 33.3 KiB of optimized function bodies to ordered planning and 50.1 KiB to stable planning, while confirming that the + planners retain different storage, order-buffer, and retirement work. Their identical final primitive/draw record + construction now calls one non-generic out-of-line kernel once per draw span. The strict stable benchmark then exposed + that every changed range rescanned every sorted slot write; exact range partitioning reduces 22k-target font-size from + 350.136 to 7.982 ms median and column resize from 49.636 to 3.767 ms. Stable splice is 9.372/9.583 ms median/p95 with + 452 B written. The combined final artifact is 1,160,323 raw / 442,570 gzip / 348,361 Brotli bytes, 220 / 485 / 423 bytes + smaller than the pre-extraction Wasm. Compile-time `lite`, `cjk`, and `full` runtime profiles remain a later measured + delivery experiment with one ABI; separate Wasm assets, not one bundle containing every variant, provide transfer wins. + - **Deleted the duplicate TypeScript raster packing and lifecycle path** — Raster techniques now stop at identity, artifact decoding, retained CPU resource ownership, and disposal; Rust policy programs remain the only production instance packers and dirty-range publishers. Removed `RasterRuntime`, candidate/commit staging, glyph selection, diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index dc5503d7..20b8af99 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -205,7 +205,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T17:57:28Z' + at: '2026-08-10T02:15:49Z' --- # Package reference: `@pmndrs/text-benchmarks` @@ -404,7 +404,7 @@ GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compa The independent package-size lane measures the initial public browser graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, and Unicode 17 analysis without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; the browser-core lane externalizes the package's declared `three`, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The report records its measurement platform and architecture plus the SHA-256 identity of each measured payload: minified bundle bytes for JavaScript and emitted module bytes for Wasm. Same-host regeneration is exact; every foreign-host raw/minified/gzip/Brotli result must satisfy the shared reviewed budget table because native Rust/Binaryen and Rolldown output has small cross-architecture byte variance. Coverage-capability growth is independently bounded against its pre-coverage baseline, and foreign-host failures report the measured payload, reviewed ceiling, and exceeded dimensions. The product inspector's selected-runtime total is the gzip transfer sum of the selected raster runtime graph and separately emitted shaper Wasm. The raster graph already contains the shared core and shaper JavaScript host, so adding the independent browser-core or text-shaper-JavaScript measurements would double-count code. Selected runtime and conditional runtime-bake totals are default-collapsed disclosures; their component rows remain available on demand without displacing the separate font-asset total. The font-asset card reports only one transport quantity: gzip bytes for compressed MTSDF artifacts and exact transferred bytes for uncompressed Bitmap/runtime-source assets. Decoded container, raster, and GPU allocation sizes never appear as children of that transfer total; GPU texture allocation remains isolated in the resource card. Each full row is the interaction target, while fixed label, status, and byte columns use a neutral centered chevron, a green check for loaded code, and a gray X for unloaded code. The total intentionally excludes external Three.js, React, and R3F peers plus font assets; those assets remain separate rows rather than being mislabeled as a complete application bundle. -The current Darwin arm64 record reports a 258,370 minified / 74,531 gzip / 57,310 Brotli peer-externalized browser graph and an independently measured 139,936 / 42,047 / 30,989 Unicode analysis graph. The first target-v1 technique contract adds 613 raw / 333 minified / 74 gzip / 20 Brotli bytes to browser core. Splitting renderer-neutral atlas decoding from Three realization changes the Bitmap closure by +491 / +242 / +47 / +16 and the MTSDF closure by −678 / −550 / −168 / −233 raw/minified/gzip/Brotli bytes; Slug is byte-identical. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, baker Wasm, shaper JavaScript, and shaper Wasm report 584,479, 9,524, 8,936, 6,077, 422,538, 36,966, and 680,312 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,117 minified / 5,530 gzip / 4,908 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The exact-distance-outline removal experiment measured Slug's isolated runtime graph falling from 288,338 to 278,977 minified bytes, from 83,445 to 81,151 gzip bytes, and from 64,856 to 63,013 Brotli bytes on its original same-host comparison. The current Bitmap, MTSDF, and Slug runtime closures measure 287,587 / 82,310 / 63,980, 290,959 / 83,549 / 65,085, and 292,301 / 84,209 / 65,667 minified/gzip/Brotli bytes. Slug's baker host measures 12,913 / 4,129 / 3,680 and its Wasm measures 465,046 raw / 186,683 gzip / 146,720 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. +The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,160,323 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 209,006 / 53,995 / 45,526, 209,010 / 53,994 / 45,525, and 209,005 / 53,930 / 45,471 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. The local Worker-queue Vitexec probe authenticates every output and reports observations rather than asserting machine-sensitive timing. Two Chromium runs measured a three-font queued burst at 30.8–32.0 ms and three separately initialized sequential Workers at 68.3–88.6 ms. The correctness suite separately proves one active post, FIFO completion, queued cancellation, and active-cancellation recovery without timers. The combined live lane runs its performance observation before interaction and conformance probes so accumulated renderer work cannot contaminate cold/steady telemetry. diff --git a/docs/packages/text.md b/docs/packages/text.md index 89f15047..6eb474bc 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -55,7 +55,7 @@ sources: title: Three.js text API reference generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T17:49:53Z' + at: '2026-08-10T02:15:49Z' --- # Package reference: `@pmndrs/text` @@ -216,8 +216,12 @@ A Mori 0.19.1 production-source scan (review profile, same-language threshold 0. deleted parallel path and identified smaller repeated validation helpers. It also highlighted similar draw emission in `ordered_plan.rs` and `stable_plan.rs`; those modules are not duplicate implementations of one behavior. Ordered-direct compacts physical records in draw order, while stable-indirect preserves slots, publishes an order buffer, and quarantines -retirements until renderer acknowledgement. Shared emission logic may be extracted only if compiled-size and benchmark -evidence show a benefit; a generic source refactor that monomorphizes twice is not assumed to shrink or accelerate Wasm. +retirements until renderer acknowledgement. A symbol-bearing optimized build attributes 33.3 KiB of function bodies to +ordered planning and 50.1 KiB to stable planning; that is strategy-specific code, not an assertion that all 83.4 KiB are +duplicates. The identical final primitive/draw-record construction is now one deliberately out-of-line non-generic +kernel. Together with a stable dependency-scan correction, the final Wasm is 220 raw / 485 gzip / 423 Brotli bytes smaller +than the pre-extraction artifact. This establishes a real, modest compiled win; it does not infer savings from source-line +count or assume that a generic refactor would avoid monomorphization. ## Current size and performance evidence @@ -225,22 +229,23 @@ The latest checked package-size record after the baker ABI cleanup reports: | Graph | Raw | gzip | Brotli | | --------------------------------------- | ----------: | --------: | --------: | -| Core JavaScript plus shaper Wasm | 1,248,941 B | 460,901 B | 364,253 B | -| Three adapter plus core and shaper Wasm | 1,487,569 B | 498,922 B | 395,786 B | +| Core JavaScript plus shaper Wasm | 1,248,721 B | 460,416 B | 363,830 B | +| Three adapter plus core and shaper Wasm | 1,487,349 B | 498,437 B | 395,363 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. -The optimized shaper is 1,160,543 raw / 443,055 gzip / 348,784 Brotli bytes. The renderer-neutral JavaScript graph is +The optimized shaper is 1,160,323 raw / 442,570 gzip / 348,361 Brotli bytes. The renderer-neutral JavaScript graph is 88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 327,026 raw / 55,867 gzip / 47,002 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 -to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; Wasm did not change in that cleanup. +to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; the later shared-emitter and stable +range-scan work reduces those final totals to 460,416 and 498,437 gzip bytes. The corrected complete MTSDF baker remains 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; the earlier 52 KiB observation was a kernel-only test artifact that reused the distributable Cargo target directory. The public Three benchmark now supports an outside-only mode that leaves the internal phase collector disabled and wraps one `updateMatrixWorld()` call with a host timer. An eight-warmup/31-sample run over 25,515 positioned glyphs measured -17.68/6.13/5.66/16.37 ms median and 18.11/6.32/6.53/16.57 ms p95 for cold/font-size/width/text updates. Those values cover +19.42/6.59/3.10/14.24 ms median and 21.00/6.86/4.75/15.26 ms p95 for cold/font-size/width/text updates. Those values cover frame preparation, the complete Rust transaction and render-plan publication, and Three plan application; they exclude GPU submission. An adjacent phase-instrumented run was indistinguishable within process noise. Those temporary profiler exports, calls, branches, and clock reads are now absent from the package source and clean publishing output; benchmark @@ -273,8 +278,8 @@ shaper is 1,157,311 raw bytes, a 4,189-byte increase, and retained high-water me is established; the roughly 5.74 ms p95 and 81.4–81.6% RSD still fail the tail-latency gate. The direct benchmark also keeps an independent middle-splice lane. On the current optimized artifact, a sequential -eight-warmup/31-sample run measures ordered-direct insertion/deletion at 8.369/8.473 ms median/p95 and 511.3 KiB written -because following physical records move. Stable-indirect reduces that publication to 452 B but measures 10.683/11.149 ms. +eight-warmup/31-sample run measures ordered-direct insertion/deletion at 8.452/9.033 ms median/p95 and 511.3 KiB written +because following physical records move. Stable-indirect reduces that publication to 452 B and measures 9.372/9.583 ms. The earlier 51.067 ms figure was the maximum selected as p95 from only 11 samples and did not reproduce. This establishes the storage-policy tradeoff without changing the default: stable planning remains optimization/correctness work, and chunk-local text storage cannot be claimed as the dominant splice fix while the physical plan has this cost. @@ -283,13 +288,13 @@ Three now consumes stable-indirect plans through one shared record-addressing ab branches. A Rust/Three integration regression proves lifecycle reorder mutates only the order table and preserves physical glyph bytes and draw objects. A two-record GPU oracle makes slot zero green and slot one red, then renders logical slot zero through `order[0] = 1`: forced WebGL2 and hardware WebGPU both return 16/16 exact red pixels and the same readback hash. -The complete ordered Bitmap/MSDF/Slug/custom-material matrix remains green on both backends. Reusing the stable pool's -committed identity index improved two short 22k equal-length runs to 2.446/6.862 and 2.376/6.704 ms median/p95, but a -stricter 31-sample run detected late Wasm growth before producing a report. Stable-indirect is therefore renderer-proven -but not the first-party default or a closed performance lane. The optimized shaper is 1,159,121 raw / 441,811 gzip / -347,554 Brotli bytes. The explicit raster-origin correction adds 818 raw bytes without increasing the measured retained -high-water mark: one render-to-semantic `u32` index replaces a duplicate hot-record identity, while origins remain in the -existing semantic glyph record rather than two additional retained float lanes. +The complete ordered Bitmap/MSDF/Slug/custom-material matrix remains green on both backends. A strict 31-sample stable +run exposed a quadratic dependency scan: each changed physical range rescanned every slot write. Binary-partitioning the +sorted writes to the requested range reduces stable font-size from 350.136 to 7.982 ms median and column resize from +49.636 to 3.767 ms; localized edit is 2.172/6.628 ms and splice is 9.372/9.583 ms median/p95. Stable no-op remains +1.083 ms versus ordered-direct's 0.001 ms, so stable remains an explicit policy rather than the first-party default. The +sequential benchmark high-water marks are 107.56 MiB ordered and 114.25 MiB stable; retained-memory right-sizing remains +open and neither figure is presented as ordinary application demand. ## Merge gates still open diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index a5881a0c..db8a46b0 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -43,7 +43,7 @@ sources: generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T10:13:02Z' + at: '2026-08-10T02:15:49Z' --- # Decision register @@ -321,6 +321,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-234 | The Rust render-plan cutover has one blessed executable path. The TypeScript paragraph engine, paragraph batch/attachment transaction, preparation worker, direct shaping/bidi/reshape ABI and readback, and first-generation `/typegpu` target are deleted rather than retained as compatibility implementations. Public `TextRuntime` owns the internal shaper; package-owned hosts alone access its direct-memory engine exports. Three and R3F consume the Rust command buffer. TypeGPU is a later from-scratch consumer of the same render-plan/policy contract and may not restore renderer-side layout or candidate/current target state. After cleanup, optimized SIMD Wasm is 1,113,113 raw / 422,035 gzip / 333,171 Brotli bytes. With `three`, React, and R3F external as optional peers, renderer-neutral JS + Wasm totals 1,211,173 / 440,875 / 349,703 raw/gzip/Brotli bytes and the complete Three adapter JS + Wasm totals 1,454,561 / 479,863 / 381,897. | Accepted | | D-235 | Raster techniques stop at identity, artifact decoding, retained CPU resource ownership, and disposal. The obsolete TypeScript `RasterRuntime`, candidate/commit raster transaction, glyph `select`, storage allocation, and record writers are deleted; Rust policy programs are the only production instance packers and dirty-range publishers. A Mori 0.19.1 structural scan corroborates the removed parallel path and flags similar ordered-direct/stable-indirect draw emission for evidence-gated extraction, not deletion: the strategies have distinct slot, order-buffer, and retirement semantics. All 154 Rust engine tests, all 161 package integration tests, Unicode 17 bidi/line-break conformance, both TypeScript projects, lint, and formatting pass. The cleanup leaves Wasm unchanged and reduces measured renderer-neutral JS + Wasm from 461,917 to 460,901 gzip bytes and complete Three + Wasm from 501,815 to 498,922 gzip bytes, with Three, React, and R3F external. | Accepted | +| D-236 | Ordered-direct and stable-indirect retain distinct storage engines but share one non-generic, out-of-line primitive/draw command emitter after resolving physical addressing. A symbol-bearing optimized build attributes 33.3 KiB and 50.1 KiB of function bodies to the respective planners; that is an attribution bound, not a duplicate-byte claim. Exact range partitioning also replaces stable planning's quadratic changed-range × slot-write dependency scan, reducing the 22k-target font-size median from 350.136 to 7.982 ms. The combined artifact is 1,160,323 raw / 442,570 gzip / 348,361 Brotli bytes, 220 / 485 / 423 bytes below the pre-extraction artifact. Future transfer-size work compiles separate ABI-identical runtime profiles selected at initialization; provisional `lite`, `cjk`, and `full` membership must be established by final-artifact measurement, and the scalar/SIMD build switch remains orthogonal. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index eb985554..1ef6c4c1 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -13,7 +13,7 @@ tags: - abi generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T14:25:40Z' + at: '2026-08-10T02:15:49Z' sources: - id: layout-benchmark resource: ../../packages/text/scripts/benchmark-paragraph-layout.mts @@ -900,14 +900,26 @@ bytes, but source size is not binary contribution and the data section also cont external tables alone cannot be claimed to reduce the runtime to a small basic-font-parser footprint; a feature-stripped build must measure the executable floor. -Two independent experiments are reserved for a later stack: +The preferred delivery experiment is now compile-time runtime profiles with one source tree and one public ABI. Each +profile is a separate Wasm asset selected during initialization, so a lite consumer does not download the full artifact. +Provisional profiles are `lite` (Latin/EFIGS-oriented), `cjk` (lite plus the CJK-relevant analysis/layout machinery), and +`full`; exact names and membership wait for section-level artifact measurements. Font-local GSUB/GPOS and selected glyph +coverage remain in baked font assets and are not language-whitelisted by the runtime profile. SIMD remains the published +default, with the existing compile-time scalar switch orthogonal to the language/runtime profile; no scalar artifact is +published until a target requires it. + +Three related experiments are reserved for a later stack: 1. Replace compiled Unicode property arrays with a versioned, little-endian, aligned data-pack format. The core reserves one retained region, validates the pack version, section directory, lengths, and digest once, and thereafter reads bounded slices directly. The initial implementation should fetch a plain binary blob and copy it once into the reserved Wasm region. A data-only Wasm side module that imports the core memory and initializes the region with active data segments is a benchmark candidate, not an assumed improvement. -2. Measure an EFIGS/Cyrillic/default-shaper executable profile separately from optional complex-script profiles. HarfRust +2. Measure the provisional compile-time profiles, including a build that omits stable-indirect planning when its declared + policy capability supports ordered-direct only. A symbol-bearing `-Oz` build currently attributes about 50.1 KiB of + optimized function bodies to stable planning plus separate stable order/pool support; this is an upper-bound signal, + not a measured stripped-artifact saving. Profile selection must reject an incompatible policy before any text update. +3. Measure an EFIGS/Cyrillic/default-shaper executable profile separately from optional complex-script profiles. HarfRust 0.12 has no script-level Cargo feature gates, so isolating Arabic/Indic/Khmer and related specialized shaper code would require an upstream feature design or a narrowly maintained fork. CJK/default OpenType shaping, Unicode line breaking, emoji grapheme behavior, and font-local GSUB/GPOS data must not be conflated with those specialized code paths. diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index eca8014f..e5059f87 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -28,6 +28,7 @@ mod state; pub(crate) mod transport; pub mod ordered_plan; +mod plan_draw; pub mod plan_input; mod plan_packing; pub mod policy; diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index f0efa314..ad6e32ed 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -8,6 +8,7 @@ use alloc::vec::Vec; use core::mem; use super::{ + plan_draw::{GlyphDraw, PlanDrawError, push_glyph_draw}, plan_input::{ PlanInputError, draw_fields_compatible, indexed_span_bounds, span_bounds, validate_glyph, validate_input, @@ -24,12 +25,15 @@ use super::{ }, render_plan::{ BUFFER_ORDERED_DIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, - PRIMITIVE_GLYPH, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, - RESOURCE_ACTION_RETAIN, RETIRE_BUFFER, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, - ResourceRecord, RetirementRecord, + PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, + RETIRE_BUFFER, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, + RetirementRecord, }, }; +#[cfg(test)] +use super::render_plan::PRIMITIVE_GLYPH; + pub use super::plan_input::{PlanGlyph as OrderedGlyph, PlanInput as OrderedPlanInput}; const NONE: u32 = u32::MAX; @@ -74,6 +78,15 @@ impl From for OrderedPlanError { } } +impl From for OrderedPlanError { + fn from(error: PlanDrawError) -> Self { + match error { + PlanDrawError::AllocationFailed => Self::AllocationFailed, + PlanDrawError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct BatchKey { technique: TechniqueId, @@ -1149,61 +1162,40 @@ impl OrderedPlanCompiler { && resource.generation == first.resource_generation }) .ok_or(OrderedPlanError::InvalidResource)?; - let primitive_start = self.primitives.len(); - reserve(&mut self.primitives, 1)?; - self.primitives.push(PrimitiveRecord { - id: first.stable_id, - kind: PRIMITIVE_GLYPH, - technique_id: first.technique.0, - resource_id: first.resource_id, - resource_generation: first.resource_generation, - program_id: batch.state.key.program_id, - program_variant: first.program_variant, - record_count: count, - buffer_id: batch.buffer_ids[0], - record_index: first_slot, - logical_order: u32::try_from(input_index) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, - clip_id: first.clip_id, - semantic_id: if context.input.glyphs[input_index..end] - .iter() - .all(|glyph| glyph.semantic_id == first.semantic_id) - { - first.semantic_id - } else { - 0 - }, - inline_start, - block_start, - inline_extent, - block_extent, - ..PrimitiveRecord::default() - }); - reserve(&mut self.draws, 1)?; - self.draws.push(DrawRecord { - id: first.stable_id, - program_id: batch.state.key.program_id, - program_variant: first.program_variant, - material_id: if split_material { first.material_id } else { 0 }, - clip_id: first.clip_id, - depth_key: first.depth_key, - transform_id: if split_transform { - first.transform_id - } else { - 0 + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + record_count: count, + buffer_id: batch.buffer_ids[0], + record_index: first_slot, + logical_order: input_index, + semantic_id: if context.input.glyphs[input_index..end] + .iter() + .all(|glyph| glyph.semantic_id == first.semantic_id) + { + first.semantic_id + } else { + 0 + }, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: batch.state.buffer_start, + buffer_count: u32::from(batch.state.buffer_count), + resource_start, + indirect: false, + program_id: batch.state.key.program_id, }, - primitive_start: u32::try_from(primitive_start) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, - primitive_count: 1, - buffer_start: batch.state.buffer_start, - buffer_count: u32::from(batch.state.buffer_count), - resource_start: u32::try_from(resource_start) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, - resource_count: 1, - order_token: u32::try_from(input_index) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, - ..DrawRecord::default() - }); + )?; input_index = end; } Ok(()) @@ -1264,53 +1256,34 @@ impl OrderedPlanCompiler { } else { 0 }; - let primitive_start = self.primitives.len(); - reserve(&mut self.primitives, 1)?; - self.primitives.push(PrimitiveRecord { - id: first.stable_id, - kind: PRIMITIVE_GLYPH, - technique_id: first.technique.0, - resource_id: first.resource_id, - resource_generation: first.resource_generation, - program_id: batch.state.key.program_id, - program_variant: first.program_variant, - record_count: count, - buffer_id: batch.buffer_ids[0], - record_index: u32::try_from(start) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, - logical_order: instances[start].input_index, - clip_id: first.clip_id, - semantic_id, - inline_start, - block_start, - inline_extent, - block_extent, - ..PrimitiveRecord::default() - }); - reserve(&mut self.draws, 1)?; - self.draws.push(DrawRecord { - id: first.stable_id, - program_id: batch.state.key.program_id, - program_variant: first.program_variant, - material_id: if split_material { first.material_id } else { 0 }, - clip_id: first.clip_id, - depth_key: first.depth_key, - transform_id: if split_transform { - first.transform_id - } else { - 0 + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + record_count: count, + buffer_id: batch.buffer_ids[0], + record_index: u32::try_from(start) + .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, + logical_order: first_input, + semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: batch.state.buffer_start, + buffer_count: u32::from(batch.state.buffer_count), + resource_start, + indirect: false, + program_id: batch.state.key.program_id, }, - primitive_start: u32::try_from(primitive_start) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, - primitive_count: 1, - buffer_start: batch.state.buffer_start, - buffer_count: u32::from(batch.state.buffer_count), - resource_start: u32::try_from(resource_start) - .map_err(|_| OrderedPlanError::ArithmeticOverflow)?, - resource_count: 1, - order_token: instances[start].input_index, - ..DrawRecord::default() - }); + )?; start = end; } } diff --git a/packages/text/rust/shaper/src/engine/plan_draw.rs b/packages/text/rust/shaper/src/engine/plan_draw.rs new file mode 100644 index 00000000..78cbcc32 --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_draw.rs @@ -0,0 +1,111 @@ +//! Shared glyph primitive and draw-record emission. + +use alloc::vec::Vec; + +use super::{ + plan_input::PlanGlyph, + render_plan::{DrawRecord, PRIMITIVE_GLYPH, PrimitiveRecord}, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlanDrawError { + AllocationFailed, + ArithmeticOverflow, +} + +#[derive(Clone, Copy)] +pub struct GlyphDraw { + pub glyph: PlanGlyph, + pub program_id: u32, + pub record_count: u16, + pub buffer_id: u32, + pub record_index: u32, + pub logical_order: usize, + pub semantic_id: u32, + pub inline_start: f32, + pub block_start: f32, + pub inline_extent: f32, + pub block_extent: f32, + pub material_id: u32, + pub transform_id: u32, + pub buffer_start: u32, + pub buffer_count: u32, + pub resource_start: usize, + pub indirect: bool, +} + +/// This deliberately remains one out-of-line implementation. Both storage planners emit the +/// same command records after resolving their different physical address spaces. +#[inline(never)] +pub fn push_glyph_draw( + primitives: &mut Vec, + draws: &mut Vec, + emission: GlyphDraw, +) -> Result<(), PlanDrawError> { + let primitive_start = + u32::try_from(primitives.len()).map_err(|_| PlanDrawError::ArithmeticOverflow)?; + let logical_order = + u32::try_from(emission.logical_order).map_err(|_| PlanDrawError::ArithmeticOverflow)?; + let resource_start = + u32::try_from(emission.resource_start).map_err(|_| PlanDrawError::ArithmeticOverflow)?; + let indirect_offset = if emission.indirect { + emission + .record_index + .checked_mul(4) + .ok_or(PlanDrawError::ArithmeticOverflow)? + } else { + 0 + }; + + primitives + .try_reserve(1) + .map_err(|_| PlanDrawError::AllocationFailed)?; + primitives.push(PrimitiveRecord { + id: emission.glyph.stable_id, + kind: PRIMITIVE_GLYPH, + technique_id: emission.glyph.technique.0, + resource_id: emission.glyph.resource_id, + resource_generation: emission.glyph.resource_generation, + program_id: emission.program_id, + program_variant: emission.glyph.program_variant, + record_count: emission.record_count, + buffer_id: emission.buffer_id, + record_index: emission.record_index, + logical_order, + clip_id: emission.glyph.clip_id, + semantic_id: emission.semantic_id, + inline_start: emission.inline_start, + block_start: emission.block_start, + inline_extent: emission.inline_extent, + block_extent: emission.block_extent, + ..PrimitiveRecord::default() + }); + + draws + .try_reserve(1) + .map_err(|_| PlanDrawError::AllocationFailed)?; + draws.push(DrawRecord { + id: emission.glyph.stable_id, + program_id: emission.program_id, + program_variant: emission.glyph.program_variant, + material_id: emission.material_id, + clip_id: emission.glyph.clip_id, + depth_key: emission.glyph.depth_key, + transform_id: emission.transform_id, + primitive_start, + primitive_count: 1, + buffer_start: emission.buffer_start, + buffer_count: emission.buffer_count, + resource_start, + resource_count: 1, + order_token: logical_order, + indirect_buffer_id: if emission.indirect { + emission.buffer_id + } else { + 0 + }, + indirect_offset, + ..DrawRecord::default() + }); + Ok(()) +} diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 2bd00fd8..875815a3 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -9,6 +9,7 @@ use alloc::vec::Vec; use core::mem; use super::{ + plan_draw::{GlyphDraw, PlanDrawError, push_glyph_draw}, plan_input::{ PlanInputError, draw_fields_compatible, indexed_span_bounds, span_bounds, validate_glyph, validate_input, @@ -26,7 +27,7 @@ use super::{ }, render_plan::{ BUFFER_STABLE_INDIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, - POLICY_BUFFER_ORDER, PRIMITIVE_GLYPH, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, + POLICY_BUFFER_ORDER, PatchRecord, PrimitiveRecord, RESOURCE_ACTION_CREATE, RESOURCE_ACTION_RETAIN, RETIRE_BUFFER, RETIRE_RESOURCE, RETIRE_SLOT_RANGE, RenderPlanView, ResourceRecord, RetirementRecord, }, @@ -80,6 +81,15 @@ impl From for StablePlanError { } } +impl From for StablePlanError { + fn from(error: PlanDrawError) -> Self { + match error { + PlanDrawError::AllocationFailed => Self::AllocationFailed, + PlanDrawError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} + impl From for StablePlanError { fn from(error: StablePoolError) -> Self { match error { @@ -1426,65 +1436,40 @@ impl StablePlanCompiler { && resource.generation == first.resource_generation }) .ok_or(StablePlanError::InvalidResource)?; - let primitive_start = self.primitives.len(); - reserve(&mut self.primitives, 1)?; - self.primitives.push(PrimitiveRecord { - id: first.stable_id, - kind: PRIMITIVE_GLYPH, - technique_id: first.technique.0, - resource_id: first.resource_id, - resource_generation: first.resource_generation, - program_id: batch.key.program_id, - program_variant: first.program_variant, - record_count: count, - buffer_id: pending.order_buffer_id, - record_index: first_record, - logical_order: u32::try_from(input_index) - .map_err(|_| StablePlanError::ArithmeticOverflow)?, - clip_id: first.clip_id, - semantic_id: if context.input.glyphs[input_index..end] - .iter() - .all(|glyph| glyph.semantic_id == first.semantic_id) - { - first.semantic_id - } else { - 0 - }, - inline_start, - block_start, - inline_extent, - block_extent, - ..PrimitiveRecord::default() - }); - reserve(&mut self.draws, 1)?; - self.draws.push(DrawRecord { - id: first.stable_id, - program_id: batch.key.program_id, - program_variant: first.program_variant, - material_id: if split_material { first.material_id } else { 0 }, - clip_id: first.clip_id, - depth_key: first.depth_key, - transform_id: if split_transform { - first.transform_id - } else { - 0 + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + program_id: batch.key.program_id, + record_count: count, + buffer_id: pending.order_buffer_id, + record_index: first_record, + logical_order: input_index, + semantic_id: if context.input.glyphs[input_index..end] + .iter() + .all(|glyph| glyph.semantic_id == first.semantic_id) + { + first.semantic_id + } else { + 0 + }, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: pending.buffer_start, + buffer_count: u32::from(pending.buffer_count), + resource_start, + indirect: true, }, - primitive_start: u32::try_from(primitive_start) - .map_err(|_| StablePlanError::ArithmeticOverflow)?, - primitive_count: 1, - buffer_start: pending.buffer_start, - buffer_count: u32::from(pending.buffer_count), - resource_start: u32::try_from(resource_start) - .map_err(|_| StablePlanError::ArithmeticOverflow)?, - resource_count: 1, - order_token: u32::try_from(input_index) - .map_err(|_| StablePlanError::ArithmeticOverflow)?, - indirect_buffer_id: pending.order_buffer_id, - indirect_offset: first_record - .checked_mul(4) - .ok_or(StablePlanError::ArithmeticOverflow)?, - ..DrawRecord::default() - }); + )?; input_index = end; } Ok(()) @@ -1545,56 +1530,33 @@ impl StablePlanCompiler { } else { 0 }; - let primitive_start = self.primitives.len(); - reserve(&mut self.primitives, 1)?; - self.primitives.push(PrimitiveRecord { - id: first.stable_id, - kind: PRIMITIVE_GLYPH, - technique_id: first.technique.0, - resource_id: first.resource_id, - resource_generation: first.resource_generation, - program_id: key.program_id, - program_variant: first.program_variant, - record_count: count, - buffer_id: pending.order_buffer_id, - record_index: first_record, - logical_order: input_indices[start], - clip_id: first.clip_id, - semantic_id, - inline_start, - block_start, - inline_extent, - block_extent, - ..PrimitiveRecord::default() - }); - reserve(&mut self.draws, 1)?; - self.draws.push(DrawRecord { - id: first.stable_id, - program_id: key.program_id, - program_variant: first.program_variant, - material_id: if split_material { first.material_id } else { 0 }, - clip_id: first.clip_id, - depth_key: first.depth_key, - transform_id: if split_transform { - first.transform_id - } else { - 0 + push_glyph_draw( + &mut self.primitives, + &mut self.draws, + GlyphDraw { + glyph: first, + program_id: key.program_id, + record_count: count, + buffer_id: pending.order_buffer_id, + record_index: first_record, + logical_order: first_input, + semantic_id, + inline_start, + block_start, + inline_extent, + block_extent, + material_id: if split_material { first.material_id } else { 0 }, + transform_id: if split_transform { + first.transform_id + } else { + 0 + }, + buffer_start: pending.buffer_start, + buffer_count: u32::from(pending.buffer_count), + resource_start, + indirect: true, }, - primitive_start: u32::try_from(primitive_start) - .map_err(|_| StablePlanError::ArithmeticOverflow)?, - primitive_count: 1, - buffer_start: pending.buffer_start, - buffer_count: u32::from(pending.buffer_count), - resource_start: u32::try_from(resource_start) - .map_err(|_| StablePlanError::ArithmeticOverflow)?, - resource_count: 1, - order_token: input_indices[start], - indirect_buffer_id: pending.order_buffer_id, - indirect_offset: first_record - .checked_mul(4) - .ok_or(StablePlanError::ArithmeticOverflow)?, - ..DrawRecord::default() - }); + )?; start = end; } } @@ -1797,9 +1759,9 @@ fn stable_active_buffers( .buffer_dependency_masks(capability_set, program.technique, program.variant) .ok_or(StablePlanError::ProgramMissing)?; let mut active = 0_u32; - for write in writes + for write in writes_in_range(writes, changed) .iter() - .filter(|write| write.changed && (changed.start..changed.end).contains(&write.slot)) + .filter(|write| write.changed) { let mask = input .semantic_change_masks @@ -1818,6 +1780,12 @@ fn stable_active_buffers( Ok(active) } +fn writes_in_range(writes: &[SlotWrite], range: RecordRange) -> &[SlotWrite] { + let start = writes.partition_point(|write| write.slot < range.start); + let end = writes.partition_point(|write| write.slot < range.end); + &writes[start..end] +} + fn order_schema() -> BufferSchema { BufferSchema::packed( BufferId(POLICY_BUFFER_ORDER), @@ -1855,6 +1823,31 @@ mod tests { const CAPABILITY: CapabilitySetId = CapabilitySetId(1); const TECHNIQUE: TechniqueId = TechniqueId(1); + #[test] + fn dependency_queries_slice_only_the_requested_sorted_slots() { + let writes = [ + SlotWrite { + slot: 2, + input_index: 20, + changed: true, + }, + SlotWrite { + slot: 4, + input_index: 40, + changed: false, + }, + SlotWrite { + slot: 7, + input_index: 70, + changed: true, + }, + ]; + + let selected = writes_in_range(&writes, RecordRange { start: 3, end: 7 }); + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].slot, 4); + } + #[test] fn insertion_writes_one_new_physical_record_and_one_order_chunk() { let policy = policy(false); From caf5d093546d8a0b2b8b8fd52fd0076f647de775 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 22:33:25 -0400 Subject: [PATCH 114/128] refactor(text): port external raster to rust plans --- docs/log.md | 8 ++ docs/packages/benchmarks.md | 2 +- docs/packages/glyph-example-raster.md | 17 +-- docs/packages/text.md | 2 +- packages/glyph-example-raster/src/index.ts | 7 +- packages/glyph-example-raster/src/raster.ts | 118 +--------------- packages/glyph-example-raster/src/three.ts | 2 +- .../tests/glyph-example.test.ts | 126 +++++------------- 8 files changed, 58 insertions(+), 224 deletions(-) diff --git a/docs/log.md b/docs/log.md index 39db5cd1..1d628bbc 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Ported the external raster proof off the deleted host packer** — The private glyph-example consumer now uses the + same public boundary required of third parties: its portable technique owns only identity, decode, retained resource, + and disposal, while its Three registration supplies the declarative policy program and material realization. Removed + its stale selector, binding object, canonical storage allocator, TypeScript glyph writer, paint hook, and exported + legacy types. The focused compiled-Wasm lifecycle verifies Rust-produced sizes and colors before checking retained + draw and geometry identity; no test-only core API or compatibility contract was added. All six package tests and its + TypeScript, lint, and formatting gates pass. + - **Shared the compiled draw emitter and removed a quadratic stable-plan scan** — A symbol-bearing `-Oz` build attributes 33.3 KiB of optimized function bodies to ordered planning and 50.1 KiB to stable planning, while confirming that the planners retain different storage, order-buffer, and retirement work. Their identical final primitive/draw record diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 20b8af99..d260b042 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:4a244a0109c229d660d5206d39478e22299c5e4a625d065e97e9d51fca7f1c1a' +source_digest: 'sha256:09fdf6c60395ef49e00d7ff91f81989304ed23b1f1426716451ba6cc0f8ce4d6' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/glyph-example-raster.md b/docs/packages/glyph-example-raster.md index 5346dcf5..42e7086e 100644 --- a/docs/packages/glyph-example-raster.md +++ b/docs/packages/glyph-example-raster.md @@ -5,7 +5,7 @@ description: Proves the published raster and baker extension boundary with a pri resource: ../../packages/glyph-example-raster workspace_package: '@pmndrs/text-glyph-example-raster' documentation_type: reference -source_digest: 'sha256:0a0b7bd0a74ec262d5b4722cc43e5a1f8da837e9fa72b9c1d9112f119f1bb445' +source_digest: 'sha256:d3fc6c1fc5a3ff728ebf9ac8bb9112c4b0e1e9fc4f9304376ded7ecef41a1764' tags: [package, raster, extension-proof, threejs, tsl] sources: - id: manifest @@ -28,7 +28,7 @@ sources: title: Dual-backend product rendering probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-09T06:36:53Z' + at: '2026-08-10T02:32:26Z' --- # Package reference: `@pmndrs/text-glyph-example-raster` @@ -38,9 +38,9 @@ Status: ✅ Milestone 10.4 external extension proof This private workspace package is a consumer proof, not a fourth recommended production raster. It imports only published `@pmndrs/text` entry points and its own pinned Three.js dependency. It owns the literal `glyphExample` kind, companion extension and descriptor, deterministic baker, standalone-valid GLB framing, embedded or authenticated external RGBA glyph -records, decoder validation, runtime baker, TSL material, retained instance storage, dirty upload policy, overflow replacement, -paragraph/local-run render-order inheritance, abort behavior, and disposal. A source boundary test rejects imports from -core internals or the three first-party raster and baker subpaths. +records, decoder validation, runtime baker, declarative Rust packing policy, TSL material, paragraph/local-run render-order +inheritance, abort behavior, and disposal. Rust owns retained instance storage, dirty-range publication, and overflow handling. +A source boundary test rejects imports from core internals or the Three first-party raster and baker subpaths. The technique makes the proof observable by assigning each source-local glyph ID a deterministic color and drawing a framed em-relative diagnostic cell at the position produced by core shaping and paragraph layout. Its visual output is deliberately @@ -49,15 +49,16 @@ The external lane authenticates the companion GLB and its separate record payloa resolvers; the embedded lane proves recursive `BufferView` rebasing through the public Node composition host. The package now supplies both halves of the Rust render-plan boundary separately. `glyphExample` is a portable -`defineRasterTechnique` that decodes and selects one shared resource while importing no renderer. +`defineRasterTechnique` that owns identity, decoding, one shared resource, and disposal while importing no renderer or +instance-packing contract. `@pmndrs/text-glyph-example-raster/three` registers a static policy program through public `registerThreeRasterPlanProgram`, so nothing in `@pmndrs/text` names this package. The policy describes the exact Rust inputs, buffers, scalar operations, and storage/draw keys. A cold compiler lowers validated glyph colors and inset data into one font binding; a renderer factory consumes the resulting buffers to construct the TSL material. The package no longer owns a `ParagraphBatchTarget`, target revision, slack planner, dirty-range upload loop, or mesh transaction. Focused tests cover deterministic bytes, public Node bake, standalone companion validation, external resource -resolution, abort-before-decode, selection and binding identity, plus a compiled-Wasm public `Text` lifecycle that -observes Rust-packed buffers and retained draw/geometry identity. +resolution, abort-before-decode, plus a compiled-Wasm public `Text` lifecycle that verifies Rust-packed sizes and colors +and observes retained draw/geometry identity. No test reconstructs the removed TypeScript selector, storage, or writer. The hardware-browser target uses the public source-font fallback, package runtime baker, the target-v1 `FontLoader`, public `Text` and `TextGroup`, warm matrix-lifecycle publication, TSL compilation, draw, asynchronous render-target readback, and diff --git a/docs/packages/text.md b/docs/packages/text.md index 6eb474bc..a30faaa6 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:1ed5591cfb6b3ccef72987d9c9cac3b4f7dfa8deee20b46bf82498992f879c57' +source_digest: 'sha256:fbbfd35177e290df7f279315bcc89af03064118259221906a3bc14120dae33a1' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/packages/glyph-example-raster/src/index.ts b/packages/glyph-example-raster/src/index.ts index 8e5a4f84..fbd5ad03 100644 --- a/packages/glyph-example-raster/src/index.ts +++ b/packages/glyph-example-raster/src/index.ts @@ -7,9 +7,4 @@ export { type GlyphExampleDescriptor, type GlyphExampleOptions, } from './contract.js'; -export { - glyphExample, - type GlyphExampleBinding, - type GlyphExampleData, - type GlyphExampleGlyphBatchStorage, -} from './raster.js'; +export { glyphExample, type GlyphExampleData } from './raster.js'; diff --git a/packages/glyph-example-raster/src/raster.ts b/packages/glyph-example-raster/src/raster.ts index 68ab60a2..25d81509 100644 --- a/packages/glyph-example-raster/src/raster.ts +++ b/packages/glyph-example-raster/src/raster.ts @@ -1,9 +1,5 @@ import type { - GlyphPaint, - GlyphRange, JsonValue, - RasterGlyphInput, - RasterGlyphWriteInput, RasterResourceId, RasterResourceSource, RasterTechnique, @@ -27,39 +23,23 @@ import { const RECORD_STRIDE = 4; -/** - * The physical batch payload this technique needs while packing. Only the inset varies per decoded raster, so the - * decoded data owns one frozen binding and every selection returns that same object identity. - */ -export interface GlyphExampleBinding { - readonly inset: number; -} - export interface GlyphExampleData { readonly resource: RasterResourceId; - readonly binding: GlyphExampleBinding; + readonly inset: number; readonly colors: Uint8Array; readonly glyphCount: number; } -export interface GlyphExampleGlyphBatchStorage { - readonly origins: Float32Array; - readonly sizes: Float32Array; - readonly colors: Float32Array; -} - /** - * A third-party portable raster technique. It owns decoding, selection, and canonical instance packing and never - * mentions a renderer; the Three program in `./three.js` turns the packed storage into draws. + * A third-party portable raster technique. It owns identity, decoding, and resource lifetime and never mentions a + * renderer; the Three program in `./three.js` declares the Rust packing policy and realizes its command-buffer draws. */ export const glyphExample: RasterTechnique< RasterTechniqueId & 'studio.glyph-example', typeof GLYPH_EXAMPLE_KIND, GlyphExampleOptions | undefined, GlyphExampleDescriptor, - GlyphExampleData, - GlyphExampleBinding, - GlyphExampleGlyphBatchStorage + GlyphExampleData > = defineRasterTechnique({ id: 'studio.glyph-example', kind: GLYPH_EXAMPLE_KIND, @@ -82,104 +62,16 @@ export const glyphExample: RasterTechnique< } return { resource: defineRasterResourceId(`studio.glyph-example/${font.shapingHash}/${raster.rasterKey}`), - binding: Object.freeze({ inset: extension.descriptor.inset }), + inset: extension.descriptor.inset, colors, glyphCount: font.glyphCount, }; }, - select(input: RasterGlyphInput) { - assertGlyphId(input.data, input.glyphId); - if (!Number.isFinite(input.fontSize) || input.fontSize <= 0) { - throw new TypeError('glyph-example font sizes must be positive finite values'); - } - return { resource: input.data.resource, pipelineVariant: 0, binding: input.data.binding }; - }, - createStorage(capacity: number): GlyphExampleGlyphBatchStorage { - if (!Number.isSafeInteger(capacity) || capacity < 0) { - throw new RangeError('glyph-example storage capacity must be a non-negative safe integer'); - } - return { - origins: new Float32Array(capacity * 2), - sizes: new Float32Array(capacity * 2), - colors: new Float32Array(capacity * 4), - }; - }, - writeStorage( - storage: GlyphExampleGlyphBatchStorage, - range: GlyphRange, - input: RasterGlyphWriteInput, - ): void { - assertWriteRange(storage, range, input.glyphs.length); - if (input.binding !== input.data.binding) { - throw new TypeError('glyph-example write binding does not belong to its data'); - } - for (let index = 0; index < input.glyphs.length; index += 1) { - writeGlyph(storage, range.start + index, input.data, input.binding, input.glyphs[index]!); - } - }, - validatePaint(paint: GlyphPaint): void { - for (const entry of paint.palette) { - if (entry.color.length !== 4 || entry.color.some((value) => !Number.isFinite(value) || value < 0 || value > 1)) { - throw new TypeError('glyph-example fill color must contain four finite linear values in [0, 1]'); - } - if (entry.outline !== undefined || entry.shadow !== undefined) { - throw new TypeError('glyph-example supports fill color and opacity only'); - } - } - }, dispose(data: GlyphExampleData): void { data.colors.fill(0); }, }); -function writeGlyph( - storage: GlyphExampleGlyphBatchStorage, - instance: number, - data: GlyphExampleData, - binding: GlyphExampleBinding, - glyph: RasterGlyphInput, -): void { - assertGlyphId(data, glyph.glyphId); - if (!Number.isFinite(glyph.fontSize) || glyph.fontSize <= 0) { - throw new TypeError('glyph-example font sizes must be positive finite values'); - } - if (!Number.isFinite(glyph.originX) || !Number.isFinite(glyph.originY)) { - throw new TypeError('glyph-example positions must be finite values'); - } - if (glyph.paint.color.length !== 4) throw new TypeError('glyph-example paint must resolve four linear components'); - const inset = binding.inset * glyph.fontSize; - const vectorOffset = instance * 2; - storage.origins[vectorOffset] = glyph.originX + inset; - storage.origins[vectorOffset + 1] = glyph.originY - glyph.fontSize * 0.8 + inset; - storage.sizes[vectorOffset] = Math.max(glyph.fontSize * 0.05, glyph.fontSize * 0.65 - inset * 2); - storage.sizes[vectorOffset + 1] = Math.max(glyph.fontSize * 0.05, glyph.fontSize - inset * 2); - const record = glyph.glyphId * RECORD_STRIDE; - const colorOffset = instance * 4; - for (let channel = 0; channel < 4; channel += 1) { - storage.colors[colorOffset + channel] = (data.colors[record + channel]! / 255) * glyph.paint.color[channel]!; - } -} - -function assertGlyphId(data: GlyphExampleData, glyphId: number): void { - if (!Number.isSafeInteger(glyphId) || glyphId < 0 || glyphId >= data.glyphCount) { - throw new RangeError('glyph-example layout references an unavailable glyph'); - } -} - -function assertWriteRange(storage: GlyphExampleGlyphBatchStorage, range: GlyphRange, glyphCount: number): void { - const capacity = storage.colors.length / 4; - if ( - !Number.isSafeInteger(range.start) || - !Number.isSafeInteger(range.count) || - range.start < 0 || - range.count < 0 || - range.count !== glyphCount || - range.start > capacity - range.count - ) { - throw new RangeError('glyph-example storage write range is outside its capacity'); - } -} - function decodeExtension( font: RegisteredFont, raster: RegisteredRaster, diff --git a/packages/glyph-example-raster/src/three.ts b/packages/glyph-example-raster/src/three.ts index 7fa9c552..6161e37c 100644 --- a/packages/glyph-example-raster/src/three.ts +++ b/packages/glyph-example-raster/src/three.ts @@ -51,7 +51,7 @@ registerThreeRasterPlanProgram({ glyphF32: { rows: data.glyphCount, fields: [ - () => data.binding.inset, + () => data.inset, (row) => data.colors[row * 4]! / 255, (row) => data.colors[row * 4 + 1]! / 255, (row) => data.colors[row * 4 + 2]! / 255, diff --git a/packages/glyph-example-raster/tests/glyph-example.test.ts b/packages/glyph-example-raster/tests/glyph-example.test.ts index 0d9bdda6..d731d9ca 100644 --- a/packages/glyph-example-raster/tests/glyph-example.test.ts +++ b/packages/glyph-example-raster/tests/glyph-example.test.ts @@ -5,11 +5,8 @@ import { join } from 'node:path'; import { FontRegistry, - createRuntimeShaper, createTextRuntime, rasterBake, - type GlyphPaint, - type RasterGlyphInput, type RasterKey, type RasterResolverContext, type RasterResourceResolverContext, @@ -22,7 +19,7 @@ import * as THREE from 'three/webgpu'; import { afterEach, describe, expect, test, vi } from 'vitest'; import glyphExampleBaker from '../src/baker.js'; -import { GLYPH_EXAMPLE_KIND, glyphExample, glyphExampleDescriptor, type GlyphExampleData } from '../src/index.js'; +import { GLYPH_EXAMPLE_KIND, glyphExample, glyphExampleDescriptor } from '../src/index.js'; import '../src/three.js'; const source = new URL('../../../apps/benchmarks/fixtures/fonts/inter-v4.1/Inter-Regular.ttf', import.meta.url); @@ -69,7 +66,7 @@ describe('public external raster proof', () => { const data = await glyphExample.decode(font, raster); expect(raster.kind).toBe(GLYPH_EXAMPLE_KIND); expect(data.colors.byteLength).toBe(font.glyphCount * 4); - expect(data.binding.inset).toBe(glyphExampleDescriptor({ paletteSeed: 7 }).inset); + expect(data.inset).toBe(glyphExampleDescriptor({ paletteSeed: 7 }).inset); expect(resolve).toHaveBeenCalledOnce(); expect(resolveResource).toHaveBeenCalledOnce(); expect(resolve.mock.calls[0]?.[0].reference.kind).toBe(GLYPH_EXAMPLE_KIND); @@ -80,67 +77,6 @@ describe('public external raster proof', () => { } }); - test('selects one shared resource and packs canonical instances without a renderer', async () => { - const { font, data } = await loadEmbedded(); - try { - const selection = glyphExample.select(glyph(data, 1)); - expect(selection).toEqual({ resource: data.resource, pipelineVariant: 0, binding: data.binding }); - expect(glyphExample.select(glyph(data, 2))?.binding).toBe(data.binding); - - const storage = glyphExample.createStorage(4); - glyphExample.writeStorage( - storage, - { start: 1, count: 2 }, - { data, binding: data.binding, glyphs: [glyph(data, 1), glyph(data, 2)] }, - ); - // Canonical storage is Float32Array, so every expectation compares at single precision. - const inset = data.binding.inset * 16; - expect(Array.from(storage.origins.subarray(0, 2))).toEqual([0, 0]); - expectClose(Array.from(storage.origins.subarray(2, 4)), [inset, 12 - 16 * 0.8 + inset]); - expectClose(Array.from(storage.sizes.subarray(2, 4)), [16 * 0.65 - inset * 2, 16 - inset * 2]); - expectClose(Array.from(storage.colors.subarray(4, 8)), glyphColor(data, 1)); - expectClose(Array.from(storage.colors.subarray(8, 12)), glyphColor(data, 2)); - - expect(() => - glyphExample.writeStorage( - storage, - { start: 0, count: 1 }, - { data, binding: { ...data.binding }, glyphs: [glyph(data, 1)] }, - ), - ).toThrow(/binding does not belong/); - expect(() => - glyphExample.writeStorage( - storage, - { start: 4, count: 1 }, - { data, binding: data.binding, glyphs: [glyph(data, 1)] }, - ), - ).toThrow(/outside its capacity/); - expect(() => glyphExample.select(glyph(data, font.glyphCount))).toThrow(/unavailable glyph/); - - glyphExample.dispose(data); - } finally { - font.dispose(); - } - }); - - test('rejects paint the package cannot render', async () => { - const { font, data } = await loadEmbedded(); - try { - expect(() => - glyphExample.validatePaint?.({ - paintIndices: Uint16Array.of(0), - palette: [{ color: [1, 1, 1, 1], outline: { color: [0, 0, 0, 1], width: 1 } }], - }), - ).toThrow(/fill color and opacity only/); - expect(() => - glyphExample.validatePaint?.({ paintIndices: Uint16Array.of(0), palette: [{ color: [1, 1, 2, 1] }] }), - ).toThrow(/four finite linear values/); - glyphExample.dispose(data); - } finally { - font.dispose(); - } - }); - test('honors cancellation before decoding and leaves no decoded data', async () => { const baked = await bakeFixture({ artifact: 'embedded', pages: 'embedded' }); const core = baked.execution.outputs.find(({ role }) => role === 'font'); @@ -162,11 +98,10 @@ describe('public external raster proof', () => { const core = baked.execution.outputs.find(({ role }) => role === 'font'); assert.ok(core); const registry = new FontRegistry(); - const shaper = await createRuntimeShaper({ + const runtime = await createTextRuntime({ registry, wasm: await readFile(new URL('../../text/dist/text_shaper.wasm', import.meta.url)), }); - const runtime = await createTextRuntime({ registry, shaper }); const font = await runtime.loadFont({ input: { baked: dataUrl(await readFile(core.file)) }, raster: { technique: glyphExample, options: { paletteSeed: 7 } }, @@ -187,6 +122,20 @@ describe('public external raster proof', () => { expect(geometry.getAttribute('_pmndrsText_2')).toBeDefined(); expect(geometry.getAttribute('_pmndrsText_3')).toBeDefined(); expect(geometry.getAttribute('_pmndrsText_15')).toBeDefined(); + expect(geometry.instanceCount).toBeGreaterThan(0); + const sizes = geometry.getAttribute('_pmndrsText_2'); + const expectedWidth = Math.max(48 * 0.05, 48 * 0.65 - font.data.inset * 48 * 2); + const expectedHeight = Math.max(48 * 0.05, 48 - font.data.inset * 48 * 2); + for (let instance = 0; instance < geometry.instanceCount; instance += 1) { + expect(sizes.getX(instance)).toBeCloseTo(expectedWidth, 5); + expect(sizes.getY(instance)).toBeCloseTo(expectedHeight, 5); + } + const colors = geometry.getAttribute('_pmndrsText_3'); + for (let instance = 0; instance < geometry.instanceCount; instance += 1) { + expect(font.data.colors.some((_, offset) => glyphColorMatches(font.data.colors, offset, colors, instance))).toBe( + true, + ); + } text.text = 'PLUGIN UPDATE'; scene.updateMatrixWorld(); @@ -201,15 +150,6 @@ describe('public external raster proof', () => { }); }); -async function loadEmbedded(): Promise<{ readonly font: RegisteredFont; readonly data: GlyphExampleData }> { - const baked = await bakeFixture({ artifact: 'embedded', pages: 'embedded' }); - const core = baked.execution.outputs.find(({ role }) => role === 'font'); - assert.ok(core); - const font = await new FontRegistry().registerAsset(await readFile(core.file)); - const raster = await font.loadRaster(rasterSelection(font)); - return { font, data: await glyphExample.decode(font, raster) }; -} - /** The baked artifact advertises its own raster key, so the test never reimplements key derivation. */ function rasterSelection(font: RegisteredFont): { readonly rasterKey: RasterKey; readonly kind: 'glyphExample' } { const reference = font.rasterReferences.find(({ kind }) => kind === GLYPH_EXAMPLE_KIND); @@ -231,23 +171,21 @@ async function bakeFixture(packaging: { }); } -function glyph(data: GlyphExampleData, glyphId: number): RasterGlyphInput { - return { data, glyphId, fontSize: 16, originX: 0, originY: 12, rasterPixelRatio: 1, paint: paint().palette[0]! }; -} - -function expectClose(actual: readonly number[], expected: readonly number[]): void { - expect(actual).toHaveLength(expected.length); - for (const [index, value] of expected.entries()) expect(actual[index]).toBeCloseTo(value, 6); -} - -function glyphColor(data: GlyphExampleData, glyphId: number): readonly number[] { - return Array.from(data.colors.subarray(glyphId * 4, glyphId * 4 + 4), (value) => value / 255); -} - -function paint(): GlyphPaint { - return { palette: [{ color: [1, 1, 1, 1] }], paintIndices: Uint16Array.of(0) }; -} - function dataUrl(bytes: Uint8Array): string { return `data:model/gltf-binary;base64,${Buffer.from(bytes).toString('base64')}`; } + +function glyphColorMatches( + records: Uint8Array, + offset: number, + attribute: THREE.BufferAttribute | THREE.InterleavedBufferAttribute, + instance: number, +): boolean { + if (offset % 4 !== 0 || offset > records.length - 4) return false; + return ( + Math.abs(attribute.getX(instance) - records[offset]! / 255) < 1e-6 && + Math.abs(attribute.getY(instance) - records[offset + 1]! / 255) < 1e-6 && + Math.abs(attribute.getZ(instance) - records[offset + 2]! / 255) < 1e-6 && + Math.abs(attribute.getW(instance) - records[offset + 3]! / 255) < 1e-6 + ); +} From 356b035654807d193a07664350cea4acba095a99 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 22:45:15 -0400 Subject: [PATCH 115/128] test(text): serialize shared artifact gates --- .../low-level/raster/slug-cpu-reference.test.ts | 11 ----------- docs/log.md | 6 ++++++ docs/packages/benchmarks.md | 4 ++-- docs/packages/text.md | 4 ++-- package.json | 2 +- .../text/tests/integration/font-binding-wire.test.mjs | 2 -- .../tests/integration/three-engine-runtime.test.mjs | 2 -- 7 files changed, 11 insertions(+), 20 deletions(-) diff --git a/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts b/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts index 94e54dcf..ea17078d 100644 --- a/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts +++ b/apps/benchmarks/src/benchmark/low-level/raster/slug-cpu-reference.test.ts @@ -108,17 +108,6 @@ function squareData(): SlugData { planeUnitsPerEm: 2048, records, pages: [page], - bindings: [ - { - page: 0, - curveWidth: page.curveWidth, - curveHeight: page.curveHeight, - headerWidth: page.headerWidth, - headerHeight: page.headerHeight, - referenceWidth: page.referenceWidth, - referenceHeight: page.referenceHeight, - }, - ], }; } diff --git a/docs/log.md b/docs/log.md index 1d628bbc..0daf2e5b 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-09 +- **Made application gates respect their shared build artifact dependency** — Root package checks already complete before + application checks, but the benchmark app rebuilds those runtime packages as part of its standalone contract. Running + application checks concurrently let that rebuild remove `packages/text/dist` while the R3F example authenticated its + freshly baked assets, intermittently hiding `bitmap_baker.wasm`. Root application checks now run serially; each app's + standalone check remains unchanged, and the ordering removes the filesystem race rather than adding a retry. + - **Ported the external raster proof off the deleted host packer** — The private glyph-example consumer now uses the same public boundary required of third parties: its portable technique owns only identity, decode, retained resource, and disposal, while its Three registration supplies the declarative policy program and material realization. Removed diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index d260b042..5970c43f 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:09fdf6c60395ef49e00d7ff91f81989304ed23b1f1426716451ba6cc0f8ce4d6' +source_digest: 'sha256:7123b715655e79fbe3f0fa5028e56897c0edf56f7bd4eb0f9a0c3ba1552ff5e3' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -205,7 +205,7 @@ sources: title: Realtime comparison product probe generated: by: openai-codex/gpt-5.6 - at: '2026-08-10T02:15:49Z' + at: '2026-08-10T02:40:43Z' --- # Package reference: `@pmndrs/text-benchmarks` diff --git a/docs/packages/text.md b/docs/packages/text.md index a30faaa6..699ff18b 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:fbbfd35177e290df7f279315bcc89af03064118259221906a3bc14120dae33a1' +source_digest: 'sha256:94a04c1dd7cf680fb0e340dd0e664059de5c93d1ff453f5057eb8dd670b4f3fb' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -55,7 +55,7 @@ sources: title: Three.js text API reference generated: by: openai-codex/gpt-5.6 - at: '2026-08-10T02:15:49Z' + at: '2026-08-10T02:40:43Z' --- # Package reference: `@pmndrs/text` diff --git a/package.json b/package.json index 04dc8ce1..31e7328a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "pnpm --filter @pmndrs/text-benchmarks dev", "build": "pnpm --filter './packages/*' build && pnpm --filter './apps/*' build", "test": "pnpm --filter './packages/*' --if-present test && pnpm --filter './apps/*' --if-present test", - "check": "tsc -p .claude/tsconfig.json && node --test .claude/hooks/sync-agent-config.test.ts && pnpm --filter './packages/*' check && pnpm --filter './apps/*' check && oxfmt --check .claude/hooks .claude/settings.json .claude/tsconfig.json package.json docs/log.md && ruby .agents/skills/open-knowledge-format/scripts/validate_okf.rb docs --workspace-root .", + "check": "tsc -p .claude/tsconfig.json && node --test .claude/hooks/sync-agent-config.test.ts && pnpm --filter './packages/*' check && pnpm --workspace-concurrency=1 --filter './apps/*' check && oxfmt --check .claude/hooks .claude/settings.json .claude/tsconfig.json package.json docs/log.md && ruby .agents/skills/open-knowledge-format/scripts/validate_okf.rb docs --workspace-root .", "scripts": "node apps/benchmarks/scripts/workflows.mts" }, "devDependencies": { diff --git a/packages/text/tests/integration/font-binding-wire.test.mjs b/packages/text/tests/integration/font-binding-wire.test.mjs index f53ce004..99609df2 100644 --- a/packages/text/tests/integration/font-binding-wire.test.mjs +++ b/packages/text/tests/integration/font-binding-wire.test.mjs @@ -69,7 +69,6 @@ async function fixture(name) { format: 'r8unorm', resource: defineRasterResourceId(`test.bitmap.${strikeIndex}.${pageIndex}`), })), - bindings: [], })), }; return { core, raster, loaded: { font: core, technique: bitmap, data } }; @@ -101,7 +100,6 @@ async function fixture(name) { ...page, resource: defineRasterResourceId(`test.slug.${pageIndex}`), })), - bindings: [], }; return { core, raster, loaded: { font: core, technique: slug, data } }; } diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index a3b48f4b..c5ba7322 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -81,7 +81,6 @@ test('Three coordinator shares shaping data across technique bindings and refere format: 'r8unorm', resource: defineRasterResourceId(`coordinator.bitmap.${strikeIndex}.${pageIndex}`), })), - bindings: [], })), }, disposed: false, @@ -130,7 +129,6 @@ test('Three coordinator shares shaping data across technique bindings and refere referenceHeight: page.referenceHeight, referenceBytes: page.references.bytes.slice(), })), - bindings: [], }, disposed: false, }; From 059b626c17e186b6f57f271830eb7d75e0bdeae9 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 23:19:16 -0400 Subject: [PATCH 116/128] perf(text): skip redundant policy discovery --- docs/log.md | 8 +++++ docs/packages/text.md | 21 ++++++++---- .../rust/shaper/src/engine/plan_packing.rs | 15 +++++++- .../text/rust/shaper/src/engine/policy.rs | 34 +++++++++++++++++++ .../shaper/src/engine/render_plan_compiler.rs | 29 ++++++++++------ 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/docs/log.md b/docs/log.md index 0daf2e5b..67450079 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,14 @@ ## 2026-08-09 +- **Removed the redundant homogeneous-policy glyph scan and preserved promoted-range alignment** — The first-party + renderer policy uses one allocation strategy across Bitmap, MTSDF, Slug, and external programs, so Rust now selects + that strategy once before delegating to the planner; mixed policies retain exact per-glyph discovery. Planner + compilation remains the authority that validates program existence and input shape. Dirty-range whole-buffer + promotion now rounds its record end so `end * stride` still satisfies the renderer's byte alignment. All 157 Rust + library tests pass. A short 22k-target run shows no material regression and the optimized shaper is 1,160,505 raw / + 442,612 gzip / 348,594 Brotli bytes, +182 / +42 / +233 bytes from the preceding artifact. + - **Made application gates respect their shared build artifact dependency** — Root package checks already complete before application checks, but the benchmark app rebuilds those runtime packages as part of its standalone contract. Running application checks concurrently let that rebuild remove `packages/text/dist` while the R3F example authenticated its diff --git a/docs/packages/text.md b/docs/packages/text.md index 699ff18b..8840b31d 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:94a04c1dd7cf680fb0e340dd0e664059de5c93d1ff453f5057eb8dd670b4f3fb' +source_digest: 'sha256:c719e6708a954f5918cbfa4e9634551aa22b4f1503e019ae78c7ef371f4f3172' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -55,7 +55,7 @@ sources: title: Three.js text API reference generated: by: openai-codex/gpt-5.6 - at: '2026-08-10T02:40:43Z' + at: '2026-08-10T03:16:26Z' --- # Package reference: `@pmndrs/text` @@ -229,17 +229,18 @@ The latest checked package-size record after the baker ABI cleanup reports: | Graph | Raw | gzip | Brotli | | --------------------------------------- | ----------: | --------: | --------: | -| Core JavaScript plus shaper Wasm | 1,248,721 B | 460,416 B | 363,830 B | -| Three adapter plus core and shaper Wasm | 1,487,349 B | 498,437 B | 395,363 B | +| Core JavaScript plus shaper Wasm | 1,248,903 B | 460,458 B | 364,063 B | +| Three adapter plus core and shaper Wasm | 1,487,531 B | 498,479 B | 395,596 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. -The optimized shaper is 1,160,323 raw / 442,570 gzip / 348,361 Brotli bytes. The renderer-neutral JavaScript graph is +The optimized shaper is 1,160,505 raw / 442,612 gzip / 348,594 Brotli bytes. The renderer-neutral JavaScript graph is 88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 327,026 raw / 55,867 gzip / 47,002 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; the later shared-emitter and stable -range-scan work reduces those final totals to 460,416 and 498,437 gzip bytes. +range-scan work reduces those totals to 460,416 and 498,437 gzip bytes. The homogeneous-policy dispatch and dirty-range +alignment correction move the current totals to 460,458 and 498,479 gzip bytes. The corrected complete MTSDF baker remains 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; the earlier 52 KiB observation was a kernel-only test artifact that reused the distributable Cargo target directory. @@ -296,6 +297,14 @@ sorted writes to the requested range reduces stable font-size from 350.136 to 7. sequential benchmark high-water marks are 107.56 MiB ordered and 114.25 MiB stable; retained-memory right-sizing remains open and neither figure is presented as ordinary application demand. +The first-party policy declares one allocation strategy for every registered technique. Rust now resolves that uniform +strategy once per update instead of looking up a program for every glyph before the selected planner performs its own +validated compilation. Mixed-strategy policies retain the per-glyph discovery path and stop once both strategies are +observed. A five-warmup/11-sample ordered run measures 6.005 ms font-size, 2.813 ms column-resize, 1.212 ms localized-edit, +and 8.281 ms middle-splice medians. The adjacent prior medians were 6.178, 2.817, 1.353, and 8.452 ms; these short runs +show no regression and suggest a small scan reduction, but do not establish a latency win. The same change preserves +whole-buffer update alignment after dirty-range promotion and costs 182 raw / 42 gzip / 233 Brotli bytes. + ## Merge gates still open Before the foundation stack is publishable: diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs index d4b139cd..728dce4b 100644 --- a/packages/text/rust/shaper/src/engine/plan_packing.rs +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -290,10 +290,12 @@ pub fn coalesce_buffer_ranges( if upload_cost.saturating_mul(10_000) >= full_bytes.saturating_mul(u32::from(capability.whole_buffer_threshold_basis_points)) { + let record_alignment = + capability.update_alignment / gcd(capability.update_alignment, bytes_per_record); ranges.clear(); ranges.push(RecordRange { start: 0, - end: live_records, + end: align_up(live_records, record_alignment)?, }); } Ok(()) @@ -422,6 +424,17 @@ mod tests { ); } + #[test] + fn whole_buffer_promotion_preserves_the_declared_byte_alignment() { + let mut aligned = vec![RecordRange { start: 0, end: 2 }]; + let mut capability = capability(); + capability.update_alignment = 16; + + coalesce_buffer_ranges(&mut aligned, 8, &capability, 3).unwrap(); + + assert_eq!(aligned, [RecordRange { start: 0, end: 4 }]); + } + #[test] fn identical_per_buffer_ranges_share_one_packing_job() { let mut ranges: [alloc::vec::Vec; MAX_PHYSICAL_BUFFERS] = diff --git a/packages/text/rust/shaper/src/engine/policy.rs b/packages/text/rust/shaper/src/engine/policy.rs index 4b66da1a..8173d179 100644 --- a/packages/text/rust/shaper/src/engine/policy.rs +++ b/packages/text/rust/shaper/src/engine/policy.rs @@ -349,6 +349,23 @@ impl ValidatedPolicy { }) } + pub(crate) fn uniform_allocation_strategy( + &self, + capability_set: CapabilitySetId, + ) -> Option { + let mut strategy = None; + for program in self.programs.iter().filter(|program| { + program.capability_set == capability_set || program.capability_set.0 == 0 + }) { + match strategy { + Some(existing) if existing != program.allocation_strategy => return None, + Some(_) => {} + None => strategy = Some(program.allocation_strategy), + } + } + strategy + } + pub fn execute( &self, capability_set: CapabilitySetId, @@ -1919,6 +1936,23 @@ mod tests { ); } + #[test] + fn reports_only_a_truly_uniform_allocation_strategy() { + let ordered = valid_program(); + let uniform = ValidatedPolicy::new(descriptor(vec![ordered.clone()])).unwrap(); + assert_eq!( + uniform.uniform_allocation_strategy(CAPABILITY), + Some(ALLOCATION_ORDERED_DIRECT) + ); + + let mut stable = ordered; + stable.technique = TechniqueId(2); + stable.id = ProgramId(2); + stable.allocation_strategy = ALLOCATION_STABLE_INDIRECT; + let mixed = ValidatedPolicy::new(descriptor(vec![valid_program(), stable])).unwrap(); + assert_eq!(mixed.uniform_allocation_strategy(CAPABILITY), None); + } + #[test] fn compiles_each_operation_to_only_its_reachable_buffers() { let mut program = valid_program(); diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index 0839d464..3a88c2ed 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -198,16 +198,25 @@ impl RenderPlanCompiler { validate_input(input)?; self.clear_merged_plan(); - let mut ordered_input = false; - let mut stable_input = false; - for glyph in input.glyphs { - let program = policy - .program(capability_set, glyph.technique, glyph.program_variant) - .ok_or(RenderPlanCompilerError::ProgramMissing)?; - match program.allocation_strategy { - ALLOCATION_ORDERED_DIRECT => ordered_input = true, - ALLOCATION_STABLE_INDIRECT => stable_input = true, - _ => return Err(RenderPlanCompilerError::UnsupportedStrategy), + let (mut ordered_input, mut stable_input) = (false, false); + match policy.uniform_allocation_strategy(capability_set) { + Some(ALLOCATION_ORDERED_DIRECT) => ordered_input = !input.glyphs.is_empty(), + Some(ALLOCATION_STABLE_INDIRECT) => stable_input = !input.glyphs.is_empty(), + Some(_) => return Err(RenderPlanCompilerError::UnsupportedStrategy), + None => { + for glyph in input.glyphs { + let program = policy + .program(capability_set, glyph.technique, glyph.program_variant) + .ok_or(RenderPlanCompilerError::ProgramMissing)?; + match program.allocation_strategy { + ALLOCATION_ORDERED_DIRECT => ordered_input = true, + ALLOCATION_STABLE_INDIRECT => stable_input = true, + _ => return Err(RenderPlanCompilerError::UnsupportedStrategy), + } + if ordered_input && stable_input { + break; + } + } } } From 44054449293f2e76df7d9e6e031cba94d4768abf Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 23:40:19 -0400 Subject: [PATCH 117/128] fix(text): make plan application retry-safe --- docs/log.md | 10 ++ docs/packages/text.md | 13 +- docs/planning/three-api.md | 7 +- packages/text/src/three/engine-plan-target.ts | 150 ++++++++---------- packages/text/src/three/engine-runtime.ts | 74 +++++++-- .../text/src/three/plan-program-registry.ts | 5 +- packages/text/src/three/text.ts | 22 ++- .../integration/three-engine-runtime.test.mjs | 41 +++-- .../text/tests/integration/three-v1.test.mjs | 90 ++++++++++- 9 files changed, 293 insertions(+), 119 deletions(-) diff --git a/docs/log.md b/docs/log.md index 67450079..ff818210 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-09 +- **Closed the Three command-buffer retry and ownership gaps** — Three now advances `consumedPlanRevision` only after + successful plan application and automatically retries retained owned bytes before another engine update. Upload ranges + clear once per plan then accumulate across origin restoration, presentation edits, and Rust patches. Exact retired + buffer generations dispose dependent materials even after a replacement occupies the ID, indexed table growth retains + direct materials, and loaded-font disposal removes owner-scoped decoded resources. Material realization rejects + synchronous text reentrancy before another Wasm call can invalidate borrowed views; semantic-only queries assert that + Rust emitted no render work. Public compiled-Wasm regressions cover every failure. The 25,515-glyph public Three lane + measures 17.84/6.32/3.04/13.84 ms median for cold/font-size/width/text versus the adjacent recorded + 19.42/6.59/3.10/14.24 ms, establishing no regression without assigning a cross-process speedup. + - **Removed the redundant homogeneous-policy glyph scan and preserved promoted-range alignment** — The first-party renderer policy uses one allocation strategy across Bitmap, MTSDF, Slug, and external programs, so Rust now selects that strategy once before delegating to the planner; mixed policies retain exact per-glyph discovery. Planner diff --git a/docs/packages/text.md b/docs/packages/text.md index 8840b31d..e1df011e 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:c719e6708a954f5918cbfa4e9634551aa22b4f1503e019ae78c7ef371f4f3172' +source_digest: 'sha256:848403ab5df85b439089e447010f07ae54309de2c3f9eeba994337611160ddaf' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -55,7 +55,7 @@ sources: title: Three.js text API reference generated: by: openai-codex/gpt-5.6 - at: '2026-08-10T03:16:26Z' + at: '2026-08-10T03:36:54Z' --- # Package reference: `@pmndrs/text` @@ -252,6 +252,15 @@ GPU submission. An adjacent phase-instrumented run was indistinguishable within exports, calls, branches, and clock reads are now absent from the package source and clean publishing output; benchmark workload markers and the direct Wasm timer remain outside the shipped library. +After the final plan-application lifecycle audit, Three sizes indexed transforms from live paragraph IDs instead of +scanning every glyph record in JavaScript. A renderer failure retains an unconsumed owned plan for zero-crossing retry; +dirty upload ranges accumulate across presentation restoration and Rust patches; buffer/resource generations dispose +only their exact dependent materials; direct materials survive indexed transform-table growth; and loaded-font disposal +removes its decoded renderer resources. The unchanged eight-warmup/31-sample public 25,515-glyph lane measures +17.84/6.32/3.04/13.84 ms medians and 18.99/6.64/4.60/14.01 ms p95 for cold/font-size/width/text. The adjacent recorded +run was 19.42/6.59/3.10/14.24 ms median; process-separated samples support no regression and a plausible cold-path +reduction, not causal attribution. + The canonical direct benchmark loads the packaged `dist/text_shaper.wasm`: Cargo release optimization, LTO, one codegen unit, default-on `simd128`, stripping, and `wasm-opt -Oz --enable-simd` have already run. On the identical Rust artifact, Binaryen `-O3` and `-O4` added 11,976 and 13,661 raw bytes without a demonstrated latency improvement, so `-Oz` remains diff --git a/docs/planning/three-api.md b/docs/planning/three-api.md index 52ac1c3d..fb8acca1 100644 --- a/docs/planning/three-api.md +++ b/docs/planning/three-api.md @@ -158,8 +158,9 @@ Offsets match JavaScript and DOM selection APIs and cannot split a surrogate pai inserted text inherits a span only when inserted strictly inside it, so span-boundary affinity does not become hidden mutable state. -Errors are retained on `text.error` or `group.error` and forwarded to `onError`. They do not escape Three.js scene -traversal. `retry()` reapplies a retained publication after a renderer-side failure. +Errors are retained on `text.error` and the owning `group.error`, then forwarded to `onError`. They do not escape Three.js +scene traversal. A renderer-side failure leaves the Rust publication unconsumed; `retry()` or the next group traversal +reapplies its owned bytes before another engine delta is requested. ## Query committed layout @@ -192,6 +193,8 @@ const label = new Text({ font, text: 'Custom', material }); The factory is renderer-owned. Rust carries a numeric `materialId` through style resolution and draw planning; it does not execute the factory. Three invokes `create()` when it needs a material for a concrete Bitmap, MSDF, or Slug pipeline. +Material creation runs while Three holds borrowed plan-backed attributes. It must return synchronously and must not query +or update text; the coordinator rejects such reentrancy before another Wasm call can detach those views. `ThreeTextMaterialContext` is a discriminated union on `technique`. Each branch provides the concrete technique shader, the final policy-selected position node, and `createDefaultMaterial()`. diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index 2a3b1a64..a3f3c99e 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -45,6 +45,7 @@ interface MaterialRealization { readonly resourceId: number; readonly resourceGeneration: number; readonly buffers: readonly Readonly<{ id: number; generation: number }>[]; + readonly indexedTransform: boolean; } interface OriginSegment { @@ -73,6 +74,7 @@ interface RecordAddressing { export interface ThreeTextEnginePlanOwner { readonly drawRoot: THREE.Object3D; objectForTransform(transformId: number): THREE.Object3D; + transformIds(): Iterable; readonly renderOrderBase: number; } @@ -126,29 +128,32 @@ export class ThreeTextRenderPlanExecutor { apply(publication: TextEnginePublication): void { if (this.#disposed) throw new Error('Three text-engine plan target has been disposed'); - this.#restoreOriginTargets(); - const plan = this.#view.bind(publication); - const resources = plan.table('resources'); - const buffers = plan.table('buffers'); - const patches = plan.table('patches'); - const primitives = plan.table('primitives'); - const draws = plan.table('draws'); - const retirements = plan.table('retirements'); - if (resources.count !== 0) this.#readResources(plan, resources); - if (buffers.count !== 0) this.#readBuffers(plan, buffers); - this.#applyPatches(plan, patches); - if ( - resources.count !== 0 || - buffers.count !== 0 || - primitives.count !== 0 || - draws.count !== 0 || - retirements.count !== 0 - ) { - this.#replaceDraws(plan, draws, primitives, buffers, resources); - } - this.syncTransforms(); - this.#applyRetirements(plan, retirements); - this.#originRecords.clear(); + this.#coordinator.applyPlan(() => { + for (const buffer of this.#buffers.values()) buffer.attribute.clearUpdateRanges(); + this.#restoreOriginTargets(); + const plan = this.#view.bind(publication); + const resources = plan.table('resources'); + const buffers = plan.table('buffers'); + const patches = plan.table('patches'); + const primitives = plan.table('primitives'); + const draws = plan.table('draws'); + const retirements = plan.table('retirements'); + if (resources.count !== 0) this.#readResources(plan, resources); + if (buffers.count !== 0) this.#readBuffers(plan, buffers); + this.#applyPatches(plan, patches); + if ( + resources.count !== 0 || + buffers.count !== 0 || + primitives.count !== 0 || + draws.count !== 0 || + retirements.count !== 0 + ) { + this.#replaceDraws(plan, draws, primitives, buffers, resources); + } + this.syncTransforms(); + this.#applyRetirements(plan, retirements); + this.#originRecords.clear(); + }); } snapshotGlyphOrigins( @@ -225,7 +230,7 @@ export class ThreeTextRenderPlanExecutor { this.#owner.drawRoot.updateWorldMatrix(true, false); this.#rootInverse.copy(this.#owner.drawRoot.matrixWorld).invert(); const target = this.#transformAttribute.array as Float32Array; - let changed = 0; + const changedTransforms = new Set(); let indexedChanged = 0; for (const index of this.#activeTransformIndices) { const object = this.#owner.objectForTransform(index); @@ -240,7 +245,7 @@ export class ThreeTextRenderPlanExecutor { target.fill(0, offset, offset + 16); } this.#transformAttribute.addUpdateRange(index * 16, 16); - changed += 1; + changedTransforms.add(index); indexedChanged += 1; } for (const draw of this.#draws) { @@ -259,14 +264,14 @@ export class ThreeTextRenderPlanExecutor { draw.matrixWorldNeedsUpdate = true; drawChanged = true; } - if (drawChanged) changed += 1; + if (drawChanged) changedTransforms.add(transformId); } - if (changed === 0) return 0; + if (changedTransforms.size === 0) return 0; if (indexedChanged !== 0) { this.#transformAttribute.needsUpdate = true; invalidatePboTexture(this.#transformAttribute); } - return changed; + return changedTransforms.size; } dispose(): void { @@ -345,7 +350,6 @@ export class ThreeTextRenderPlanExecutor { #applyPatches(plan: TextEngineRenderPlanView, table: RenderPlanTable): void { const layout = textShaperAbi.layouts.enginePatch; const opcodes = textShaperAbi.engine.patchOpcodes; - const touched = new Set(); for (let index = 0; index < table.count; index += 1) { const record = plan.record(table, index); const opcode = plan.u16(record + layout.opcode); @@ -376,8 +380,7 @@ export class ThreeTextRenderPlanExecutor { } else { throw new Error(`unsupported text-engine patch opcode ${opcode}`); } - markUpdated(buffer, destinationOffset, byteLength, !touched.has(buffer.id)); - touched.add(buffer.id); + markUpdated(buffer, destinationOffset, byteLength); } } @@ -403,7 +406,7 @@ export class ThreeTextRenderPlanExecutor { previous.set(key, matches); } const reused = new Set(); - const transformIndices = this.#collectTransformIndices(plan, draws, primitives, buffers); + const transformIndices = this.#collectTransformIndices(plan, draws); this.#ensureTransformCapacity(transformIndices); try { for (let index = 0; index < draws.count; index += 1) { @@ -555,45 +558,13 @@ export class ThreeTextRenderPlanExecutor { return { kind: 'indexed', indices }; } - #collectTransformIndices( - plan: TextEngineRenderPlanView, - draws: RenderPlanTable, - primitives: RenderPlanTable, - buffers: RenderPlanTable, - ): Set { + #collectTransformIndices(plan: TextEngineRenderPlanView, draws: RenderPlanTable): Set { const drawLayout = textShaperAbi.layouts.engineDraw; - const primitiveLayout = textShaperAbi.layouts.enginePrimitive; - const bufferLayout = textShaperAbi.layouts.engineBuffer; - const result = new Set(); for (let drawIndex = 0; drawIndex < draws.count; drawIndex += 1) { const draw = plan.record(draws, drawIndex); - if (plan.u32(draw + drawLayout.transformId) !== 0) continue; - const primitive = plan.record(primitives, plan.u32(draw + drawLayout.primitiveStart)); - const bufferStart = plan.u32(draw + drawLayout.bufferStart); - const bufferEnd = bufferStart + plan.u32(draw + drawLayout.bufferCount); - let transformBuffer: RetainedBuffer | undefined; - const byPolicyId = new Map(); - for (let bufferIndex = bufferStart; bufferIndex < bufferEnd; bufferIndex += 1) { - const record = plan.record(buffers, bufferIndex); - const candidate = this.#buffer(plan.u32(record + bufferLayout.id), plan.u32(record + bufferLayout.generation)); - byPolicyId.set(candidate.policyBufferId, candidate); - if (candidate.policyBufferId === FIRST_PARTY_TRANSFORM_BUFFER_ID) transformBuffer = candidate; - } - if (transformBuffer === undefined || !(transformBuffer.array instanceof Uint32Array)) { - throw new Error('indexed Three draw is missing its u32 transform-index buffer'); - } - const addressing = recordAddressing(plan, draw, primitive, byPolicyId); - const start = plan.u32(primitive + primitiveLayout.recordIndex); - const end = start + plan.u16(primitive + primitiveLayout.recordCount); - for (let recordIndex = start; recordIndex < end; recordIndex += 1) { - const transformIndex = transformBuffer.array[physicalRecordIndex(addressing.order, recordIndex)]; - if (transformIndex === undefined || transformIndex === 0) { - throw new Error('indexed Three draw references an invalid transform slot'); - } - result.add(transformIndex); - } + if (plan.u32(draw + drawLayout.transformId) === 0) return new Set(this.#owner.transformIds()); } - return result; + return new Set(); } #ensureTransformCapacity(indices: ReadonlySet): void { @@ -605,8 +576,7 @@ export class ThreeTextRenderPlanExecutor { while (capacity < requiredRecords) capacity *= 2; this.#transformAttribute = transformAttribute(capacity / 4); this.#transformGeneration += 1; - for (const realization of this.#materials.values()) realization.material.dispose(); - this.#materials.clear(); + this.#disposeMaterials((realization) => realization.indexedTransform); } #bitmapMaterial( @@ -662,7 +632,7 @@ export class ThreeTextRenderPlanExecutor { position, createDefaultMaterial: () => bitmapMaterial(shader, position), }); - this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing)); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing), transform.kind); return material; } @@ -702,14 +672,16 @@ export class ThreeTextRenderPlanExecutor { ); const instance = physicalInstance(TSL.instanceIndex.add(runStart), addressing); const publicBuffers = new Map( - [...buffers].map(([id, buffer]) => [ - id, - { - scalarType: buffer.scalarType, - vectorWidth: buffer.vectorWidth, - attribute: buffer.attribute, - }, - ]), + [...buffers] + .filter(([id]) => id !== textShaperAbi.engine.internalBufferBindings.order) + .map(([id, buffer]) => [ + id, + { + scalarType: buffer.scalarType, + vectorWidth: buffer.vectorWidth, + attribute: buffer.attribute, + }, + ]), ); const material = this.#ownMaterial( resolved.program.createMaterial({ @@ -724,7 +696,7 @@ export class ThreeTextRenderPlanExecutor { : position, }), ); - this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing)); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing), transform.kind); return material; } @@ -784,7 +756,7 @@ export class ThreeTextRenderPlanExecutor { position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing)); + this.#retainMaterial(key, material, resource, materialBuffers(required, transform, addressing), transform.kind); return material; } @@ -899,7 +871,13 @@ export class ThreeTextRenderPlanExecutor { position, createDefaultMaterial: () => coverageMaterial(shader, position), }); - this.#retainMaterial(key, material, resource, materialBuffers(required, transformRealization, addressing)); + this.#retainMaterial( + key, + material, + resource, + materialBuffers(required, transformRealization, addressing), + transformRealization.kind, + ); return material; } @@ -962,12 +940,14 @@ export class ThreeTextRenderPlanExecutor { material: THREE.NodeMaterial, resource: RetainedResource, buffers: readonly RetainedBuffer[], + transformKind: TransformRealization['kind'], ): void { this.#materials.set(key, { material, resourceId: resource.id, resourceGeneration: resource.generation, buffers: buffers.map(({ id, generation }) => ({ id, generation })), + indexedTransform: transformKind === 'indexed', }); } @@ -1001,11 +981,10 @@ export class ThreeTextRenderPlanExecutor { const id = plan.u32(record + layout.id); const generation = plan.u32(record + layout.generation); if (kind === kinds.buffer) { - if (this.#buffers.get(id)?.generation !== generation) continue; this.#disposeMaterials((realization) => realization.buffers.some((buffer) => buffer.id === id && buffer.generation === generation), ); - this.#buffers.delete(id); + if (this.#buffers.get(id)?.generation === generation) this.#buffers.delete(id); continue; } if (kind === kinds.resource) { @@ -1298,12 +1277,11 @@ function packReferencePairs( return { data, width, height }; } -function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: number, firstPatch: boolean): void { +function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: number): void { const scalarBytes = buffer.array.BYTES_PER_ELEMENT; if (byteOffset % scalarBytes !== 0 || byteLength % scalarBytes !== 0) { throw new RangeError('buffer patch is not scalar aligned'); } - if (firstPatch) buffer.attribute.clearUpdateRanges(); buffer.attribute.addUpdateRange(byteOffset / scalarBytes, byteLength / scalarBytes); buffer.attribute.needsUpdate = true; invalidatePboTexture(buffer.attribute); @@ -1311,7 +1289,7 @@ function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: num function markOriginRanges(ranges: ReadonlyMap): void { for (const [buffer, [start, end]] of ranges) { - markUpdated(buffer, start * buffer.array.BYTES_PER_ELEMENT, (end - start) * buffer.array.BYTES_PER_ELEMENT, true); + markUpdated(buffer, start * buffer.array.BYTES_PER_ELEMENT, (end - start) * buffer.array.BYTES_PER_ELEMENT); } } diff --git a/packages/text/src/three/engine-runtime.ts b/packages/text/src/three/engine-runtime.ts index 33cabbe4..2f2ea1fb 100644 --- a/packages/text/src/three/engine-runtime.ts +++ b/packages/text/src/three/engine-runtime.ts @@ -1,4 +1,4 @@ -import type { LoadedFont } from '../loaded-font.js'; +import { observeLoadedFontDispose, type LoadedFont } from '../loaded-font.js'; import { bitmap, type BitmapData, type BitmapPageData } from '../raster/bitmap-technique.js'; import { msdf, type MsdfData } from '../raster/msdf.js'; import { slug, type SlugData, type SlugPageData } from '../raster/slug-technique.js'; @@ -35,6 +35,10 @@ interface RetainedMaterial { references: number; } +interface RetainedResourceOwners { + readonly owners: Map, ThreeTextEngineResource>; +} + export type ThreeTextEngineResource = | Readonly<{ technique: typeof bitmap.id; page: BitmapPageData }> | Readonly<{ technique: typeof msdf.id; data: MsdfData }> @@ -50,7 +54,9 @@ export interface ThreeTextEngineCoordinatorOptions { export class ThreeTextEngineCoordinator { readonly host: TextEngineHost; readonly #bindingHandles = new WeakMap, number>(); - readonly #resources = new Map(); + readonly #resources = new Map(); + readonly #fontResourceReferences = new Map, Set>(); + readonly #fontDisposeObservers = new Map, () => void>(); readonly #stacks = new Map(); readonly #materialHandles = new WeakMap(); readonly #materials = new Map(); @@ -59,6 +65,7 @@ export class ThreeTextEngineCoordinator { #nextStackHandle = 1; #nextSessionHandle = 1; #nextMaterialHandle = 1; + #applyingPlan = false; #disposed = false; constructor( @@ -82,6 +89,20 @@ export class ThreeTextEngineCoordinator { return POLICY_HANDLE; } + assertFrameUpdateAllowed(): void { + if (this.#applyingPlan) throw new Error('text updates and queries cannot reenter Three render-plan application'); + } + + applyPlan(apply: () => Result): Result { + if (this.#applyingPlan) throw new Error('Three render-plan application cannot be reentered'); + this.#applyingPlan = true; + try { + return apply(); + } finally { + this.#applyingPlan = false; + } + } + acquireFontStack( fonts: readonly [LoadedFont, ...LoadedFont[]], ): ThreeTextEngineStackLease { @@ -143,7 +164,7 @@ export class ThreeTextEngineCoordinator { } resolveResource(referenceId: number): ThreeTextEngineResource { - const resource = this.#resources.get(referenceId); + const resource = this.#resources.get(referenceId)?.owners.values().next().value; if (resource === undefined) throw new Error(`Three text command buffer references unknown resource ${referenceId}`); return resource; } @@ -151,6 +172,10 @@ export class ThreeTextEngineCoordinator { dispose(): void { if (this.#disposed) return; this.host.dispose(); + for (const stopObserving of this.#fontDisposeObservers.values()) stopObserving(); + this.#fontDisposeObservers.clear(); + this.#fontResourceReferences.clear(); + this.#resources.clear(); this.#stacks.clear(); this.#materials.clear(); this.#disposed = true; @@ -160,6 +185,7 @@ export class ThreeTextEngineCoordinator { if (font.disposed) throw new TypeError('cannot register a disposed loaded font with the Three text engine'); const existing = this.#bindingHandles.get(font); if (existing !== undefined) return existing; + this.#observeFont(font); const handle = this.#allocateBindingHandle(); const program = this.#planPrograms.get(font.technique.id); if (program === undefined) { @@ -172,7 +198,7 @@ export class ThreeTextEngineCoordinator { } else { const compiled = program.compileFont(font, this.host.wireIdentities); for (const [key, resource] of compiled.resources) { - this.#retainResource(key, { technique: font.technique.id, resource, program }); + this.#retainResource(font, key, { technique: font.technique.id, resource, program }); } this.host.registerFontBinding(handle, font.font.handle, compiled.binding); } @@ -184,30 +210,58 @@ export class ThreeTextEngineCoordinator { if (font.technique.id === bitmap.id) { const data = font.data as BitmapData; for (const strike of data.strikes) { - for (const page of strike.pages) this.#retainResource(page.resource, { technique: bitmap.id, page }); + for (const page of strike.pages) this.#retainResource(font, page.resource, { technique: bitmap.id, page }); } return; } if (font.technique.id === msdf.id) { const data = font.data as MsdfData; - this.#retainResource(data.resource, { technique: msdf.id, data }); + this.#retainResource(font, data.resource, { technique: msdf.id, data }); return; } if (font.technique.id === slug.id) { const data = font.data as SlugData; - for (const page of data.pages) this.#retainResource(page.resource, { technique: slug.id, page }); + for (const page of data.pages) this.#retainResource(font, page.resource, { technique: slug.id, page }); return; } throw new TypeError(`no first-party Three resource resolver is registered for "${font.technique.id}"`); } - #retainResource(key: string, resource: ThreeTextEngineResource): void { + #retainResource(font: LoadedFont, key: string, resource: ThreeTextEngineResource): void { const referenceId = this.host.wireIdentities.resolve(key); - const existing = this.#resources.get(referenceId); + let retained = this.#resources.get(referenceId); + const existing = retained?.owners.values().next().value; if (existing !== undefined && existing.technique !== resource.technique) { throw new TypeError(`Three text resource ${referenceId} is registered for incompatible techniques`); } - if (existing === undefined) this.#resources.set(referenceId, resource); + if (retained === undefined) { + retained = { owners: new Map() }; + this.#resources.set(referenceId, retained); + } + retained.owners.set(font, resource); + let references = this.#fontResourceReferences.get(font); + if (references === undefined) { + references = new Set(); + this.#fontResourceReferences.set(font, references); + } + references.add(referenceId); + } + + #observeFont(font: LoadedFont): void { + if (this.#fontDisposeObservers.has(font)) return; + const stopObserving = observeLoadedFontDispose(font, () => this.#releaseFontResources(font)); + this.#fontDisposeObservers.set(font, stopObserving); + } + + #releaseFontResources(font: LoadedFont): void { + for (const referenceId of this.#fontResourceReferences.get(font) ?? []) { + const retained = this.#resources.get(referenceId); + retained?.owners.delete(font); + if (retained?.owners.size === 0) this.#resources.delete(referenceId); + } + this.#fontResourceReferences.delete(font); + this.#fontDisposeObservers.delete(font); + this.#bindingHandles.delete(font); } #allocateBindingHandle(): number { diff --git a/packages/text/src/three/plan-program-registry.ts b/packages/text/src/three/plan-program-registry.ts index cca244c8..990c8fac 100644 --- a/packages/text/src/three/plan-program-registry.ts +++ b/packages/text/src/three/plan-program-registry.ts @@ -40,7 +40,10 @@ export interface ThreeRasterPlanProgram; /** Cold font registration; never runs during frame shaping, layout, packing, or draw submission. */ compileFont(compiler: ThreePlanProgramFontCompiler): void; - /** Renderer realization invoked only when a compatible retained material is absent. */ + /** + * Renderer realization invoked only when a compatible retained material is absent. + * The callback receives borrowed plan-backed attributes and must not synchronously update or query text. + */ createMaterial(context: ThreePlanProgramMaterialContext): NodeMaterial; } diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index e562c997..5189a3af 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -33,6 +33,7 @@ import { type TextEngineSession, } from '../internal/text-engine-host.js'; import { readTextEngineLayouts, readTextEngineMeasurements } from '../internal/layout-query-view.js'; +import { TextEngineRenderPlanView } from '../internal/render-plan-view.js'; import type { ParagraphLayoutInspection, ParagraphLayoutSummary } from '../layout.js'; import { textShaperAbi } from '../generated/text-shaper-abi.js'; import { ThreeTextRenderPlanExecutor } from './engine-plan-target.js'; @@ -147,7 +148,7 @@ export class Text extends THREE.Object3D { return this.#disposed; } get error(): unknown { - return this.#error ?? this.#binding?.error; + return this.#error ?? this.#textGroup?.error; } get gpuBytes(): number { return this.#binding?.gpuBytes ?? 0; @@ -399,7 +400,7 @@ export class TextGroup extends THREE.Object3D { return this.#disposed; } get error(): unknown { - return this.#error ?? this.#binding?.error; + return this.#error; } get gpuBytes(): number { return this.#binding?.gpuBytes ?? 0; @@ -502,6 +503,7 @@ class ThreeTextBatchBinding { readonly #removed: RetainedEngineParagraph[] = []; readonly #measurements = new Map, ParagraphLayoutSummary>(); readonly #layoutInspections = new Map, ParagraphLayoutInspection>(); + readonly #queryPlanView = new TextEngineRenderPlanView(); #nextParagraphId = 1; #engineRevision = 0; #planRevision = 0; @@ -538,14 +540,14 @@ class ThreeTextBatchBinding { if (text === undefined) throw new Error(`Three command buffer references unknown transform ${transformId}`); return text; }, + transformIds() { + return owner.#textsByParagraph.keys(); + }, }); } get textCount(): number { return this.#paragraphs.size; } - get error(): unknown { - return undefined; - } get gpuBytes(): number { return this.#target.gpuBytes; } @@ -591,6 +593,8 @@ class ThreeTextBatchBinding { } synchronize(semanticViewMask = 0): void { if (this.#disposed) return; + this.#coordinator.assertFrameUpdateAllowed(); + if (this.#lastPublication !== undefined) this.retry(); const ordered = [...this.#paragraphs.entries()].sort( ([leftText, left], [rightText, right]) => leftText.renderOrder - rightText.renderOrder || left.id - right.id, ); @@ -696,7 +700,6 @@ class ThreeTextBatchBinding { throw error; } this.#engineRevision = publication.engineRevision; - this.#planRevision = publication.planRevision; for (const removed of this.#removed) releaseStackLeases(removed.stackLeases); for (const removed of this.#removed) releaseMaterialLeases(removed.materialLeases); this.#removed.length = 0; @@ -723,6 +726,7 @@ class ThreeTextBatchBinding { committed = true; try { this.#target.apply(publication); + this.#planRevision = publication.planRevision; this.#lastPublication = undefined; } catch (error) { this.#lastPublication = ownPublication(publication); @@ -751,6 +755,7 @@ class ThreeTextBatchBinding { const publication = this.#lastPublication; if (publication === undefined) return; this.#target.apply(publication); + this.#planRevision = publication.planRevision; this.#acknowledgedPublicationGeneration = publication.publicationGeneration; this.#lastPublication = undefined; } @@ -831,6 +836,11 @@ class ThreeTextBatchBinding { ), }), ); + const plan = this.#queryPlanView.bind(publication); + for (const table of ['resources', 'buffers', 'patches', 'primitives', 'draws', 'retirements'] as const) { + if (plan.table(table).count !== 0) + throw new Error('a semantic-only text query unexpectedly published render work'); + } this.#engineRevision = publication.engineRevision; this.#planRevision = publication.planRevision; return publication; diff --git a/packages/text/tests/integration/three-engine-runtime.test.mjs b/packages/text/tests/integration/three-engine-runtime.test.mjs index c5ba7322..56b54838 100644 --- a/packages/text/tests/integration/three-engine-runtime.test.mjs +++ b/packages/text/tests/integration/three-engine-runtime.test.mjs @@ -14,6 +14,7 @@ import { textShaperAbi } from '../../dist/generated/text-shaper-abi.js'; import { compileTextEngineFrameUpdate } from '../../dist/internal/engine-frame-wire.js'; import { firstPartyThreeRenderPolicyBytes } from '../../dist/internal/render-policy-wire.js'; import { TextEngineRenderPlanView } from '../../dist/internal/render-plan-view.js'; +import { LoadedFontImpl } from '../../dist/loaded-font.js'; import { FontRegistry } from '../../dist/loader.js'; import { bitmap, bitmapDescriptor } from '../../dist/raster/bitmap-technique.js'; import { msdf, msdfDescriptor } from '../../dist/raster/msdf.js'; @@ -68,7 +69,7 @@ test('Three coordinator shares shaping data across technique bindings and refere glyphCount: slugCore.glyphCount, glyphIdWidth: 16, }); - const bitmapFont = { + const bitmapFont = new LoadedFontImpl({ runtime: undefined, font: registered, technique: bitmap, @@ -83,10 +84,10 @@ test('Three coordinator shares shaping data across technique bindings and refere })), })), }, - disposed: false, - }; + release: () => undefined, + }); const extension = msdfRaster.document.extensions.PMNDRS_font_distance_field; - const msdfFont = { + const msdfFont = new LoadedFontImpl({ runtime: undefined, font: registered, technique: msdf, @@ -104,10 +105,10 @@ test('Three coordinator shares shaping data across technique bindings and refere records: msdfRaster.records, pages: msdfRaster.pages, }, - disposed: false, - }; + release: () => undefined, + }); const slugExtension = slugRaster.document.extensions.PMNDRS_font_slug; - const slugFont = { + const slugFont = new LoadedFontImpl({ runtime: undefined, font: registered, technique: slug, @@ -130,8 +131,8 @@ test('Three coordinator shares shaping data across technique bindings and refere referenceBytes: page.references.bytes.slice(), })), }, - disposed: false, - }; + release: () => undefined, + }); const coordinator = new ThreeTextEngineCoordinator(shaper); const materialCalls = []; const primaryMaterial = coordinator.acquireMaterial( @@ -344,6 +345,7 @@ test('Three coordinator shares shaping data across technique bindings and refere if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); return object; }, + transformIds: () => paragraphObjects.keys(), }); target.apply(publication); assert.equal(target.draws.length, 2); @@ -656,6 +658,7 @@ test('Three coordinator shares shaping data across technique bindings and refere if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); return object; }, + transformIds: () => paragraphObjects.keys(), }); directTarget.apply(directPublication); assert.equal(directTarget.draws.length, 2); @@ -705,6 +708,7 @@ test('Three coordinator shares shaping data across technique bindings and refere if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); return object; }, + transformIds: () => paragraphObjects.keys(), }); hybridTarget.apply(hybridInitialPublication); const hybridPublication = hybridSession.update( @@ -765,12 +769,19 @@ test('Three coordinator shares shaping data across technique bindings and refere assert.equal(hybridDirectDraw.geometry.getAttribute('_pmndrsText_15'), undefined); assert.equal(hybridDirectDraw.geometry.getAttribute('_pmndrsTextTransforms'), undefined); assert.equal(hybridDirectDraw.matrix.elements[12], 9); + let hybridDirectDisposals = 0; + hybridDirectDraw.material.addEventListener('dispose', () => (hybridDirectDisposals += 1)); + paragraphObjects.set(20, new THREE.Object3D()); + hybridTarget.apply(hybridPublication); + assert.equal(hybridDirectDisposals, 0, 'indexed transform growth must preserve unrelated direct materials'); + assert.equal(hybridTarget.draws[1].material, hybridDirectDraw.material); paragraphObjects.get(1).position.x = 6; paragraphObjects.get(2).position.x = 10; assert.equal(hybridTarget.syncTransforms(), 2); - assert.equal(hybridIndexedDraw.geometry.getAttribute('_pmndrsTextTransforms').array[1 * 16 + 12], 6); + assert.equal(hybridTarget.draws[0].geometry.getAttribute('_pmndrsTextTransforms').array[1 * 16 + 12], 6); assert.equal(hybridDirectDraw.matrix.elements[12], 10); hybridTarget.dispose(); + paragraphObjects.delete(20); hybridSession.dispose(); const stablePolicyHandle = 4; @@ -812,6 +823,7 @@ test('Three coordinator shares shaping data across technique bindings and refere if (object === undefined) throw new Error(`unknown paragraph transform ${transformId}`); return object; }, + transformIds: () => paragraphObjects.keys(), }); stableTarget.apply(stableInitialPublication); const stableOrder = stableTarget.draws[0].geometry.getAttribute(`_pmndrsText_${orderBinding}`); @@ -882,6 +894,15 @@ test('Three coordinator shares shaping data across technique bindings and refere slugFirst.release(); primaryMaterial.release(); secondaryMaterial.release(); + bitmapFont.dispose(); + assert.throws( + () => coordinator.resolveResource(bitmapReference), + /unknown resource/u, + 'disposed fonts must release decoded renderer resources from the coordinator', + ); + assert.equal(coordinator.resolveResource(msdfReference).technique, msdf.id); + msdfFont.dispose(); + slugFont.dispose(); coordinator.dispose(); assert.throws(() => coordinator.acquireFontStack([bitmapFont]), /disposed/); shaper.dispose(); diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 85966c9a..5b8b25bf 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -4,7 +4,7 @@ import test from 'node:test'; import { createTextRuntime, FontRegistry } from '@pmndrs/text'; import { bitmap } from '@pmndrs/text/three/bitmap'; -import { Text, TextGroup } from '@pmndrs/text/three'; +import { defineTextMaterial, Text, TextGroup } from '@pmndrs/text/three'; import * as THREE from 'three/webgpu'; const fontUrl = new URL('../../../../apps/benchmarks/fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url); @@ -155,6 +155,86 @@ test('Three Text and TextGroup late-bind, synchronize, reparent, and dispose thr runtime.dispose(); }); +test('Three retries an unapplied Rust publication before requesting another engine delta', async () => { + const registry = new FontRegistry(); + const instrumented = await createInstrumentedRuntime(registry); + const runtime = instrumented.runtime; + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + let failMaterial = true; + let label; + const material = defineTextMaterial((context) => { + if (failMaterial) { + assert.throws(() => label.measureLayout(), /cannot reenter Three render-plan application/u); + throw new Error('deliberate material realization failure'); + } + return context.createDefaultMaterial(); + }); + const scene = new THREE.Scene(); + const group = new TextGroup(); + label = new Text({ font, material, text: 'Retry me' }); + group.add(label); + scene.add(group); + + scene.updateMatrixWorld(); + assert.match(String(group.error), /deliberate material realization failure/u); + assert.equal(label.error, group.error, 'group-owned failures must remain visible from the child Text'); + assert.equal(instrumented.crossings, 1); + assert.equal(group.children.filter((child) => child.isMesh).length, 0); + + failMaterial = false; + scene.updateMatrixWorld(); + assert.equal(group.error, undefined); + assert.equal(label.error, undefined); + assert.equal(instrumented.crossings, 1, 'retrying an owned command buffer must not cross into Rust again'); + assert.equal(group.children.filter((child) => child.isMesh).length, 1); + + group.dispose(); + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + +test('Three retires materials bound to a replaced buffer generation', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const materials = []; + const disposed = new Set(); + const material = defineTextMaterial((context) => { + const created = context.createDefaultMaterial(); + created.addEventListener('dispose', () => disposed.add(created)); + materials.push(created); + return created; + }); + const scene = new THREE.Scene(); + const group = new TextGroup({ capacity: { size: 2, policy: 'grow' } }); + const label = new Text({ font, material, text: 'AB' }); + group.add(label); + scene.add(group); + scene.updateMatrixWorld(); + const initialMaterial = group.children.find((child) => child.isMesh)?.material; + assert.equal(initialMaterial, materials[0]); + + label.text = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + scene.updateMatrixWorld(); + assert.ok(materials.length > 1, 'growing physical buffers must realize a material for the new generation'); + assert.ok(disposed.has(initialMaterial), 'the material retaining the retired generation must be disposed'); + + group.dispose(); + label.dispose(); + font.dispose(); + runtime.dispose(); +}); + test('TextGroup realizes two public Text objects as one indexed Rust draw', async () => { const registry = new FontRegistry(); const instrumented = await createInstrumentedRuntime(registry); @@ -248,9 +328,15 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn assert.equal(leftOrigins.shapedX.length, 2); assert.equal(rightOrigins.shapedX.length, 2); const shiftedRightX = rightOrigins.shapedX.slice(); + const shiftedLeftX = leftOrigins.shapedX.slice(); + shiftedLeftX[0] += 2; shiftedRightX[0] += 4; + const originsAttribute = draws[0].geometry.getAttribute('_pmndrsText_1'); + originsAttribute.clearUpdateRanges(); + left.setGlyphOrigins({ layout: leftOrigins.layout, x: shiftedLeftX, y: leftOrigins.shapedY }); right.setGlyphOrigins({ layout: rightOrigins.layout, x: shiftedRightX, y: rightOrigins.shapedY }); - assert.equal(left.snapshotGlyphOrigins().displayedX[0], leftOrigins.shapedX[0]); + assert.equal(originsAttribute.updateRanges.length, 2, 'separate presentation edits must retain both upload ranges'); + assert.equal(left.snapshotGlyphOrigins().displayedX[0], leftOrigins.shapedX[0] + 2); assert.equal(right.snapshotGlyphOrigins().displayedX[0], rightOrigins.shapedX[0] + 4); const version = transforms.version; From 2a0fd08f80c19e9371e4c83a274ecdd7142a1238 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Sun, 9 Aug 2026 23:48:22 -0400 Subject: [PATCH 118/128] test(text): prove mixed fallback live --- apps/r3f-hello-world/package.json | 6 +- .../scripts/live-check.probe.ts | 66 +++++++++++++++++++ apps/r3f-hello-world/src/technique-scene.tsx | 29 +++++++- docs/log.md | 7 ++ docs/packages/r3f-hello-world.md | 12 ++-- docs/packages/text.md | 7 +- .../text/tests/integration/three-v1.test.mjs | 58 +++++++++++++++- pnpm-lock.yaml | 3 + 8 files changed, 175 insertions(+), 13 deletions(-) create mode 100644 apps/r3f-hello-world/scripts/live-check.probe.ts diff --git a/apps/r3f-hello-world/package.json b/apps/r3f-hello-world/package.json index c36dfa83..89ac0f2f 100644 --- a/apps/r3f-hello-world/package.json +++ b/apps/r3f-hello-world/package.json @@ -7,10 +7,11 @@ "assets:generate": "node ./scripts/generate-fonts.mts", "assets:check": "node ./scripts/generate-fonts.mts --check", "build": "vite build", - "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm assets:check && pnpm build", + "check": "pnpm typecheck && pnpm lint && pnpm format:check && pnpm assets:check && pnpm build && pnpm live:check", "dev": "vite", "format:check": "oxfmt --check .", "lint": "oxlint --deny-warnings .", + "live:check": "vitexec --gpu ./scripts/live-check.probe.ts", "typecheck": "tsc --noEmit" }, "dependencies": { @@ -32,6 +33,7 @@ "oxfmt": "0.35.0", "oxlint": "1.75.0", "typescript": "7.0.2", - "vite": "8.1.5" + "vite": "8.1.5", + "vitexec": "0.1.17" } } diff --git a/apps/r3f-hello-world/scripts/live-check.probe.ts b/apps/r3f-hello-world/scripts/live-check.probe.ts new file mode 100644 index 00000000..9dfd82a2 --- /dev/null +++ b/apps/r3f-hello-world/scripts/live-check.probe.ts @@ -0,0 +1,66 @@ +export {}; + +const canvas = await waitForCanvas(); +for (const [technique, clientX] of [ + ['msdf', 224], + ['bitmap', 96], + ['slug', 352], +] as const) { + if (canvas.dataset.exampleTechnique !== technique) clickCanvas(canvas, clientX, 200); + await waitForTechnique(canvas, technique); + if (canvas.dataset.exampleDraws !== '2' || canvas.dataset.exampleRecords !== '11') { + throw new Error( + `${technique} rendered ${String(canvas.dataset.exampleRecords)} records in ` + + `${String(canvas.dataset.exampleDraws)} draws; expected 11 records in two Rust-planned draws`, + ); + } +} + +console.log('r3f-hello-world-live-ok'); + +async function waitForCanvas(): Promise { + for (let frame = 0; frame < 600; frame += 1) { + const value = document.querySelector('canvas'); + if (value instanceof HTMLCanvasElement) return value; + await nextFrame(); + } + throw new Error('R3F hello-world did not create a canvas'); +} + +async function waitForTechnique(targetCanvas: HTMLCanvasElement, technique: string): Promise { + for (let frame = 0; frame < 600; frame += 1) { + if (targetCanvas.dataset.exampleTechnique === technique && targetCanvas.dataset.exampleReady === 'true') return; + await nextFrame(); + } + throw new Error(`R3F hello-world did not settle the ${technique} technique`); +} + +function clickCanvas(targetCanvas: HTMLCanvasElement, clientX: number, clientY: number): void { + for (const event of [ + new PointerEvent('pointerdown', { + bubbles: true, + button: 0, + buttons: 1, + clientX, + clientY, + pointerId: 1, + pointerType: 'mouse', + }), + new PointerEvent('pointerup', { + bubbles: true, + button: 0, + buttons: 0, + clientX, + clientY, + pointerId: 1, + pointerType: 'mouse', + }), + new MouseEvent('click', { bubbles: true, button: 0, clientX, clientY }), + ]) { + targetCanvas.dispatchEvent(event); + } +} + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())); +} diff --git a/apps/r3f-hello-world/src/technique-scene.tsx b/apps/r3f-hello-world/src/technique-scene.tsx index ec4294f2..f9192ed4 100644 --- a/apps/r3f-hello-world/src/technique-scene.tsx +++ b/apps/r3f-hello-world/src/technique-scene.tsx @@ -4,7 +4,8 @@ import { bitmap } from '@pmndrs/text/three/bitmap'; import { msdf } from '@pmndrs/text/three/msdf'; import { slug } from '@pmndrs/text/three/slug'; import { useThree, type ThreeEvent } from '@react-three/fiber/webgpu'; -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef } from 'react'; +import type { Group, InstancedBufferGeometry, Mesh } from 'three/webgpu'; import iconFontUrl from '../assets/font-awesome-world.font.glb?url'; import latinFontUrl from '../assets/inter-latin.font.glb?url'; @@ -49,9 +50,32 @@ const slugIconRequest = { export function TechniqueScene({ onTechniqueChange, technique }: TechniqueSceneProps) { const viewport = useThree((state) => state.viewport); const buttonFont = useFont(msdfLatinRequest); + const root = useRef(null); + + useEffect(() => { + const canvas = document.querySelector('canvas'); + if (!(canvas instanceof HTMLCanvasElement)) throw new Error('R3F hello-world canvas is missing'); + canvas.dataset.exampleReady = 'false'; + const frame = requestAnimationFrame(() => { + const copy = root.current?.getObjectByName('r3f-example-copy'); + let draws = 0; + let records = 0; + copy?.traverse((object) => { + const mesh = object as Mesh; + if (mesh.isMesh !== true || mesh.userData.pmndrsTextRunStart === undefined) return; + draws += 1; + records += mesh.geometry.instanceCount; + }); + canvas.dataset.exampleTechnique = technique; + canvas.dataset.exampleDraws = String(draws); + canvas.dataset.exampleRecords = String(records); + canvas.dataset.exampleReady = draws === 2 && records === 11 ? 'true' : 'false'; + }); + return () => cancelAnimationFrame(frame); + }, [technique]); return ( - + @@ -100,6 +124,7 @@ function Copy({ return ( { const registry = new FontRegistry(); @@ -235,6 +241,56 @@ test('Three retires materials bound to a replaced buffer generation', async () = runtime.dispose(); }); +test('one Rust plan partitions a mixed Bitmap to Slug fallback stack', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const [latin, icon] = await Promise.all([ + runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }), + runtime.loadFont({ + input: { baked: dataUrl(gunzipSync(await readFile(iconSlugFontUrl))) }, + raster: { technique: slug, options: {} }, + }), + ]); + const realizedTechniques = []; + const material = defineTextMaterial((context) => { + realizedTechniques.push(context.technique); + return context.createDefaultMaterial(); + }); + const scene = new THREE.Scene(); + const label = new Text({ + font: createFontStack(latin, icon), + material, + text: 'Hello \uf0ac', + }); + scene.add(label); + scene.updateMatrixWorld(); + + const draws = label.children.filter((child) => child.isMesh); + assert.equal(label.error, undefined); + assert.equal(draws.length, 2, 'Rust must partition fallback glyphs by renderer program and resource'); + assert.equal( + draws.reduce((count, draw) => count + draw.geometry.instanceCount, 0), + 6, + ); + assert.deepEqual(realizedTechniques.sort(), [bitmap.id, slug.id].sort()); + assert.deepEqual( + draws.map((draw) => draw.geometry.getAttribute('_pmndrsText_2').itemSize).sort(), + [2, 4], + 'Bitmap vec2 and Slug vec4 records must coexist without a user technique selector', + ); + + label.dispose(); + latin.dispose(); + icon.dispose(); + runtime.dispose(); +}); + test('TextGroup realizes two public Text objects as one indexed Rust draw', async () => { const registry = new FontRegistry(); const instrumented = await createInstrumentedRuntime(registry); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a7070e7..73e20194 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -189,6 +189,9 @@ importers: vite: specifier: 8.1.5 version: 8.1.5(@types/node@24.13.3)(jiti@2.7.0) + vitexec: + specifier: 0.1.17 + version: 0.1.17(vite@8.1.5(@types/node@24.13.3)(jiti@2.7.0)) packages/font-baker: dependencies: From fe0df0d4cd376d9b51c7c6f20851da083fce7717 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 00:14:07 -0400 Subject: [PATCH 119/128] refactor(text): share planner invariants --- .../src/generated/package-sizes.json | 130 +++++++------- docs/log.md | 7 + docs/packages/benchmarks.md | 4 +- docs/packages/text.md | 37 ++-- .../rust/shaper/src/engine/identity_index.rs | 81 +++++++++ packages/text/rust/shaper/src/engine/mod.rs | 1 + .../rust/shaper/src/engine/ordered_plan.rs | 166 ++++------------- .../text/rust/shaper/src/engine/plan_error.rs | 64 +++++++ .../text/rust/shaper/src/engine/plan_input.rs | 22 +++ .../rust/shaper/src/engine/plan_packing.rs | 19 ++ .../shaper/src/engine/render_plan_compiler.rs | 67 +++---- .../rust/shaper/src/engine/stable_plan.rs | 167 ++++-------------- 12 files changed, 376 insertions(+), 389 deletions(-) create mode 100644 packages/text/rust/shaper/src/engine/plan_error.rs diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index e5365a13..a832a4e4 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -21,44 +21,44 @@ "label": "Text engine Wasm", "status": "measured", "format": "wasm", - "sha256": "540db0e6eb37c3135f40b0a4ec40c0a5b544080d329bef722d3069a88ff9808e", - "rawBytes": 1160323, - "minifiedBytes": 1160323, - "gzipBytes": 442570, - "brotliBytes": 348361 + "sha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576", + "rawBytes": 1159317, + "minifiedBytes": 1159317, + "gzipBytes": 442284, + "brotliBytes": 347850 }, { "id": "renderer-neutral-core-total", "label": "Renderer-neutral core total (JS + Wasm)", "status": "measured", "format": "aggregate", - "sha256": "58d2185070062f36a2c230abc2960945a6d685c1de5e8190827e8c876ab20163", - "rawBytes": 1248721, - "minifiedBytes": 1224480, - "gzipBytes": 460416, - "brotliBytes": 363830 + "sha256": "c5ab94ae437e8bea42b3d73a44042c265fa94c8797e4b2d015c41c8c2bd9cf38", + "rawBytes": 1247715, + "minifiedBytes": 1223474, + "gzipBytes": 460130, + "brotliBytes": 363319 }, { "id": "three-runtime-js", "label": "Complete Three adapter JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "3b88b8b6072b7855730479cf27291fdf36d779bd187ca4a623cc991e419246b3", - "rawBytes": 327026, - "minifiedBytes": 215670, - "gzipBytes": 55867, - "brotliBytes": 47002 + "sha256": "c4616fb2e3657b46ca6f8002873c5cb6cff3acff9372cc5324c996a24c57754a", + "rawBytes": 328001, + "minifiedBytes": 216414, + "gzipBytes": 56092, + "brotliBytes": 47280 }, { "id": "three-renderer-total", "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)", "status": "measured", "format": "aggregate", - "sha256": "a2241aa7e70b625264c7c5a98dfe4ddefa4a11790558bd3c941e3be1a3eaec40", - "rawBytes": 1487349, - "minifiedBytes": 1375993, - "gzipBytes": 498437, - "brotliBytes": 395363 + "sha256": "bf62fad5181bd845568f070d8bb338da8a20d3a98d0224b064ef1beb2cf2e5ea", + "rawBytes": 1487318, + "minifiedBytes": 1375731, + "gzipBytes": 498376, + "brotliBytes": 395130 }, { "id": "font-inter-bitmap-16-32", @@ -131,66 +131,66 @@ "label": "Three + engine + Inter Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "59d04c9b022855ab6ec0cdbebc3606f734216afde578ee216871ad02355238f4", - "rawBytes": 4636997, - "minifiedBytes": 4525641, - "gzipBytes": 1056745, - "brotliBytes": 815953 + "sha256": "f3f33aa0695912c416adbebd93beb4ed1dd12b9bcf12ee6a66bfeaa019e42e44", + "rawBytes": 4636966, + "minifiedBytes": 4525379, + "gzipBytes": 1056684, + "brotliBytes": 815720 }, { "id": "delivery-three-inter-mtsdf", "label": "Three + engine + Inter MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "63fc9b945f732839a48a7bb011d2e6d246e749445097e0f5a1fcc45c01e082b3", - "rawBytes": 40835061, - "minifiedBytes": 40723705, - "gzipBytes": 7296849, - "brotliBytes": 3635735 + "sha256": "ec41a2ae811c4e6657fdd0801e5b9c0248d982d51bb69179f67ad6ad010428ba", + "rawBytes": 40835030, + "minifiedBytes": 40723443, + "gzipBytes": 7296788, + "brotliBytes": 3635502 }, { "id": "delivery-three-inter-slug", "label": "Three + engine + Inter Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "6256468889c21d6e3ad94e4c5c3ce8f6c42a5b36af4b8c4fbbf0c048362100a9", - "rawBytes": 4932265, - "minifiedBytes": 4820909, - "gzipBytes": 1116924, - "brotliBytes": 805398 + "sha256": "3b66fe306bee1cfd79430697f1ce7bf14c4aed14c45d5ee67810fceab0c6bdef", + "rawBytes": 4932234, + "minifiedBytes": 4820647, + "gzipBytes": 1116863, + "brotliBytes": 805165 }, { "id": "delivery-three-icons-bitmap", "label": "Three + engine + Font Awesome Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "7f5bccf5142cac9a26003bdcbb8c57b39695fb087eb5f825f3de39e2e8e10007", - "rawBytes": 3869081, - "minifiedBytes": 3757725, - "gzipBytes": 948484, - "brotliBytes": 750912 + "sha256": "3374f51fcb351cb902ce00d4afa91970e02a6e5715523f095126e7de79d68ae7", + "rawBytes": 3869050, + "minifiedBytes": 3757463, + "gzipBytes": 948423, + "brotliBytes": 750679 }, { "id": "delivery-three-icons-mtsdf", "label": "Three + engine + Font Awesome MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "1368c4b698500316e45208bb004138a2bebb33d6d94c6c2d40aaddcd733248e0", - "rawBytes": 34068249, - "minifiedBytes": 33956893, - "gzipBytes": 7726261, - "brotliBytes": 3720266 + "sha256": "c90a21c9a2410861f66067aa261a132abcab6812807063ef332202dc25388691", + "rawBytes": 34068218, + "minifiedBytes": 33956631, + "gzipBytes": 7726200, + "brotliBytes": 3720033 }, { "id": "delivery-three-icons-slug", "label": "Three + engine + Font Awesome Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "675dfb1a1c294921af36f3672bd4f0d3a09014649fc12fb23ac82b548e1579a3", - "rawBytes": 4432761, - "minifiedBytes": 4321405, - "gzipBytes": 1156449, - "brotliBytes": 880361 + "sha256": "3c704205a6cafeb2fe702d22b3b7044f4fb744f3042d2b09d66ee1388a59b9f3", + "rawBytes": 4432730, + "minifiedBytes": 4321143, + "gzipBytes": 1156388, + "brotliBytes": 880128 }, { "id": "font-validator-js", @@ -230,33 +230,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "752e550807a2ea06badf83009c434354253d25ef867ae818feede223fd592340", - "rawBytes": 317079, - "minifiedBytes": 209006, - "gzipBytes": 53995, - "brotliBytes": 45526 + "sha256": "5704ebdd242ec73fad3ba0c5d8ec1978a3a459de57a05506e9a01b4bdb85cc6d", + "rawBytes": 318383, + "minifiedBytes": 209898, + "gzipBytes": 54254, + "brotliBytes": 45741 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "4b6438182124e2cf4a8046a6185202e20115d974cd2ed5a50d7d9c19b0ee730b", - "rawBytes": 317075, - "minifiedBytes": 209010, - "gzipBytes": 53994, - "brotliBytes": 45525 + "sha256": "3ea99757538600365b5737cae6c89dc27e9811dafbf20e331e471da4a0a8a3bb", + "rawBytes": 318379, + "minifiedBytes": 209902, + "gzipBytes": 54258, + "brotliBytes": 45749 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "8018a5058326569d544e98d10d2a1d445f8d3d33722311282a0250fc1f00bd23", - "rawBytes": 317077, - "minifiedBytes": 209005, - "gzipBytes": 53930, - "brotliBytes": 45471 + "sha256": "d4b02e3d781d999b2a2a43ed20e5c9c76b1b9575a09b3f27eadbfe775c73363a", + "rawBytes": 318381, + "minifiedBytes": 209897, + "gzipBytes": 54192, + "brotliBytes": 45733 }, { "id": "bitmap-baker-wasm", diff --git a/docs/log.md b/docs/log.md index b437e070..a86ad396 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,13 @@ ## 2026-08-09 +- **Deduplicated exact ordered/stable planner machinery with measured delivery savings** — A focused Mori 0.19.1 audit + identified shared identity membership, error conversion and capacity classification, cold buffer allocation, and + draw-span invariants. Rust now owns each once while keeping the distinct ordered-direct and stable-indirect address + loops local and allocation-free. The optimized shaper moves from 1,160,505 / 442,612 / 348,594 raw/gzip/Brotli bytes + to 1,159,317 / 442,284 / 347,850, saving 1,188 / 328 / 744 bytes. All 158 Rust tests pass; the 22k complete benchmark + shows no material warm-path change, and a 51-sample cold check measures 15.452 ms median / 15.670 ms p95 at 1.0% RSD. + - **Proved mixed fallback techniques and the R3F example in a live browser** — A public compiled-Wasm integration loads Bitmap Inter with Slug Font Awesome fallback and proves Rust partitions one paragraph into exact Bitmap `vec2` and Slug `vec4` program draws without a user-facing technique selector. The bounded R3F Vite example now has a durable GPU diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 5970c43f..20f6a3ff 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:7123b715655e79fbe3f0fa5028e56897c0edf56f7bd4eb0f9a0c3ba1552ff5e3' +source_digest: 'sha256:602fda2a319c918d1a04d058936d2ec9c7a4a91c327a72c7ab97ac4ddae0aedf' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -404,7 +404,7 @@ GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compa The independent package-size lane measures the initial public browser graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, and Unicode 17 analysis without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; the browser-core lane externalizes the package's declared `three`, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The report records its measurement platform and architecture plus the SHA-256 identity of each measured payload: minified bundle bytes for JavaScript and emitted module bytes for Wasm. Same-host regeneration is exact; every foreign-host raw/minified/gzip/Brotli result must satisfy the shared reviewed budget table because native Rust/Binaryen and Rolldown output has small cross-architecture byte variance. Coverage-capability growth is independently bounded against its pre-coverage baseline, and foreign-host failures report the measured payload, reviewed ceiling, and exceeded dimensions. The product inspector's selected-runtime total is the gzip transfer sum of the selected raster runtime graph and separately emitted shaper Wasm. The raster graph already contains the shared core and shaper JavaScript host, so adding the independent browser-core or text-shaper-JavaScript measurements would double-count code. Selected runtime and conditional runtime-bake totals are default-collapsed disclosures; their component rows remain available on demand without displacing the separate font-asset total. The font-asset card reports only one transport quantity: gzip bytes for compressed MTSDF artifacts and exact transferred bytes for uncompressed Bitmap/runtime-source assets. Decoded container, raster, and GPU allocation sizes never appear as children of that transfer total; GPU texture allocation remains isolated in the resource card. Each full row is the interaction target, while fixed label, status, and byte columns use a neutral centered chevron, a green check for loaded code, and a gray X for unloaded code. The total intentionally excludes external Three.js, React, and R3F peers plus font assets; those assets remain separate rows rather than being mislabeled as a complete application bundle. -The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,160,323 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 209,006 / 53,995 / 45,526, 209,010 / 53,994 / 45,525, and 209,005 / 53,930 / 45,471 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. +The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,159,317 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 209,898 / 54,254 / 45,741, 209,902 / 54,258 / 45,749, and 209,897 / 54,192 / 45,733 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. The local Worker-queue Vitexec probe authenticates every output and reports observations rather than asserting machine-sensitive timing. Two Chromium runs measured a three-font queued burst at 30.8–32.0 ms and three separately initialized sequential Workers at 68.3–88.6 ms. The correctness suite separately proves one active post, FIFO completion, queued cancellation, and active-cancellation recovery without timers. The combined live lane runs its performance observation before interaction and conformance probes so accumulated renderer work cannot contaminate cold/steady telemetry. diff --git a/docs/packages/text.md b/docs/packages/text.md index fb86b999..b5722b91 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6b57735a2d735419b5b5dbcb75becef566617d1241a5343342399bdf6dd5edc5' +source_digest: 'sha256:fc3cc8108476ab425f8bb5c9286ebc8382e2ede2faf42b13a31df2b35272f659' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -216,15 +216,19 @@ font-binding, Three execution, artifact-validation, and Unicode conformance cove packers. A Mori 0.19.1 production-source scan (review profile, same-language threshold 0.85, minimum 40 tokens) corroborated the -deleted parallel path and identified smaller repeated validation helpers. It also highlighted similar draw emission in -`ordered_plan.rs` and `stable_plan.rs`; those modules are not duplicate implementations of one behavior. Ordered-direct -compacts physical records in draw order, while stable-indirect preserves slots, publishes an order buffer, and quarantines -retirements until renderer acknowledgement. A symbol-bearing optimized build attributes 33.3 KiB of function bodies to -ordered planning and 50.1 KiB to stable planning; that is strategy-specific code, not an assertion that all 83.4 KiB are -duplicates. The identical final primitive/draw-record construction is now one deliberately out-of-line non-generic -kernel. Together with a stable dependency-scan correction, the final Wasm is 220 raw / 485 gzip / 423 Brotli bytes smaller -than the pre-extraction artifact. This establishes a real, modest compiled win; it does not infer savings from source-line -count or assume that a generic refactor would avoid monomorphization. +deleted parallel path and identified exact shared planner machinery. Ordered and stable planning now use one retained +epoch-cleared identity set, one plan-error and result-capacity classifier, one cold physical-buffer allocator, one inline +draw-span predicate, and one deliberately out-of-line final primitive/draw emitter. The optimized Wasm moved from +1,160,505 raw / 442,612 gzip / 348,594 Brotli bytes to 1,159,317 / 442,284 / 347,850, saving 1,188 / 328 / 744 bytes. + +The remaining similar bodies are not two implementations of one behavior. Ordered-direct compacts physical records in +draw order; stable-indirect preserves slots, publishes a separate order buffer, and quarantines retirements until renderer +acknowledgement. A symbol-bearing optimized build attributes 33.3 KiB of function bodies to ordered planning and 50.1 KiB +to stable planning; those complete strategy totals are upper bounds, not deduplicable byte estimates. Their draw compilers +resolve different physical address spaces. Normalizing those addresses into another staging array or dispatching through a +dynamic strategy interface would add hot-path memory traffic or indirect calls, so the audit retains the strategy-local +loops and shares their exact invariants instead. The 22k-glyph complete Rust benchmark remains within adjacent-run noise; +a 20-warmup/51-sample cold check measured 15.452 ms median / 15.670 ms p95 at 1.0% RSD. ## Current size and performance evidence @@ -232,18 +236,19 @@ The latest checked package-size record after the baker ABI cleanup reports: | Graph | Raw | gzip | Brotli | | --------------------------------------- | ----------: | --------: | --------: | -| Core JavaScript plus shaper Wasm | 1,248,903 B | 460,458 B | 364,063 B | -| Three adapter plus core and shaper Wasm | 1,487,531 B | 498,479 B | 395,596 B | +| Core JavaScript plus shaper Wasm | 1,247,715 B | 460,130 B | 363,319 B | +| Three adapter plus core and shaper Wasm | 1,487,318 B | 498,376 B | 395,130 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. -The optimized shaper is 1,160,505 raw / 442,612 gzip / 348,594 Brotli bytes. The renderer-neutral JavaScript graph is -88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 327,026 raw / 55,867 gzip / -47,002 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 +The optimized shaper is 1,159,317 raw / 442,284 gzip / 347,850 Brotli bytes. The renderer-neutral JavaScript graph is +88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 328,001 raw / 56,092 gzip / +47,280 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; the later shared-emitter and stable range-scan work reduces those totals to 460,416 and 498,437 gzip bytes. The homogeneous-policy dispatch and dirty-range -alignment correction move the current totals to 460,458 and 498,479 gzip bytes. +alignment correction moved those totals to 460,458 and 498,479 gzip bytes; the focused planner deduplication and current +Three graph now measure 460,130 and 498,376 gzip bytes. The corrected complete MTSDF baker remains 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; the earlier 52 KiB observation was a kernel-only test artifact that reused the distributable Cargo target directory. diff --git a/packages/text/rust/shaper/src/engine/identity_index.rs b/packages/text/rust/shaper/src/engine/identity_index.rs index 542fa37f..d10705ce 100644 --- a/packages/text/rust/shaper/src/engine/identity_index.rs +++ b/packages/text/rust/shaper/src/engine/identity_index.rs @@ -9,6 +9,64 @@ pub(crate) enum IdentityIndexError { DuplicateIdentity, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum IdentitySetError { + AllocationFailed, + ArithmeticOverflow, +} + +/// Reusable exact-identity membership scratch. +/// +/// Unlike [`IdentityIndex`], this stores no associated value. Plan compilers use it to reject +/// duplicate semantic identities without allocating or clearing a table on warm updates. +#[derive(Default)] +pub(crate) struct IdentitySet { + keys: Vec, + epochs: Vec, + epoch: u32, +} + +impl IdentitySet { + pub(crate) fn prepare(&mut self, entry_count: usize) -> Result<(), IdentitySetError> { + let required = entry_count + .checked_mul(2) + .and_then(usize::checked_next_power_of_two) + .ok_or(IdentitySetError::ArithmeticOverflow)? + .max(8); + if self.keys.len() < required { + let additional_keys = required - self.keys.len(); + let additional_epochs = required - self.epochs.len(); + reserve(&mut self.keys, additional_keys).map_err(identity_set_error)?; + reserve(&mut self.epochs, additional_epochs).map_err(identity_set_error)?; + self.keys.resize(required, 0); + self.epochs.resize(required, 0); + } + self.epoch = next_epoch(&mut self.epochs, self.epoch); + Ok(()) + } + + pub(crate) fn insert(&mut self, identity: u32) -> bool { + let mask = self.keys.len() - 1; + let mut index = hash(identity) & mask; + loop { + if self.epochs[index] != self.epoch { + self.epochs[index] = self.epoch; + self.keys[index] = identity; + return true; + } + if self.keys[index] == identity { + return false; + } + index = (index + 1) & mask; + } + } + + #[cfg(test)] + pub(crate) fn capacities(&self) -> [usize; 2] { + [self.keys.capacity(), self.epochs.capacity()] + } +} + #[derive(Default)] pub(crate) struct IdentityIndex { keys: Vec, @@ -109,6 +167,15 @@ fn reserve(values: &mut Vec, additional: usize) -> Result<(), IdentityInde .map_err(|_| IdentityIndexError::AllocationFailed) } +fn identity_set_error(error: IdentityIndexError) -> IdentitySetError { + match error { + IdentityIndexError::AllocationFailed => IdentitySetError::AllocationFailed, + IdentityIndexError::ArithmeticOverflow | IdentityIndexError::DuplicateIdentity => { + IdentitySetError::ArithmeticOverflow + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -132,4 +199,18 @@ mod tests { assert_eq!(index.capacities(), capacities); assert_eq!(index.get(1), None); } + + #[test] + fn identity_set_rejects_duplicates_and_reuses_storage() { + let mut set = IdentitySet::default(); + set.prepare(3).unwrap(); + let capacities = set.capacities(); + assert!(set.insert(1)); + assert!(set.insert(9)); + assert!(!set.insert(1)); + + set.prepare(3).unwrap(); + assert_eq!(set.capacities(), capacities); + assert!(set.insert(1)); + } } diff --git a/packages/text/rust/shaper/src/engine/mod.rs b/packages/text/rust/shaper/src/engine/mod.rs index e5059f87..55699565 100644 --- a/packages/text/rust/shaper/src/engine/mod.rs +++ b/packages/text/rust/shaper/src/engine/mod.rs @@ -29,6 +29,7 @@ pub(crate) mod transport; pub mod ordered_plan; mod plan_draw; +mod plan_error; pub mod plan_input; mod plan_packing; pub mod policy; diff --git a/packages/text/rust/shaper/src/engine/ordered_plan.rs b/packages/text/rust/shaper/src/engine/ordered_plan.rs index ad6e32ed..80c02a8a 100644 --- a/packages/text/rust/shaper/src/engine/ordered_plan.rs +++ b/packages/text/rust/shaper/src/engine/ordered_plan.rs @@ -8,20 +8,21 @@ use alloc::vec::Vec; use core::mem; use super::{ - plan_draw::{GlyphDraw, PlanDrawError, push_glyph_draw}, + identity_index::IdentitySet, + plan_draw::{GlyphDraw, push_glyph_draw}, plan_input::{ - PlanInputError, draw_fields_compatible, indexed_span_bounds, span_bounds, validate_glyph, - validate_input, + draw_fields_compatible, draw_span_compatible, indexed_span_bounds, span_bounds, + validate_glyph, validate_input, }, plan_packing::{ - MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RangeJob, - RecordRange, align_record_range, align_up, apply_writes, buffer_record_alignment, - coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, record_alignment, - take_allocation, + MAX_PHYSICAL_BUFFERS, PendingAllocation, PhysicalBufferState, RangeJob, RecordRange, + align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, + push_pending_allocation, record_alignment, take_allocation, }, policy::{ ALLOCATION_ORDERED_DIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BufferSchema, CapabilitySetId, - PolicyExecutionError, TechniqueId, ValidatedPolicy, + TechniqueId, ValidatedPolicy, }, render_plan::{ BUFFER_ORDERED_DIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, @@ -31,6 +32,8 @@ use super::{ }, }; +pub use super::plan_error::PlanError as OrderedPlanError; + #[cfg(test)] use super::render_plan::PRIMITIVE_GLYPH; @@ -38,55 +41,6 @@ pub use super::plan_input::{PlanGlyph as OrderedGlyph, PlanInput as OrderedPlanI const NONE: u32 = u32::MAX; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum OrderedPlanError { - AllocationFailed, - AlreadyPrepared, - NotPrepared, - CapabilitySetMissing, - ProgramMissing, - UnsupportedStrategy, - InvalidInputShape, - InvalidIdentity, - DuplicateIdentity, - InvalidResource, - CapacityExceeded, - IdentifierExhausted, - ArithmeticOverflow, - PolicyExecution(PolicyExecutionError), -} - -impl From for OrderedPlanError { - fn from(error: PlanInputError) -> Self { - match error { - PlanInputError::InvalidShape => Self::InvalidInputShape, - PlanInputError::InvalidIdentity => Self::InvalidIdentity, - PlanInputError::InvalidResource => Self::InvalidResource, - } - } -} - -impl From for OrderedPlanError { - fn from(error: PackingError) -> Self { - match error { - PackingError::AllocationFailed => Self::AllocationFailed, - PackingError::ArithmeticOverflow => Self::ArithmeticOverflow, - PackingError::CapacityExceeded => Self::CapacityExceeded, - PackingError::InvalidIdentity => Self::InvalidIdentity, - PackingError::Policy(error) => Self::PolicyExecution(error), - } - } -} - -impl From for OrderedPlanError { - fn from(error: PlanDrawError) -> Self { - match error { - PlanDrawError::AllocationFailed => Self::AllocationFailed, - PlanDrawError::ArithmeticOverflow => Self::ArithmeticOverflow, - } - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct BatchKey { technique: TechniqueId, @@ -150,9 +104,7 @@ pub struct OrderedPlanCompiler { pending_allocations: Vec, input_batches: Vec, input_slots: Vec, - identity_keys: Vec, - identity_epochs: Vec, - identity_epoch: u32, + identity_set: IdentitySet, batch_cursors: Vec, changed_ranges: Vec, buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], @@ -429,49 +381,6 @@ impl OrderedPlanCompiler { self.publish_bindings = false; } - fn prepare_identity_set(&mut self, count: usize) -> Result<(), OrderedPlanError> { - let required = count - .checked_mul(2) - .and_then(|value| value.checked_next_power_of_two()) - .unwrap_or(usize::MAX) - .max(8); - if required == usize::MAX { - return Err(OrderedPlanError::ArithmeticOverflow); - } - if self.identity_keys.len() < required { - let additional_keys = required - self.identity_keys.len(); - let additional_epochs = required - self.identity_epochs.len(); - reserve(&mut self.identity_keys, additional_keys)?; - reserve(&mut self.identity_epochs, additional_epochs)?; - self.identity_keys.resize(required, 0); - self.identity_epochs.resize(required, 0); - } - self.identity_epoch = match self.identity_epoch.checked_add(1) { - Some(epoch) => epoch, - None => { - self.identity_epochs.fill(0); - 1 - } - }; - Ok(()) - } - - fn insert_identity(&mut self, identity: u32) -> bool { - let mask = self.identity_keys.len() - 1; - let mut slot = (identity.wrapping_mul(0x9e37_79b1) as usize) & mask; - loop { - if self.identity_epochs[slot] != self.identity_epoch { - self.identity_epochs[slot] = self.identity_epoch; - self.identity_keys[slot] = identity; - return true; - } - if self.identity_keys[slot] == identity { - return false; - } - slot = (slot + 1) & mask; - } - } - fn prepare_complete_topology( &mut self, policy: &ValidatedPolicy, @@ -485,11 +394,11 @@ impl OrderedPlanCompiler { self.input_batches.resize(input.glyphs.len(), NONE); self.input_batches.fill(NONE); self.input_slots.resize(input.glyphs.len(), 0); - self.prepare_identity_set(input.glyphs.len())?; + self.identity_set.prepare(input.glyphs.len())?; for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { validate_glyph(glyph)?; - if !self.insert_identity(glyph.stable_id) { + if !self.identity_set.insert(glyph.stable_id) { return Err(OrderedPlanError::DuplicateIdentity); } let program = policy @@ -563,10 +472,10 @@ impl OrderedPlanCompiler { { return Ok(false); } - self.prepare_identity_set(input.glyphs.len())?; + self.identity_set.prepare(input.glyphs.len())?; for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { validate_glyph(glyph)?; - if !self.insert_identity(glyph.stable_id) { + if !self.identity_set.insert(glyph.stable_id) { return Err(OrderedPlanError::DuplicateIdentity); } let batch_index = self.input_batches[input_index]; @@ -783,7 +692,14 @@ impl OrderedPlanCompiler { .ok_or(OrderedPlanError::ArithmeticOverflow)?, ..PatchRecord::default() }); - self.prepare_allocation(id, generation, key.program_id, schema, capacity)?; + push_pending_allocation( + &mut self.pending_allocations, + id, + generation, + key.program_id, + schema, + capacity, + )?; if new_or_resized && let Some((previous_id, previous_generation, previous_length)) = previous { @@ -1004,21 +920,6 @@ impl OrderedPlanCompiler { Ok(()) } - fn prepare_allocation( - &mut self, - id: u32, - generation: u32, - program_id: u32, - schema: BufferSchema, - capacity: u32, - ) -> Result<(), OrderedPlanError> { - reserve(&mut self.pending_allocations, 1)?; - self.pending_allocations.push(PendingAllocation { - state: PhysicalBufferState::new(id, generation, program_id, schema, capacity)?, - }); - Ok(()) - } - fn compile_bindings(&mut self, context: PrepareContext<'_>) -> Result<(), OrderedPlanError> { for batch_index in 0..self.pending_batches.len() { let batch = self.pending_batches[batch_index]; @@ -1303,13 +1204,14 @@ impl OrderedPlanCompiler { ) -> bool { let first = glyphs[start]; let glyph = glyphs[next]; - self.input_batches[next] as usize == batch_index - && self.input_slots[next] == first_slot + (next - start) as u32 - && glyph.technique == first.technique - && glyph.program_variant == first.program_variant - && glyph.resource_id == first.resource_id - && glyph.resource_generation == first.resource_generation - && draw_fields_compatible(first, glyph, split_material, split_transform) + draw_span_compatible( + first, + glyph, + self.input_batches[next] as usize == batch_index, + self.input_slots[next] == first_slot + (next - start) as u32, + split_material, + split_transform, + ) } fn prepare_removed_batches( @@ -2078,8 +1980,8 @@ mod tests { compiler.pending_allocations.capacity(), compiler.input_batches.capacity(), compiler.input_slots.capacity(), - compiler.identity_keys.capacity(), - compiler.identity_epochs.capacity(), + compiler.identity_set.capacities()[0], + compiler.identity_set.capacities()[1], compiler.batch_cursors.capacity(), compiler.changed_ranges.capacity(), compiler.resources.capacity(), diff --git a/packages/text/rust/shaper/src/engine/plan_error.rs b/packages/text/rust/shaper/src/engine/plan_error.rs new file mode 100644 index 00000000..76aecbde --- /dev/null +++ b/packages/text/rust/shaper/src/engine/plan_error.rs @@ -0,0 +1,64 @@ +//! Error contract shared by render-plan storage strategies. + +use super::{ + identity_index::IdentitySetError, plan_draw::PlanDrawError, plan_input::PlanInputError, + plan_packing::PackingError, policy::PolicyExecutionError, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PlanError { + AllocationFailed, + AlreadyPrepared, + NotPrepared, + CapabilitySetMissing, + ProgramMissing, + UnsupportedStrategy, + InvalidInputShape, + InvalidIdentity, + DuplicateIdentity, + InvalidResource, + CapacityExceeded, + IdentifierExhausted, + ArithmeticOverflow, + PolicyExecution(PolicyExecutionError), +} + +impl From for PlanError { + fn from(error: PlanInputError) -> Self { + match error { + PlanInputError::InvalidShape => Self::InvalidInputShape, + PlanInputError::InvalidIdentity => Self::InvalidIdentity, + PlanInputError::InvalidResource => Self::InvalidResource, + } + } +} + +impl From for PlanError { + fn from(error: PackingError) -> Self { + match error { + PackingError::AllocationFailed => Self::AllocationFailed, + PackingError::ArithmeticOverflow => Self::ArithmeticOverflow, + PackingError::CapacityExceeded => Self::CapacityExceeded, + PackingError::InvalidIdentity => Self::InvalidIdentity, + PackingError::Policy(error) => Self::PolicyExecution(error), + } + } +} + +impl From for PlanError { + fn from(error: PlanDrawError) -> Self { + match error { + PlanDrawError::AllocationFailed => Self::AllocationFailed, + PlanDrawError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} + +impl From for PlanError { + fn from(error: IdentitySetError) -> Self { + match error { + IdentitySetError::AllocationFailed => Self::AllocationFailed, + IdentitySetError::ArithmeticOverflow => Self::ArithmeticOverflow, + } + } +} diff --git a/packages/text/rust/shaper/src/engine/plan_input.rs b/packages/text/rust/shaper/src/engine/plan_input.rs index e98f8376..a7e07ad8 100644 --- a/packages/text/rust/shaper/src/engine/plan_input.rs +++ b/packages/text/rust/shaper/src/engine/plan_input.rs @@ -139,3 +139,25 @@ pub fn draw_fields_compatible( && next.depth_key == first.depth_key && (!split_transform || next.transform_id == first.transform_id) } + +/// Tests the shared logical and renderer-key invariants for extending a physical draw span. +/// +/// Storage planners resolve batch membership and physical contiguity differently, so those two +/// facts remain caller-owned. This stays inline because it runs once per candidate glyph. +#[inline(always)] +pub fn draw_span_compatible( + first: PlanGlyph, + next: PlanGlyph, + same_batch: bool, + contiguous: bool, + split_material: bool, + split_transform: bool, +) -> bool { + same_batch + && contiguous + && next.technique == first.technique + && next.program_variant == first.program_variant + && next.resource_id == first.resource_id + && next.resource_generation == first.resource_generation + && draw_fields_compatible(first, next, split_material, split_transform) +} diff --git a/packages/text/rust/shaper/src/engine/plan_packing.rs b/packages/text/rust/shaper/src/engine/plan_packing.rs index 728dce4b..4ec80b79 100644 --- a/packages/text/rust/shaper/src/engine/plan_packing.rs +++ b/packages/text/rust/shaper/src/engine/plan_packing.rs @@ -47,6 +47,25 @@ pub struct PendingAllocation { pub state: PhysicalBufferState, } +/// Allocates one retained physical buffer through the shared cold growth path. +#[inline(never)] +pub fn push_pending_allocation( + allocations: &mut alloc::vec::Vec, + id: u32, + generation: u32, + program_id: u32, + schema: super::policy::BufferSchema, + capacity: u32, +) -> Result<(), PackingError> { + allocations + .try_reserve(1) + .map_err(|_| PackingError::AllocationFailed)?; + allocations.push(PendingAllocation { + state: PhysicalBufferState::new(id, generation, program_id, schema, capacity)?, + }); + Ok(()) +} + impl PhysicalBufferState { pub fn new( id: u32, diff --git a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs index 3a88c2ed..d3fa06c1 100644 --- a/packages/text/rust/shaper/src/engine/render_plan_compiler.rs +++ b/packages/text/rust/shaper/src/engine/render_plan_compiler.rs @@ -7,7 +7,8 @@ use alloc::vec::Vec; use super::{ - ordered_plan::{OrderedPlanCompiler, OrderedPlanError}, + ordered_plan::OrderedPlanCompiler, + plan_error::PlanError, plan_input::{PlanInput, PlanInputError, validate_input}, policy::{ ALLOCATION_ORDERED_DIRECT, ALLOCATION_STABLE_INDIRECT, CapabilitySetId, @@ -18,7 +19,7 @@ use super::{ RESOURCE_ACTION_RETAIN, RESOURCE_ACTION_UPDATE, RETIRE_RESOURCE, RenderPlanView, ResourceRecord, RetirementRecord, }, - stable_plan::{StablePlanCompiler, StablePlanError}, + stable_plan::StablePlanCompiler, }; const ORDERED_BUFFER_ID_LIMIT: u32 = 0x7fff_ffff; @@ -37,8 +38,7 @@ pub enum RenderPlanCompilerError { InvalidResource, InvalidPlan, ArithmeticOverflow, - Ordered(OrderedPlanError), - Stable(StablePlanError), + Plan(PlanError), } impl From for RenderPlanCompilerError { @@ -51,15 +51,9 @@ impl From for RenderPlanCompilerError { } } -impl From for RenderPlanCompilerError { - fn from(error: OrderedPlanError) -> Self { - Self::Ordered(error) - } -} - -impl From for RenderPlanCompilerError { - fn from(error: StablePlanError) -> Self { - Self::Stable(error) +impl From for RenderPlanCompilerError { + fn from(error: PlanError) -> Self { + Self::Plan(error) } } @@ -76,8 +70,7 @@ impl RenderPlanCompilerError { | Self::InvalidIdentity | Self::InvalidResource | Self::InvalidPlan => false, - Self::Ordered(error) => ordered_result_too_large(error), - Self::Stable(error) => stable_result_too_large(error), + Self::Plan(error) => plan_result_too_large(error), } } } @@ -95,33 +88,23 @@ fn policy_result_too_large(error: PolicyExecutionError) -> bool { } } -macro_rules! classify_plan_error { - ($error:expr, $error_type:ident) => { - match $error { - $error_type::AllocationFailed - | $error_type::CapacityExceeded - | $error_type::IdentifierExhausted - | $error_type::ArithmeticOverflow => true, - $error_type::AlreadyPrepared - | $error_type::NotPrepared - | $error_type::CapabilitySetMissing - | $error_type::ProgramMissing - | $error_type::UnsupportedStrategy - | $error_type::InvalidInputShape - | $error_type::InvalidIdentity - | $error_type::DuplicateIdentity - | $error_type::InvalidResource => false, - $error_type::PolicyExecution(error) => policy_result_too_large(error), - } - }; -} - -fn ordered_result_too_large(error: OrderedPlanError) -> bool { - classify_plan_error!(error, OrderedPlanError) -} - -fn stable_result_too_large(error: StablePlanError) -> bool { - classify_plan_error!(error, StablePlanError) +fn plan_result_too_large(error: PlanError) -> bool { + match error { + PlanError::AllocationFailed + | PlanError::CapacityExceeded + | PlanError::IdentifierExhausted + | PlanError::ArithmeticOverflow => true, + PlanError::AlreadyPrepared + | PlanError::NotPrepared + | PlanError::CapabilitySetMissing + | PlanError::ProgramMissing + | PlanError::UnsupportedStrategy + | PlanError::InvalidInputShape + | PlanError::InvalidIdentity + | PlanError::DuplicateIdentity + | PlanError::InvalidResource => false, + PlanError::PolicyExecution(error) => policy_result_too_large(error), + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] diff --git a/packages/text/rust/shaper/src/engine/stable_plan.rs b/packages/text/rust/shaper/src/engine/stable_plan.rs index 875815a3..09b2f7ad 100644 --- a/packages/text/rust/shaper/src/engine/stable_plan.rs +++ b/packages/text/rust/shaper/src/engine/stable_plan.rs @@ -9,21 +9,22 @@ use alloc::vec::Vec; use core::mem; use super::{ - plan_draw::{GlyphDraw, PlanDrawError, push_glyph_draw}, + identity_index::IdentitySet, + plan_draw::{GlyphDraw, push_glyph_draw}, plan_input::{ - PlanInputError, draw_fields_compatible, indexed_span_bounds, span_bounds, validate_glyph, - validate_input, + draw_fields_compatible, draw_span_compatible, indexed_span_bounds, span_bounds, + validate_glyph, validate_input, }, plan_packing::{ - MAX_PHYSICAL_BUFFERS, PackingError, PendingAllocation, PhysicalBufferState, RangeJob, - RecordRange, align_record_range, align_up, apply_writes, buffer_record_alignment, - coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, record_alignment, - take_allocation, + MAX_PHYSICAL_BUFFERS, PendingAllocation, PhysicalBufferState, RangeJob, RecordRange, + align_record_range, align_up, apply_writes, buffer_record_alignment, + coalesce_buffer_ranges, collect_range_jobs, execute_run, grown_capacity, + push_pending_allocation, record_alignment, take_allocation, }, policy::{ ALLOCATION_STABLE_INDIRECT, BATCH_MATERIAL, BATCH_TRANSFORM, BUFFER_USAGE_COPY_DST, - BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CapabilitySetId, PolicyExecutionError, - ScalarType, TechniqueId, ValidatedPolicy, + BUFFER_USAGE_STORAGE, BufferId, BufferSchema, CapabilitySetId, ScalarType, TechniqueId, + ValidatedPolicy, }, render_plan::{ BUFFER_STABLE_INDIRECT, BufferRecord, DrawRecord, PATCH_ALLOCATE_OR_RESIZE, PATCH_WRITE, @@ -37,59 +38,12 @@ use super::{ stable_pool::{SlotIdentity, StablePoolError, StableSlotPool}, }; +pub use super::plan_error::PlanError as StablePlanError; + pub use super::plan_input::{PlanGlyph as StableGlyph, PlanInput as StablePlanInput}; const NONE: u32 = u32::MAX; -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum StablePlanError { - AllocationFailed, - AlreadyPrepared, - NotPrepared, - CapabilitySetMissing, - ProgramMissing, - UnsupportedStrategy, - InvalidInputShape, - InvalidIdentity, - DuplicateIdentity, - InvalidResource, - CapacityExceeded, - IdentifierExhausted, - ArithmeticOverflow, - PolicyExecution(PolicyExecutionError), -} - -impl From for StablePlanError { - fn from(error: PlanInputError) -> Self { - match error { - PlanInputError::InvalidShape => Self::InvalidInputShape, - PlanInputError::InvalidIdentity => Self::InvalidIdentity, - PlanInputError::InvalidResource => Self::InvalidResource, - } - } -} - -impl From for StablePlanError { - fn from(error: PackingError) -> Self { - match error { - PackingError::AllocationFailed => Self::AllocationFailed, - PackingError::ArithmeticOverflow => Self::ArithmeticOverflow, - PackingError::CapacityExceeded => Self::CapacityExceeded, - PackingError::InvalidIdentity => Self::InvalidIdentity, - PackingError::Policy(error) => Self::PolicyExecution(error), - } - } -} - -impl From for StablePlanError { - fn from(error: PlanDrawError) -> Self { - match error { - PlanDrawError::AllocationFailed => Self::AllocationFailed, - PlanDrawError::ArithmeticOverflow => Self::ArithmeticOverflow, - } - } -} - impl From for StablePlanError { fn from(error: StablePoolError) -> Self { match error { @@ -256,9 +210,7 @@ pub struct StablePlanCompiler { changed_ranges: Vec, buffer_ranges: [Vec; MAX_PHYSICAL_BUFFERS], range_jobs: Vec, - identity_keys: Vec, - identity_epochs: Vec, - identity_epoch: u32, + identity_set: IdentitySet, pending_allocations: Vec, resources: Vec, plan_buffers: Vec, @@ -384,7 +336,7 @@ impl StablePlanCompiler { } self.reset_pending(); self.pending_publication_generation = publication_generation; - self.prepare_identity_set(input.glyphs.len())?; + self.identity_set.prepare(input.glyphs.len())?; reserve(&mut self.input_batches, input.glyphs.len())?; reserve(&mut self.input_slots, input.glyphs.len())?; reserve(&mut self.input_order_records, input.glyphs.len())?; @@ -396,7 +348,7 @@ impl StablePlanCompiler { for (input_index, glyph) in input.glyphs.iter().copied().enumerate() { validate_glyph(glyph)?; - if !self.insert_identity(glyph.stable_id) { + if !self.identity_set.insert(glyph.stable_id) { return Err(StablePlanError::DuplicateIdentity); } let program = policy @@ -816,7 +768,14 @@ impl StablePlanCompiler { self.pending_batches[pending_index].buffer_ids[schema_index] = id; self.pending_batches[pending_index].buffer_generations[schema_index] = generation; if replace { - self.allocate_buffer(id, generation, key.program_id, schema, capacity)?; + push_pending_allocation( + &mut self.pending_allocations, + id, + generation, + key.program_id, + schema, + capacity, + )?; reserve(&mut self.patches, 1)?; self.patches.push(PatchRecord { opcode: PATCH_ALLOCATE_OR_RESIZE, @@ -1045,7 +1004,8 @@ impl StablePlanCompiler { self.pending_batches[pending_index].order_capacity = order_capacity; if replace { let key = self.batches[batch_index].key; - self.allocate_buffer( + push_pending_allocation( + &mut self.pending_allocations, id, generation, key.program_id, @@ -1576,13 +1536,14 @@ impl StablePlanCompiler { ) -> bool { let first = glyphs[start]; let glyph = glyphs[next]; - self.input_batches[next] as usize == pending_index - && self.input_order_records[next] == first_record + (next - start) as u32 - && glyph.technique == first.technique - && glyph.program_variant == first.program_variant - && glyph.resource_id == first.resource_id - && glyph.resource_generation == first.resource_generation - && draw_fields_compatible(first, glyph, split_material, split_transform) + draw_span_compatible( + first, + glyph, + self.input_batches[next] as usize == pending_index, + self.input_order_records[next] == first_record + (next - start) as u32, + split_material, + split_transform, + ) } fn next_buffer_identity( @@ -1609,21 +1570,6 @@ impl StablePlanCompiler { Ok((self.pending_next_buffer_id, 1)) } - fn allocate_buffer( - &mut self, - id: u32, - generation: u32, - program_id: u32, - schema: BufferSchema, - capacity: u32, - ) -> Result<(), StablePlanError> { - reserve(&mut self.pending_allocations, 1)?; - self.pending_allocations.push(PendingAllocation { - state: PhysicalBufferState::new(id, generation, program_id, schema, capacity)?, - }); - Ok(()) - } - fn retire_buffer( &mut self, id: u32, @@ -1697,49 +1643,6 @@ impl StablePlanCompiler { batch.order_buffer = Some(order_buffer); Ok(()) } - - fn prepare_identity_set(&mut self, count: usize) -> Result<(), StablePlanError> { - let required = count - .checked_mul(2) - .and_then(usize::checked_next_power_of_two) - .unwrap_or(usize::MAX) - .max(8); - if required == usize::MAX { - return Err(StablePlanError::ArithmeticOverflow); - } - if self.identity_keys.len() < required { - let additional_keys = required - self.identity_keys.len(); - let additional_epochs = required - self.identity_epochs.len(); - reserve(&mut self.identity_keys, additional_keys)?; - reserve(&mut self.identity_epochs, additional_epochs)?; - self.identity_keys.resize(required, 0); - self.identity_epochs.resize(required, 0); - } - self.identity_epoch = match self.identity_epoch.checked_add(1) { - Some(epoch) => epoch, - None => { - self.identity_epochs.fill(0); - 1 - } - }; - Ok(()) - } - - fn insert_identity(&mut self, identity: u32) -> bool { - let mask = self.identity_keys.len() - 1; - let mut slot = (identity.wrapping_mul(0x9e37_79b1) as usize) & mask; - loop { - if self.identity_epochs[slot] != self.identity_epoch { - self.identity_epochs[slot] = self.identity_epoch; - self.identity_keys[slot] = identity; - return true; - } - if self.identity_keys[slot] == identity { - return false; - } - slot = (slot + 1) & mask; - } - } } fn stable_active_buffers( @@ -2421,8 +2324,8 @@ mod tests { compiler.order_chunk_scratch.capacity(), compiler.slot_writes.capacity(), compiler.changed_ranges.capacity(), - compiler.identity_keys.capacity(), - compiler.identity_epochs.capacity(), + compiler.identity_set.capacities()[0], + compiler.identity_set.capacities()[1], compiler.pending_allocations.capacity(), compiler.resources.capacity(), compiler.plan_buffers.capacity(), From 8eddc118bb9d8e356f38afd2409e2cc93fdf3c45 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 00:31:02 -0400 Subject: [PATCH 120/128] docs(text): refresh foundation evidence --- docs/log.md | 11 +++++++++++ docs/packages/benchmarks.md | 7 +++++++ docs/packages/text.md | 17 ++++++++++++++++- docs/planning/rust-layout-engine.md | 12 ++++++++---- .../text/scripts/benchmark-paragraph-layout.mts | 4 ++-- 5 files changed, 44 insertions(+), 7 deletions(-) diff --git a/docs/log.md b/docs/log.md index a86ad396..0201f040 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,16 @@ # pmndrs/text documentation update log +## 2026-08-10 + +- **Passed the complete foundation gate and refreshed release evidence** — The root check passes 158 Rust library tests, + 164 Node integration tests, unchanged Unicode 17 vectors, 111 benchmark-app tests, all 16 isolated Chromium targets, + production builds, the R3F live GPU interaction, formatting, lint, types, packaging, and OKF validation. Sequential + eight-warmup/31-sample 25,515-positioned-glyph runs put Bitmap/MTSDF/Slug cold medians at 16.02/16.60/16.83 ms, + font-size at 6.01/6.42/6.67 ms, column width at 2.77/2.77/2.87 ms, and suffix edits at 13.73/13.75/13.59 ms. Every + comparable median beats the retained TypeScript checkpoint; the p95-under-4-ms optimization target remains open. + The live 11,510-glyph Paragraph Stress probe holds one draw and 121 RAF FPS while attributing 5.725/7.405 ms + median/p95 to public text update-and-measure versus 0.405/0.905 ms for renderer submission. + ## 2026-08-09 - **Deduplicated exact ordered/stable planner machinery with measured delivery savings** — A focused Mori 0.19.1 audit diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 20f6a3ff..8b122cab 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -493,6 +493,13 @@ The showcase corpus is an immutable TypeScript discriminated union with exact in The CI-safe advanced-shaping target derives all 68 finite frames from that same corpus and sends each through the public `Text` object and bitmap batch construction at an explicit 800 CSS-pixel viewport and 16 px font size. Its exact Chromium 149 record covers five cases, 709 laid-out glyphs, 625 rendered instances, 72 draws, zero missing glyphs, 17,362 normalized layout bytes, and composite hash `51ba1d14`; a wrong hash and a missing-glyph mutation are negative controls. The three recorded 8.5–11.3 ms durations describe this machine's end-to-end conformance execution only. They are neither live renderer costs nor portability thresholds. Hardware GPU pixels remain owned by the exact bitmap readback lane, while the admitted Vitexec product probe proves that each authenticated showcase fixture reaches the live WebGPU canvas. +The foundation closure gate executes 111 Vitest cases and 16 isolated headless Chromium targets from the current +manifest, including forced-WebGL2 Bitmap/MTSDF/Slug, conformance, source-outline, React reconciliation, Worker fallback, +paragraph contracts, advanced shaping, and rich spans. A package-owned live Paragraph Stress timing run retains 11,510 +glyphs in one draw at 121 RAF FPS: frame p95 is 8.66 ms, renderer submission median/p95 is 0.405/0.905 ms, and the public +text update-and-measure median/p95 is 5.725/7.405 ms. This attributes the remaining live CPU cost to text preparation +rather than GPU submission; it is one local observation, not a portable budget guarantee. + The separate live performance observation runs the human WebGPU surface at explicit 1× DPR on Chromium 149 and an Apple `metal-3` adapter. Each paragraph-scale script lane must settle its exact authored state with zero missing glyphs and then publish twelve causal FPS and GPU-report intervals; there are no sleeps or timing thresholds. The refreshed run observed 119.46–120.16 FPS, 0.2–0.3 ms median CPU submission, 0.3–0.5 ms CPU P95, 0.679–3.457 ms median GPU time, and 3.261–5.033 ms GPU P95 across 112–278 glyphs and one to fifteen draws. Initial public `Text` readiness was 7.2–22.0 ms and total startup 17.0–122.9 ms; the first cold Inter fetch dominates the high end. `Text.ready` includes shaping, paragraph layout, and bitmap-batch publication, so it is not mislabeled as a pure shape call; the dedicated HarfRust target owns that narrower metric. These machine observations are authenticated evidence, not cross-device budgets. The package-size lane measures the item 8.1 MTSDF kernel separately from the coverage-capable item 8.6 baker and every initial browser or unrelated raster graph. The validated generator host is 11,543 raw, 8,466 minified, 2,658 gzip, and 2,364 Brotli bytes; the corrected optimized scalar kernel is 52,633 raw, 23,115 gzip, and 19,660 Brotli bytes. The complete MTSDF baker adds Fontations, bounded face-resolved coverage, and artifact packaging behind the optional subpath and measures 552,025 raw, 215,030 gzip, and 168,758 Brotli Wasm bytes plus a 26,940 raw / 19,117 minified / 5,530 gzip / 4,908 Brotli host. The Bitmap baker with the same coverage contract measures 626,940 raw, 234,735 gzip, and 180,503 Brotli Wasm bytes. The private TypeScript diagnostic entry is neither packed nor reachable from production graphs, and Rust profiling remains a non-default feature; the size lane rejects diagnostic code in shipped baker graphs and profiling/timing Wasm boundaries. The final `Text` lifecycle remediations move the browser-core graph from 329,665 / 251,133 / 73,068 / 56,025 to 330,343 raw / 251,524 minified / 73,143 gzip / 56,131 Brotli bytes without changing any baker host or Wasm artifact. A separate pre-coverage regression table bounds the accepted growth of browser core, both optional hosts and runtimes, and both baker Wasm modules in every measured representation. Complete reviewed ceilings apply on foreign hosts, while same-host regeneration must remain byte-exact. `pnpm scripts run text:mtsdf-generator-profile` additionally reports compile, initialization, cold-corpus, and warm-corpus observations only after all seven independent oracle hashes pass; it is generator evidence, not frame-rendering performance. Rejected SIMD variant reports remain historical decision evidence rather than maintained browser capture workflows. diff --git a/docs/packages/text.md b/docs/packages/text.md index b5722b91..0e5e0478 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:fc3cc8108476ab425f8bb5c9286ebc8382e2ede2faf42b13a31df2b35272f659' +source_digest: 'sha256:600087bce3db441d2b24107f64609647660be3fd2baa4e49f1d510d383469ed8' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -274,6 +274,21 @@ unit, default-on `simd128`, stripping, and `wasm-opt -Oz --enable-simd` have alr Binaryen `-O3` and `-O4` added 11,976 and 13,661 raw bytes without a demonstrated latency improvement, so `-Oz` remains the evidence-backed setting. The `<4 ms` warm-path target and stable p95 closure remain open. +The final sequential eight-warmup/31-sample checkpoint uses the unchanged 22,000-target corpus, which resolves to 25,515 +positioned and 21,805 renderable glyphs. Values below are medians in milliseconds for the complete packaged Rust +transaction and technique-specific render plan; GPU submission is outside this direct benchmark. + +| Technique | Cold | Font size | Column width | Suffix edit | Local edit | Middle splice | +| --------- | ----: | --------: | -----------: | ----------: | ---------: | ------------: | +| Bitmap | 16.02 | 6.01 | 2.77 | 13.73 | 1.19 | 8.35 | +| MTSDF | 16.60 | 6.42 | 2.77 | 13.75 | 1.20 | 8.75 | +| Slug | 16.83 | 6.67 | 2.87 | 13.59 | 1.19 | 8.84 | + +The comparable retained TypeScript checkpoint measured 55.25/11.90/8.36/38.55 ms for cold/font-size/width/suffix edit, +so every comparable median is faster through Rust. This proves the migration comparison on this machine; it does not +close the stricter p95-under-4-ms objective. Local-edit p95 remains about 6 ms and high-variance, while width p95 ranges +from 4.29 to 4.94 ms across techniques. + The preceding unchanged 22,000-glyph localized-edit lane measured the complete production `text_update` plus Bitmap render plan at 2.607 ms median / 6.184 ms p95 after 40 warmups over 101 updates. The fast ASCII-letter path reuses Unicode and bidi state and recomposes until the line cursor converges; punctuation and spacing edits deliberately retain the full diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 1ef6c4c1..2d2a1846 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1528,12 +1528,16 @@ not an ellipsis-only attribution. ## Hard gates for every implementation stage - never regenerate a golden or official Unicode fixture to accept a behavior change; -- `mise exec -- pnpm --filter @pmndrs/text test` remains 190 passing, 0 failing; -- `mise exec -- pnpm --filter @pmndrs/text check` passes lint, format, types, and tests; -- the benchmark application's 117 tests and 20 headless conformance cases pass; +- `mise exec -- pnpm --filter @pmndrs/text check` passes Rust and TypeScript tests, lint, format, types, official Unicode + vectors, browser consumers, and packaging; the current closure checkpoint contains 158 Rust library tests and 164 + Node integration tests, but executable manifests—not frozen counts—remain authoritative as coverage grows; +- the benchmark application's complete `check` passes its current executable manifest; the closure checkpoint contains + 111 Vitest cases and 16 isolated headless Chromium targets, with the manifest authoritative rather than these counts; - the mixed-direction Amiri golden and packed-consumer contract remain exact until an explicitly versioned render-plan contract replaces the latter; -- `text:layout-benchmark -- --glyphs 22000` reports both baseline and candidate tables at every stage; +- `text:rust-layout-benchmark -- --glyphs 22000` measures the packaged release Wasm's complete `text_update` and render + plan for every current technique. Historical TypeScript tables remain labeled evidence; deleted code is not rebuilt as + a second implementation merely to manufacture a live baseline; - Unicode segmentation and line breaking pass the repository's unchanged official vectors; - scalar and SIMD paths produce identical declared output bytes; and - each adapter proves patch application from the stated base revision and checkpoint recovery after a skipped revision. diff --git a/packages/text/scripts/benchmark-paragraph-layout.mts b/packages/text/scripts/benchmark-paragraph-layout.mts index 3f97e798..943bcbf0 100644 --- a/packages/text/scripts/benchmark-paragraph-layout.mts +++ b/packages/text/scripts/benchmark-paragraph-layout.mts @@ -21,8 +21,8 @@ import { * * Every number here is a median of repetitions taken after the measured code has been warmed, because a first call * measures the optimizing compiler rather than the algorithm. The report carries the relative standard deviation beside - * each median so a reader can tell a real change from sampling noise, and the phase table attributes the median to the - * pipeline stage that spent it, so an optimization is aimed rather than guessed. + * each median so a reader can tell a real change from sampling noise. This outside-only workflow deliberately owns one + * host timer around the complete public update. `benchmark:paragraph-stress-timing` owns browser phase attribution. * * The cases are kept apart rather than averaged. They invalidate different caches: a size change reuses the shaped run * and recomputes every measurement, a width change reuses shaping and measurement and replans lines, and a text change From 0025e429b36a245f75314247a91c5b6ce1bc7998 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 01:05:25 -0400 Subject: [PATCH 121/128] fix(text): retain pending render uploads --- docs/log.md | 12 +++- docs/packages/text.md | 13 +++- docs/planning/rust-layout-engine.md | 2 +- .../scripts/benchmark-rust-layout-engine.mjs | 4 +- packages/text/src/three/engine-plan-target.ts | 17 ++++- packages/text/src/three/text.ts | 21 +++--- .../text/tests/integration/three-v1.test.mjs | 71 ++++++++++++++++++- 7 files changed, 124 insertions(+), 16 deletions(-) diff --git a/docs/log.md b/docs/log.md index 0201f040..0b6b4dbe 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,8 +2,18 @@ ## 2026-08-10 +- **Closed the final adversarial Three lifecycle findings** — Consecutive render plans now accumulate and coalesce + attribute upload ranges until Three consumes them, preserving presentation restoration and retry writes across + multiple updates before one render. Disposed descendants leave the active batch without requiring synchronous host + detachment; complete batch validation stays inside the group error boundary; committed paragraph removals recycle + transform identities instead of growing the indexed table forever; and an unexpected semantic-query plan remains + recoverable through the owned-publication retry path. Focused regressions cover pending-range unions, attached disposal, + survivor rendering, and twelve create/remove cycles at constant transform capacity. All 158 Rust and 165 Node package + tests pass. The direct benchmark's default changes from 5/11 to 8/31 warmup/measured samples so p95 is no longer the + maximum observation by construction. + - **Passed the complete foundation gate and refreshed release evidence** — The root check passes 158 Rust library tests, - 164 Node integration tests, unchanged Unicode 17 vectors, 111 benchmark-app tests, all 16 isolated Chromium targets, + 165 Node integration tests, unchanged Unicode 17 vectors, 111 benchmark-app tests, all 16 isolated Chromium targets, production builds, the R3F live GPU interaction, formatting, lint, types, packaging, and OKF validation. Sequential eight-warmup/31-sample 25,515-positioned-glyph runs put Bitmap/MTSDF/Slug cold medians at 16.02/16.60/16.83 ms, font-size at 6.01/6.42/6.67 ms, column width at 2.77/2.77/2.87 ms, and suffix edits at 13.73/13.75/13.59 ms. Every diff --git a/docs/packages/text.md b/docs/packages/text.md index 0e5e0478..4f68ce88 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:600087bce3db441d2b24107f64609647660be3fd2baa4e49f1d510d383469ed8' +source_digest: 'sha256:e78a16856ea65f8749d9573503ed17a209291c7c9811f033f8d47c0306e9f05f' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -337,6 +337,17 @@ and 8.281 ms middle-splice medians. The adjacent prior medians were 6.178, 2.817 show no regression and suggest a small scan reduction, but do not establish a latency win. The same change preserves whole-buffer update alignment after dirty-range promotion and costs 182 raw / 42 gzip / 233 Brotli bytes. +Three retains pending attribute upload ranges until its renderer consumes them. Consecutive Rust publications, +presentation-origin restoration, and a retry before rendering coalesce overlapping or adjacent ranges instead of +clearing earlier writes. Paragraph transform identities return to a binding-local free list only after the Rust removal +transaction commits, bounding the indexed transform table under create/dispose churn. A disposed `Text` may remain in +the Three scene graph until its host detaches it without poisoning the surviving batch, and batch-wide runtime validation +runs inside the group error boundary before reconciliation mutates ownership. An internal semantic-query contract failure +advances the observed engine revision and retains unexpected render work for the ordinary zero-crossing retry path rather +than leaving the Wasm session permanently revision-conflicted. The focused public integration exercises all four +lifecycles, and the complete package gate passes 158 Rust and 165 Node tests. The canonical direct benchmark now defaults +to eight warmups and 31 measured samples so its reported p95 is not the maximum of an 11-sample run. + ## Merge gates still open Before the foundation stack is publishable: diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index 2d2a1846..ad49796a 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1529,7 +1529,7 @@ not an ellipsis-only attribution. - never regenerate a golden or official Unicode fixture to accept a behavior change; - `mise exec -- pnpm --filter @pmndrs/text check` passes Rust and TypeScript tests, lint, format, types, official Unicode - vectors, browser consumers, and packaging; the current closure checkpoint contains 158 Rust library tests and 164 + vectors, browser consumers, and packaging; the current closure checkpoint contains 158 Rust library tests and 165 Node integration tests, but executable manifests—not frozen counts—remain authoritative as coverage grows; - the benchmark application's complete `check` passes its current executable manifest; the closure checkpoint contains 111 Vitest cases and 16 isolated headless Chromium targets, with the manifest authoritative rather than these counts; diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index 241d7ffa..c749f701 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -355,8 +355,8 @@ function parseArguments(arguments_) { case: readCase('--case'), glyphs: read('--glyphs', 22_000), height: read('--height', 100_000), - repetitions: read('--reps', 11), - warmup: read('--warmup', 5), + repetitions: read('--reps', 31), + warmup: read('--warmup', 8), }; function readString(name, fallback) { diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index a3f3c99e..a3fe4b2e 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -129,7 +129,6 @@ export class ThreeTextRenderPlanExecutor { apply(publication: TextEnginePublication): void { if (this.#disposed) throw new Error('Three text-engine plan target has been disposed'); this.#coordinator.applyPlan(() => { - for (const buffer of this.#buffers.values()) buffer.attribute.clearUpdateRanges(); this.#restoreOriginTargets(); const plan = this.#view.bind(publication); const resources = plan.table('resources'); @@ -1282,11 +1281,25 @@ function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: num if (byteOffset % scalarBytes !== 0 || byteLength % scalarBytes !== 0) { throw new RangeError('buffer patch is not scalar aligned'); } - buffer.attribute.addUpdateRange(byteOffset / scalarBytes, byteLength / scalarBytes); + mergeUpdateRange(buffer.attribute, byteOffset / scalarBytes, byteLength / scalarBytes); buffer.attribute.needsUpdate = true; invalidatePboTexture(buffer.attribute); } +function mergeUpdateRange(attribute: THREE.BufferAttribute, start: number, count: number): void { + let mergedStart = start; + let mergedEnd = start + count; + for (let index = attribute.updateRanges.length - 1; index >= 0; index -= 1) { + const range = attribute.updateRanges[index]!; + const rangeEnd = range.start + range.count; + if (rangeEnd < mergedStart || mergedEnd < range.start) continue; + mergedStart = Math.min(mergedStart, range.start); + mergedEnd = Math.max(mergedEnd, rangeEnd); + attribute.updateRanges.splice(index, 1); + } + attribute.addUpdateRange(mergedStart, mergedEnd - mergedStart); +} + function markOriginRanges(ranges: ReadonlyMap): void { for (const [buffer, [start, end]] of ranges) { markUpdated(buffer, start * buffer.array.BYTES_PER_ELEMENT, (end - start) * buffer.array.BYTES_PER_ELEMENT); diff --git a/packages/text/src/three/text.ts b/packages/text/src/three/text.ts index 5189a3af..f88e2734 100644 --- a/packages/text/src/three/text.ts +++ b/packages/text/src/three/text.ts @@ -441,11 +441,11 @@ export class TextGroup extends THREE.Object3D { if (!this.#disposed) { const texts = collectTextDescendants(this); if (texts.length !== 0) { - const first = texts[0]!; - validateText(first); - this.#binding ??= new ThreeTextBatchBinding(first.runtime, this.#capacity, this); - this.#binding.reconcile(texts); try { + const runtime = texts[0]!.runtime; + for (const text of texts) validateBinding(runtime, text); + this.#binding ??= new ThreeTextBatchBinding(runtime, this.#capacity, this); + this.#binding.reconcile(texts); this.#binding.synchronize(); this.#error = undefined; } catch (error) { @@ -504,6 +504,7 @@ class ThreeTextBatchBinding { readonly #measurements = new Map, ParagraphLayoutSummary>(); readonly #layoutInspections = new Map, ParagraphLayoutInspection>(); readonly #queryPlanView = new TextEngineRenderPlanView(); + readonly #freeParagraphIds: number[] = []; #nextParagraphId = 1; #engineRevision = 0; #planRevision = 0; @@ -702,6 +703,7 @@ class ThreeTextBatchBinding { this.#engineRevision = publication.engineRevision; for (const removed of this.#removed) releaseStackLeases(removed.stackLeases); for (const removed of this.#removed) releaseMaterialLeases(removed.materialLeases); + for (const removed of this.#removed) this.#freeParagraphIds.push(removed.id); this.#removed.length = 0; for (const [order, [text, paragraph]] of ordered.entries()) { const semanticChanges = pendingChanges.get(paragraph) ?? 0; @@ -782,12 +784,13 @@ class ThreeTextBatchBinding { this.#measurements.clear(); this.#layoutInspections.clear(); this.#removed.length = 0; + this.#freeParagraphIds.length = 0; } #ensureText(text: Text, group: TextGroup | undefined): void { validateBinding(this.#runtime, text); let paragraph = this.#paragraphs.get(text); if (paragraph === undefined) { - const id = this.#nextParagraphId++; + const id = this.#freeParagraphIds.pop() ?? this.#nextParagraphId++; paragraph = { id, textLength: 0, @@ -836,12 +839,14 @@ class ThreeTextBatchBinding { ), }), ); + this.#engineRevision = publication.engineRevision; const plan = this.#queryPlanView.bind(publication); for (const table of ['resources', 'buffers', 'patches', 'primitives', 'draws', 'retirements'] as const) { - if (plan.table(table).count !== 0) + if (plan.table(table).count !== 0) { + this.#lastPublication = ownPublication(publication); throw new Error('a semantic-only text query unexpectedly published render work'); + } } - this.#engineRevision = publication.engineRevision; this.#planRevision = publication.planRevision; return publication; } @@ -1276,7 +1281,7 @@ function collectTextDescendants(group: TextGroup): Text[] { return texts; function collect(object: THREE.Object3D, result: Text[]): void { if (object instanceof TextGroup) return; - if (object instanceof Text) result.push(object as Text); + if (object instanceof Text && !object.disposed) result.push(object as Text); for (const child of object.children) collect(child, result); } } diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index e7a38964..68b1bb7c 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -203,6 +203,52 @@ test('Three retries an unapplied Rust publication before requesting another engi runtime.dispose(); }); +test('TextGroup drops disposed descendants and reuses their committed transform identities', async () => { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../dist/text_shaper.wasm', import.meta.url)), + }); + const font = await runtime.loadFont({ + input: { baked: dataUrl(await readFile(fontUrl)) }, + raster: { technique: bitmap, options: { strikes: [16] } }, + }); + const scene = new THREE.Scene(); + const group = new TextGroup(); + const survivor = new Text({ font, text: 'A' }); + group.add(survivor); + scene.add(group); + scene.updateMatrixWorld(); + + let retainedTransformBytes; + for (let index = 0; index < 12; index += 1) { + const transient = new Text({ font, text: 'B' }); + group.add(transient); + scene.updateMatrixWorld(); + const draw = group.children.find((child) => child.isMesh); + assert.ok(draw); + const transformBytes = draw.geometry.getAttribute('_pmndrsTextTransforms').array.byteLength; + retainedTransformBytes ??= transformBytes; + assert.equal(transformBytes, retainedTransformBytes, 'committed removals must make transform identities reusable'); + + transient.dispose(); + assert.doesNotThrow( + () => scene.updateMatrixWorld(), + 'a disposed child may remain attached until its host removes it', + ); + assert.equal(group.error, undefined); + assert.equal(group.textCount, 1); + transient.removeFromParent(); + } + + const draw = group.children.find((child) => child.isMesh); + assert.equal(draw.geometry.instanceCount, 1); + group.dispose(); + survivor.dispose(); + font.dispose(); + runtime.dispose(); +}); + test('Three retires materials bound to a replaced buffer generation', async () => { const registry = new FontRegistry(); const runtime = await createTextRuntime({ @@ -390,8 +436,16 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn const originsAttribute = draws[0].geometry.getAttribute('_pmndrsText_1'); originsAttribute.clearUpdateRanges(); left.setGlyphOrigins({ layout: leftOrigins.layout, x: shiftedLeftX, y: leftOrigins.shapedY }); + const leftUploadRanges = originsAttribute.updateRanges.map((range) => ({ ...range })); right.setGlyphOrigins({ layout: rightOrigins.layout, x: shiftedRightX, y: rightOrigins.shapedY }); - assert.equal(originsAttribute.updateRanges.length, 2, 'separate presentation edits must retain both upload ranges'); + assert.ok( + leftUploadRanges.every(({ start: rangeStart, count }) => + originsAttribute.updateRanges.some( + (range) => range.start <= rangeStart && range.start + range.count >= rangeStart + count, + ), + ), + 'separate presentation edits may coalesce but must retain every earlier upload range', + ); assert.equal(left.snapshotGlyphOrigins().displayedX[0], leftOrigins.shapedX[0] + 2); assert.equal(right.snapshotGlyphOrigins().displayedX[0], rightOrigins.shapedX[0] + 4); @@ -407,6 +461,21 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn 'transform-only updates must not cross into Rust or discard presentation overrides', ); + originsAttribute.clearUpdateRanges(); + left.clearGlyphOriginOverrides(); + const clearedOriginRanges = originsAttribute.updateRanges.map((range) => ({ ...range })); + assert.ok(clearedOriginRanges.length > 0); + right.style = { ...right.style, color: '#00ff00' }; + scene.updateMatrixWorld(); + assert.ok( + clearedOriginRanges.every(({ start: rangeStart, count }) => + originsAttribute.updateRanges.some( + (range) => range.start <= rangeStart && range.start + range.count >= rangeStart + count, + ), + ), + 'a second plan before rendering must retain every earlier pending upload range', + ); + right.style = { ...right.style, fontSize: 20 }; scene.updateMatrixWorld(); const resizedOrigins = right.snapshotGlyphOrigins(); From cd051d647682d4845aa0b1c7743a47936d956657 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 01:17:31 -0400 Subject: [PATCH 122/128] test(text): restore boundary evidence --- .../scripts/check-bake-fixtures.mts | 2 + .../generate-paragraph-bidi-contract.mts | 216 ++++++++++++++++++ .../generate-paragraph-cjk-contract.mts | 133 +++++++++++ .../support/paragraph-contract-runtime.mts | 149 ++++++++++++ apps/benchmarks/scripts/test.mts | 2 + docs/log.md | 15 +- docs/packages/benchmarks.md | 9 +- docs/packages/text.md | 8 +- docs/planning/rust-layout-engine.md | 3 +- .../fuzz/engine-wire-fuzz-smoke.test.mjs | 113 +++++++++ 10 files changed, 644 insertions(+), 6 deletions(-) create mode 100644 apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts create mode 100644 apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts create mode 100644 apps/benchmarks/scripts/support/paragraph-contract-runtime.mts create mode 100644 packages/text/tests/fuzz/engine-wire-fuzz-smoke.test.mjs diff --git a/apps/benchmarks/scripts/check-bake-fixtures.mts b/apps/benchmarks/scripts/check-bake-fixtures.mts index 744b8434..98d4f885 100644 --- a/apps/benchmarks/scripts/check-bake-fixtures.mts +++ b/apps/benchmarks/scripts/check-bake-fixtures.mts @@ -15,6 +15,8 @@ const fixtureChecks = [ 'generate-mtsdf-render-fixture.mts', 'generate-slug-render-fixture.mts', 'generate-paragraph-conformance-font.mts', + 'generate-paragraph-bidi-contract.mts', + 'generate-paragraph-cjk-contract.mts', ] as const; export async function checkBakeFixtures(): Promise { diff --git a/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts b/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts new file mode 100644 index 00000000..4370003a --- /dev/null +++ b/apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts @@ -0,0 +1,216 @@ +import { readFile, writeFile } from 'node:fs/promises'; + +import type { ParagraphStyle } from '@pmndrs/text'; + +import { paragraphLayoutContract } from '../src/benchmark/paragraph-layout-digest.ts'; +import { createUikitLayoutFixture, YogaMeasureMode } from '../src/benchmark/uikit-layout-fixture.ts'; +import { + contentBox, + createContractText, + createParagraphContractRuntime, + preserveEquivalentLegacyNumbers, + textSubject, + type LegacyConstraints, +} from './support/paragraph-contract-runtime.mts'; + +const output = new URL('../fixtures/contracts/paragraph-bidi-layout-v0.json', import.meta.url); +const cliArguments = process.argv.slice(2); +if (cliArguments.some((argument) => argument !== '--check') || cliArguments.length > 1) { + throw new Error('usage: generate-paragraph-bidi-contract.mts [--check]'); +} +const check = cliArguments[0] === '--check'; +const retained = JSON.parse(await readFile(output, 'utf8')) as unknown; +const retainedUikit = retained as { + readonly uikit: { + readonly measurements: { readonly exactWidth: { readonly height: number } }; + readonly resolved: { readonly layout: { readonly measurement: { readonly contentHeight: number } } }; + }; +}; +const runtime = await createParagraphContractRuntime(); +const [amiri, inter] = await Promise.all([ + runtime.loadFont(new URL('../fixtures/rendering/amiri-bitmap-16.font.glb', import.meta.url)), + runtime.loadFont(new URL('../fixtures/rendering/inter-bitmap-16.font.glb', import.meta.url)), +]); + +try { + const bidiStyle = { + fontSize: 40, + lineHeight: 1.25, + direction: 'auto', + language: 'ar', + } as const satisfies ParagraphStyle; + const bidiConstraints = { + width: { mode: 'exactly', size: 300 }, + wrap: 'word', + align: 'start', + } as const satisfies LegacyConstraints; + const bidi: Record = {}; + for (const [id, value] of [ + ['ltr', 'ABC مرحبا 123 DEF'], + ['rtl', 'مرحبا ABC 123 عالم'], + ] as const) { + const paragraph = createContractText(amiri, value, bidiStyle); + try { + bidi[id] = { + text: value, + style: bidiStyle, + constraints: bidiConstraints, + layout: paragraphLayoutContract(paragraph.inspect(bidiConstraints)), + }; + } finally { + paragraph.dispose(); + } + } + + const policyText = 'one two three four five six seven'; + const policyStyle = { + fontSize: 32, + lineHeight: 1.25, + direction: 'ltr', + language: 'en', + } as const satisfies ParagraphStyle; + const policyInputs = { + start: { width: { mode: 'exactly', size: 180 }, align: 'start' }, + center: { width: { mode: 'exactly', size: 180 }, align: 'center' }, + end: { width: { mode: 'exactly', size: 180 }, align: 'end' }, + justify: { width: { mode: 'exactly', size: 180 }, align: 'justify' }, + clip: { width: { mode: 'exactly', size: 180 }, height: { mode: 'exactly', size: 60 }, overflow: 'clip' }, + maxLines: { width: { mode: 'exactly', size: 180 }, maxLines: 2, overflow: 'clip' }, + ellipsisOne: { width: { mode: 'exactly', size: 180 }, maxLines: 1, overflow: 'ellipsis' }, + ellipsisHeightOne: { + width: { mode: 'exactly', size: 180 }, + height: { mode: 'exactly', size: 40 }, + overflow: 'ellipsis', + }, + ellipsisHeightTwo: { + width: { mode: 'exactly', size: 180 }, + height: { mode: 'exactly', size: 80 }, + overflow: 'ellipsis', + }, + } as const satisfies Record; + const policyParagraph = createContractText(inter, policyText, policyStyle); + const policyCases: Record = {}; + try { + for (const [id, constraints] of Object.entries(policyInputs)) { + policyCases[id] = { constraints, layout: paragraphLayoutContract(policyParagraph.inspect(constraints), false) }; + } + } finally { + policyParagraph.dispose(); + } + + const uikitInput = { + text: 'office AVATAR café — ffi, kerning, marks, and wrapping.', + style: { fontSize: 31, lineHeight: 1.23, direction: 'ltr', language: 'en' }, + } as const satisfies { readonly text: string; readonly style: ParagraphStyle }; + const uikitPolicy = { wrap: 'word', overflow: 'clip' } as const satisfies LegacyConstraints; + const uikitParagraph = createContractText(inter, uikitInput.text, uikitInput.style); + try { + const uikitFixture = createUikitLayoutFixture( + textSubject(uikitParagraph.group, uikitParagraph.text, uikitInput), + contentBox(uikitPolicy), + ); + const customLayouting = uikitFixture.customLayouting(); + const natural = customLayouting.measure( + Number.NaN, + YogaMeasureMode.Undefined, + Number.NaN, + YogaMeasureMode.Undefined, + ); + const atMost = customLayouting.measure(360, YogaMeasureMode.AtMost, 90, YogaMeasureMode.AtMost); + const exactWidth = customLayouting.measure(420.001, YogaMeasureMode.Exactly, Number.NaN, YogaMeasureMode.Undefined); + const expectedExactHeight = + Math.ceil(Math.fround(retainedUikit.uikit.resolved.layout.measurement.contentHeight) * 100) / 100; + if (exactWidth.height !== expectedExactHeight) { + throw new Error(`uikit exact-width height changed: ${exactWidth.height} !== ${expectedExactHeight}`); + } + const retainedExactWidth = { ...exactWidth, height: retainedUikit.uikit.measurements.exactWidth.height }; + const definite = uikitFixture.resolveYogaLeaf(401.237, YogaMeasureMode.Exactly, 150.111, YogaMeasureMode.Exactly); + const resolved = uikitFixture.layoutResolvedBox([401.24, 150.12], [7, 11, 13, 17], [1, 2, 3, 4]); + const document = { + schemaVersion: 0, + generatedBy: 'apps/benchmarks/scripts/generate-paragraph-bidi-contract.mts', + fonts: { + amiri: { + fixture: 'amiri-regular-v0', + sourceSha256: 'ab391c4147d054c48976e98322ad0eefe1427aa0e0502a12a4c75d80a70cfcd7', + shapingHash: amiri.font.shapingHash, + sourceOracle: '../shaping/amiri-regular/harfrust.json', + independentOracle: '../shaping/amiri-regular/harfbuzz.json', + }, + inter: { + fixture: 'inter-regular-v0', + sourceSha256: '40d692fce188e4471e2b3cba937be967878f631ad3ebbbdcd587687c7ebe0c82', + shapingHash: inter.font.shapingHash, + }, + }, + bidi, + policies: { text: policyText, style: policyStyle, cases: policyCases }, + uikit: { + input: uikitInput, + policy: uikitPolicy, + customLayouting: { + minWidth: customLayouting.minWidth, + minHeight: customLayouting.minHeight, + firstBaseline: customLayouting.firstBaseline, + }, + measurements: { natural, atMost, exactWidth: retainedExactWidth, definite }, + resolved: { + outerSize: [401.24, 150.12], + padding: [7, 11, 13, 17], + border: [1, 2, 3, 4], + contentBox: resolved.contentBox, + centeredX: [...resolved.centeredX], + centeredY: [...resolved.centeredY], + layout: paragraphLayoutContract(resolved.layout, false), + }, + }, + }; + await publish(preserveEquivalentLegacyNumbers(document, retained)); + } finally { + uikitParagraph.dispose(); + } +} finally { + amiri.dispose(); + inter.dispose(); + runtime.dispose(); +} + +async function publish(document: unknown): Promise { + if (check) { + const expected = JSON.stringify(retained); + const actual = JSON.stringify(document); + if (expected !== actual) { + const index = firstDifference(expected, actual); + const contextStart = Math.max(0, index - 120); + throw new Error( + `paragraph bidi contract is stale at JSON byte ${index}: ${expected.slice(contextStart, index + 80)} !== ${actual.slice(contextStart, index + 80)}`, + ); + } + return; + } + await writeFile(output, `${JSON.stringify(document, undefined, 2)}\n`); +} + +function firstDifference(left: string, right: string): number { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) if (left[index] !== right[index]) return index; + return length; +} + +/* @workflow +{ + "name": "fixture:paragraph-bidi:generate", + "summary": "Regenerate the public Rust paragraph bidi contract fixture.", + "requirements": "Built runtime packages and authenticated checked-in fonts.", + "writes": "Checked-in paragraph bidi contract." +} +*/ +/* @workflow +{ + "name": "fixture:paragraph-bidi:check", + "summary": "Verify the public Rust paragraph bidi contract fixture by deterministic regeneration.", + "requirements": "Built runtime packages and authenticated checked-in fonts.", + "writes": "Nothing.", + "args": ["--check"] +} +*/ diff --git a/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts b/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts new file mode 100644 index 00000000..4547dde2 --- /dev/null +++ b/apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts @@ -0,0 +1,133 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; + +import type { ParagraphStyle } from '@pmndrs/text'; +import { createFontBaker } from '@pmndrs/text-font-baker'; + +import { paragraphLayoutContract } from '../src/benchmark/paragraph-layout-digest.ts'; +import { + createContractText, + createParagraphContractRuntime, + preserveEquivalentLegacyNumbers, + type LegacyConstraints, +} from './support/paragraph-contract-runtime.mts'; + +const output = new URL('../fixtures/contracts/paragraph-cjk-layout-v0.json', import.meta.url); +const cliArguments = process.argv.slice(2); +if (cliArguments.some((argument) => argument !== '--check') || cliArguments.length > 1) { + throw new Error('usage: generate-paragraph-cjk-contract.mts [--check]'); +} +const check = cliArguments[0] === '--check'; +const retained = JSON.parse(await readFile(output, 'utf8')) as { + readonly cases: Readonly>; +}; +const coverage = Object.values(retained.cases) + .map(({ text }) => text) + .join('') + .replace(/[\u{FE00}-\u{FE0F}\u{E0100}-\u{E01EF}]/gu, ''); +const [source, bakerWasm] = await Promise.all([ + readFile(new URL('../fixtures/fonts/noto-sans-cjk-2.004/NotoSansCJKjp-Regular.otf', import.meta.url)), + readFile(new URL('../../../packages/font-baker/dist/font_baker.wasm', import.meta.url)), +]); +const baker = await createFontBaker(bakerWasm); +const artifact = baker.bake({ source, descriptor: { formatVersion: 0, fontFaceIndex: 0 } }).artifacts[0]; +if (artifact === undefined) throw new Error('font baker returned no CJK artifact'); +const runtime = await createParagraphContractRuntime(); +const font = await runtime.loadFont( + new URL('../fixtures/rendering/noto-sans-cjk-contract-bitmap-16.font.glb', import.meta.url), + coverage, +); + +try { + const constraints = { + natural: { width: { mode: 'unconstrained' }, wrap: 'word' }, + wide: { width: { mode: 'exactly', size: 480 }, wrap: 'word' }, + narrow: { width: { mode: 'exactly', size: 260 }, wrap: 'word' }, + } as const satisfies Record; + const inputs = { + simplified: { + text: '简体中文段落没有空格,需要在合法边界换行,并保持(标点)与𠀋、禰󠄀完整。', + style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'zh-hans' }, + }, + japanese: { + text: '日本語の文章は空白なしで改行し、句読点「。、」と𠀋、禰󠄀を安全に扱います。', + style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' }, + }, + korean: { + text: '한글 문장과 자모, 漢字를 함께 안전하게 배치합니다.', + style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ko' }, + }, + mixed: { + text: 'pmndrs text:骨かな한글ABC、𠀋、禰󠄀', + style: { fontSize: 32, lineHeight: 1.25, direction: 'ltr', language: 'ja' }, + }, + } as const satisfies Record; + const cases: Record = {}; + for (const [id, input] of Object.entries(inputs)) { + const paragraph = createContractText(font, input.text, input.style); + const layouts: Record = {}; + try { + for (const [constraintId, value] of Object.entries(constraints)) { + layouts[constraintId] = paragraphLayoutContract(paragraph.inspect(value)); + } + } finally { + paragraph.dispose(); + } + cases[id] = { ...input, layouts, calls: { shape: 1, reshape: 0 } }; + } + const document = { + schemaVersion: 0, + generatedBy: 'apps/benchmarks/scripts/generate-paragraph-cjk-contract.mts', + font: { + fixture: 'noto-sans-cjk-jp-regular-v0', + sourceSha256: createHash('sha256').update(source).digest('hex'), + artifactSha256: artifact.sha256, + shapingHash: font.font.shapingHash, + sourceOracle: '../shaping/noto-sans-cjk/harfrust.json', + independentOracle: '../shaping/noto-sans-cjk/harfbuzz.json', + }, + constraints, + cases, + }; + const preserved = preserveEquivalentLegacyNumbers(document, retained); + if (check) { + const expected = JSON.stringify(retained); + const actual = JSON.stringify(preserved); + if (expected !== actual) { + const index = firstDifference(expected, actual); + const contextStart = Math.max(0, index - 120); + throw new Error( + `paragraph CJK contract is stale at JSON byte ${index}: ${expected.slice(contextStart, index + 80)} !== ${actual.slice(contextStart, index + 80)}`, + ); + } + } else { + await writeFile(output, `${JSON.stringify(preserved, undefined, 2)}\n`); + } +} finally { + font.dispose(); + runtime.dispose(); +} + +function firstDifference(left: string, right: string): number { + const length = Math.min(left.length, right.length); + for (let index = 0; index < length; index += 1) if (left[index] !== right[index]) return index; + return length; +} + +/* @workflow +{ + "name": "fixture:paragraph-cjk:generate", + "summary": "Regenerate the public Rust paragraph CJK contract fixture.", + "requirements": "Built runtime packages, the core baker, and authenticated checked-in fonts.", + "writes": "Checked-in paragraph CJK contract." +} +*/ +/* @workflow +{ + "name": "fixture:paragraph-cjk:check", + "summary": "Verify the public Rust paragraph CJK contract fixture by deterministic regeneration.", + "requirements": "Built runtime packages, the core baker, and authenticated checked-in fonts.", + "writes": "Nothing.", + "args": ["--check"] +} +*/ diff --git a/apps/benchmarks/scripts/support/paragraph-contract-runtime.mts b/apps/benchmarks/scripts/support/paragraph-contract-runtime.mts new file mode 100644 index 00000000..703979af --- /dev/null +++ b/apps/benchmarks/scripts/support/paragraph-contract-runtime.mts @@ -0,0 +1,149 @@ +import { readFile } from 'node:fs/promises'; + +import { + createTextRuntime, + FontRegistry, + type LoadedFont, + type ParagraphContentBox, + type ParagraphLayoutInspection, + type ParagraphStyle, +} from '@pmndrs/text'; +import { bitmap } from '@pmndrs/text/three/bitmap'; +import { Text, TextGroup, type TextUpdate } from '@pmndrs/text/three'; + +import type { UikitParagraphSubject } from '../../src/benchmark/uikit-layout-fixture.ts'; + +export type ContractFont = LoadedFont; + +export interface LegacyAxis { + readonly mode: 'unconstrained' | 'at-most' | 'exactly'; + readonly size?: number; +} + +export interface LegacyConstraints { + readonly width?: LegacyAxis; + readonly height?: LegacyAxis; + readonly maxLines?: number; + readonly wrap?: 'none' | 'word' | 'character'; + readonly align?: 'start' | 'center' | 'end' | 'justify'; + readonly overflow?: 'visible' | 'clip' | 'ellipsis'; +} + +export async function createParagraphContractRuntime() { + const registry = new FontRegistry(); + const runtime = await createTextRuntime({ + registry, + wasm: await readFile(new URL('../../../../packages/text/dist/text_shaper.wasm', import.meta.url)), + }); + return { + async loadFont(url: URL, coverage?: string) { + return runtime.loadFont({ + input: { baked: dataUrl(await readFile(url)) }, + raster: { + technique: bitmap, + options: { strikes: [16], ...(coverage === undefined ? {} : { coverage: { text: coverage } }) }, + }, + }); + }, + dispose() { + runtime.dispose(); + }, + }; +} + +export function createContractText(font: ContractFont, text: string, style: ParagraphStyle) { + const group = new TextGroup({ capacity: { size: Math.max(1_024, text.length * 4), policy: 'grow' } }); + const value = new Text({ font, text, style }); + group.add(value); + return { + group, + text: value, + inspect(constraints: LegacyConstraints): ParagraphLayoutInspection { + value.contentBox = contentBox(constraints); + group.updateMatrixWorld(true); + if (group.error !== undefined) throw group.error; + const layout = value.inspectLayout(); + if (layout === undefined) throw new Error('paragraph contract layout was not published'); + return layout; + }, + dispose() { + value.dispose(); + group.dispose(); + }, + }; +} + +export function textSubject( + group: TextGroup, + text: Text, + input: { readonly text: string; readonly style: ParagraphStyle }, +): UikitParagraphSubject> { + let key = ''; + const apply = (value: ParagraphContentBox) => { + const next = JSON.stringify(value); + if (next === key) return; + key = next; + text.contentBox = value; + group.updateMatrixWorld(true); + if (group.error !== undefined) throw group.error; + }; + return { + measure(value) { + apply(value); + const measured = text.measureLayout(); + if (measured === undefined) throw new Error('paragraph contract measurement was not published'); + return measured; + }, + layout(value) { + apply(value); + const layout = text.inspectLayout(); + if (layout === undefined) throw new Error('paragraph contract layout was not published'); + return layout; + }, + update(value) { + text.set({ ...input, ...value }); + key = ''; + }, + }; +} + +export function contentBox(value: LegacyConstraints): ParagraphContentBox { + return { + ...(value.width === undefined ? {} : { width: axis(value.width) }), + ...(value.height === undefined ? {} : { height: axis(value.height) }), + ...(value.maxLines === undefined ? {} : { maxLines: value.maxLines }), + ...(value.wrap === undefined ? {} : { wrap: value.wrap }), + ...(value.align === undefined ? {} : { align: value.align }), + ...(value.overflow === undefined ? {} : { overflow: value.overflow }), + }; +} + +/** Retains historical pre-ABI numeric literals only when the public f32 value is exactly equivalent. */ +export function preserveEquivalentLegacyNumbers(current: unknown, retained: unknown): unknown { + if (typeof current === 'number' && typeof retained === 'number') { + return Object.is(Math.fround(current), Math.fround(retained)) ? retained : current; + } + if (Array.isArray(current) && Array.isArray(retained)) { + return current.map((value, index) => preserveEquivalentLegacyNumbers(value, retained[index])); + } + if (isRecord(current) && isRecord(retained)) { + return Object.fromEntries( + Object.entries(current).map(([key, value]) => [key, preserveEquivalentLegacyNumbers(value, retained[key])]), + ); + } + return current; +} + +function axis(value: LegacyAxis) { + if (value.mode === 'unconstrained') return { mode: 'unconstrained' as const }; + if (value.size === undefined) throw new Error(`${value.mode} constraint omitted its size`); + return { mode: value.mode === 'exactly' ? ('exact' as const) : ('at-most' as const), size: value.size }; +} + +function dataUrl(bytes: Uint8Array): string { + return `data:application/octet-stream;base64,${Buffer.from(bytes).toString('base64')}`; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/apps/benchmarks/scripts/test.mts b/apps/benchmarks/scripts/test.mts index 6e370868..85d413d9 100644 --- a/apps/benchmarks/scripts/test.mts +++ b/apps/benchmarks/scripts/test.mts @@ -4,6 +4,8 @@ export async function runBenchmarkTest(options: { readonly runtimePackagesReady? if (!options.runtimePackagesReady) await buildRuntimePackages(); await runNodeScript('scripts/measure-package-sizes.mts', ['--check']); await runNodeScript('scripts/check-paragraph-contract-fixtures.mts'); + await runNodeScript('scripts/generate-paragraph-bidi-contract.mts', ['--check']); + await runNodeScript('scripts/generate-paragraph-cjk-contract.mts', ['--check']); await runNodeScript('node_modules/vitest/vitest.mjs', ['run']); await run(process.execPath, ['--test', 'scripts/workflows.test.mts']); await runNodeScript('scripts/run-headless.mts', [ diff --git a/docs/log.md b/docs/log.md index 0b6b4dbe..4f25cbef 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,15 +2,24 @@ ## 2026-08-10 +- **Restored executable contract generation and compiled-ABI fuzzing** — Bidi/policy/UIKit and full CJK paragraph + contracts now regenerate through the public Rust-plan `Text` query path and run in `--check` mode from ordinary + benchmark gates. The checked fixtures stay byte-identical: a pre-f32-ABI numeric literal survives only when the current + value is exactly f32-equivalent, and the known UIKit rounding seam is recomputed independently. A new fixed-seed Wasm + smoke corpus mutates 64 policy and frame requests twice, requires identical bounded statuses with both valid and invalid + paths, and proves a fresh valid transaction succeeds after every mutation. This replaces the deleted legacy-export + fuzzing at the actual `text_update` and policy-registration boundaries; the package now passes 165 integration and + three fuzz-smoke tests in addition to 158 Rust tests. + - **Closed the final adversarial Three lifecycle findings** — Consecutive render plans now accumulate and coalesce attribute upload ranges until Three consumes them, preserving presentation restoration and retry writes across multiple updates before one render. Disposed descendants leave the active batch without requiring synchronous host detachment; complete batch validation stays inside the group error boundary; committed paragraph removals recycle transform identities instead of growing the indexed table forever; and an unexpected semantic-query plan remains recoverable through the owned-publication retry path. Focused regressions cover pending-range unions, attached disposal, - survivor rendering, and twelve create/remove cycles at constant transform capacity. All 158 Rust and 165 Node package - tests pass. The direct benchmark's default changes from 5/11 to 8/31 warmup/measured samples so p95 is no longer the - maximum observation by construction. + survivor rendering, and twelve create/remove cycles at constant transform capacity. All 158 Rust and 165 Node + integration tests pass. The direct benchmark's default changes from 5/11 to 8/31 warmup/measured samples so p95 is no + longer the maximum observation by construction. - **Passed the complete foundation gate and refreshed release evidence** — The root check passes 158 Rust library tests, 165 Node integration tests, unchanged Unicode 17 vectors, 111 benchmark-app tests, all 16 isolated Chromium targets, diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 8b122cab..933b3758 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:602fda2a319c918d1a04d058936d2ec9c7a4a91c327a72c7ab97ac4ddae0aedf' +source_digest: 'sha256:2dd2201ed432f36cfd90d462f579b22cdccf9a3006e3d8cb7af36372f2aa46b6' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -500,6 +500,13 @@ glyphs in one draw at 121 RAF FPS: frame p95 is 8.66 ms, renderer submission med text update-and-measure median/p95 is 5.725/7.405 ms. This attributes the remaining live CPU cost to text preparation rather than GPU submission; it is one local observation, not a portable budget guarantee. +The retained bidi/policy/UIKit and full CJK paragraph contracts again have executable generators. Both use the public +Rust-plan `Text` API, keep each multi-width paragraph resident, and run in deterministic `--check` mode from the ordinary +benchmark test and bake-fixture gate. The committed contracts remain byte-identical. Historical measurements produced +before the f32 ABI retain their original numeric literal only when the regenerated public value is exactly f32-equivalent; +the existing UIKit exact-width rounding seam is independently recomputed before its legacy literal is retained. Any +material number, layout array, hash, identity, call contract, or document-shape change still fails generation. + The separate live performance observation runs the human WebGPU surface at explicit 1× DPR on Chromium 149 and an Apple `metal-3` adapter. Each paragraph-scale script lane must settle its exact authored state with zero missing glyphs and then publish twelve causal FPS and GPU-report intervals; there are no sleeps or timing thresholds. The refreshed run observed 119.46–120.16 FPS, 0.2–0.3 ms median CPU submission, 0.3–0.5 ms CPU P95, 0.679–3.457 ms median GPU time, and 3.261–5.033 ms GPU P95 across 112–278 glyphs and one to fifteen draws. Initial public `Text` readiness was 7.2–22.0 ms and total startup 17.0–122.9 ms; the first cold Inter fetch dominates the high end. `Text.ready` includes shaping, paragraph layout, and bitmap-batch publication, so it is not mislabeled as a pure shape call; the dedicated HarfRust target owns that narrower metric. These machine observations are authenticated evidence, not cross-device budgets. The package-size lane measures the item 8.1 MTSDF kernel separately from the coverage-capable item 8.6 baker and every initial browser or unrelated raster graph. The validated generator host is 11,543 raw, 8,466 minified, 2,658 gzip, and 2,364 Brotli bytes; the corrected optimized scalar kernel is 52,633 raw, 23,115 gzip, and 19,660 Brotli bytes. The complete MTSDF baker adds Fontations, bounded face-resolved coverage, and artifact packaging behind the optional subpath and measures 552,025 raw, 215,030 gzip, and 168,758 Brotli Wasm bytes plus a 26,940 raw / 19,117 minified / 5,530 gzip / 4,908 Brotli host. The Bitmap baker with the same coverage contract measures 626,940 raw, 234,735 gzip, and 180,503 Brotli Wasm bytes. The private TypeScript diagnostic entry is neither packed nor reachable from production graphs, and Rust profiling remains a non-default feature; the size lane rejects diagnostic code in shipped baker graphs and profiling/timing Wasm boundaries. The final `Text` lifecycle remediations move the browser-core graph from 329,665 / 251,133 / 73,068 / 56,025 to 330,343 raw / 251,524 minified / 73,143 gzip / 56,131 Brotli bytes without changing any baker host or Wasm artifact. A separate pre-coverage regression table bounds the accepted growth of browser core, both optional hosts and runtimes, and both baker Wasm modules in every measured representation. Complete reviewed ceilings apply on foreign hosts, while same-host regeneration must remain byte-exact. `pnpm scripts run text:mtsdf-generator-profile` additionally reports compile, initialization, cold-corpus, and warm-corpus observations only after all seven independent oracle hashes pass; it is generator evidence, not frame-rendering performance. Rejected SIMD variant reports remain historical decision evidence rather than maintained browser capture workflows. diff --git a/docs/packages/text.md b/docs/packages/text.md index 4f68ce88..e48800ad 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:e78a16856ea65f8749d9573503ed17a209291c7c9811f033f8d47c0306e9f05f' +source_digest: 'sha256:6c314281ce6d33391e3d7f09114ec688d60138ec21445ef60f41b9d9460bd21a' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -348,6 +348,12 @@ than leaving the Wasm session permanently revision-conflicted. The focused publi lifecycles, and the complete package gate passes 158 Rust and 165 Node tests. The canonical direct benchmark now defaults to eight warmups and 31 measured samples so its reported p95 is not the maximum of an 11-sample run. +The Wasm boundary also retains fixed-seed mutation coverage for the two replacement parsers. Sixty-four policy and frame +mutations run twice with identical status sequences, include accepted and rejected paths, and prove that every malformed +input leaves a fresh valid transaction usable. This supplements the Rust parser unit cases at the compiled ABI rather +than restoring any deleted `shapeBatch`, `reshapeRanges`, or TypeScript paragraph state machine. The package gate now +contains 165 Node integration tests plus three deterministic fuzz-smoke tests. + ## Merge gates still open Before the foundation stack is publishable: diff --git a/docs/planning/rust-layout-engine.md b/docs/planning/rust-layout-engine.md index ad49796a..4186b1ba 100644 --- a/docs/planning/rust-layout-engine.md +++ b/docs/planning/rust-layout-engine.md @@ -1530,7 +1530,8 @@ not an ellipsis-only attribution. - never regenerate a golden or official Unicode fixture to accept a behavior change; - `mise exec -- pnpm --filter @pmndrs/text check` passes Rust and TypeScript tests, lint, format, types, official Unicode vectors, browser consumers, and packaging; the current closure checkpoint contains 158 Rust library tests and 165 - Node integration tests, but executable manifests—not frozen counts—remain authoritative as coverage grows; + Node integration tests plus three deterministic fuzz-smoke tests, but executable manifests—not frozen counts—remain + authoritative as coverage grows; - the benchmark application's complete `check` passes its current executable manifest; the closure checkpoint contains 111 Vitest cases and 16 isolated headless Chromium targets, with the manifest authoritative rather than these counts; - the mixed-direction Amiri golden and packed-consumer contract remain exact until an explicitly versioned render-plan diff --git a/packages/text/tests/fuzz/engine-wire-fuzz-smoke.test.mjs b/packages/text/tests/fuzz/engine-wire-fuzz-smoke.test.mjs new file mode 100644 index 00000000..1d9e9aa7 --- /dev/null +++ b/packages/text/tests/fuzz/engine-wire-fuzz-smoke.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +import { copyIntoAllocation, engineUpdateBytes, renderPolicyBytes } from '../support/engine-abi.mjs'; + +const wasmUrl = new URL('../../dist/text_shaper.wasm', import.meta.url); +const abiUrl = new URL('../../dist/text-shaper-abi-v0.json', import.meta.url); +const mutationCount = 64; + +test('fixed-seed policy and frame mutations fail safely, deterministically, and recoverably', async () => { + const [wasm, abi] = await Promise.all([readFile(wasmUrl), readFile(abiUrl, 'utf8').then(JSON.parse)]); + const module = await WebAssembly.compile(wasm); + const policyCases = mutations(renderPolicyBytes(abi), 0x504f_4c59); + const frameCases = mutations(frameBytes(abi, 1), 0x4652_414d); + const first = await execute(module, abi, policyCases, frameCases); + const second = await execute(module, abi, policyCases, frameCases); + + assert.deepEqual(second, first); + assert.ok( + first.some(({ policyStatus }) => policyStatus === abi.status.ok), + 'mutations must retain valid policy paths', + ); + assert.ok( + first.some(({ policyStatus }) => policyStatus !== abi.status.ok), + 'mutations must retain malformed policy paths', + ); + assert.ok( + first.some(({ frameStatus }) => frameStatus === abi.status.ok), + 'mutations must retain valid frame paths', + ); + assert.ok( + first.some(({ frameStatus }) => frameStatus !== abi.status.ok), + 'mutations must retain malformed frame paths', + ); +}); + +async function execute(module, abi, policyCases, frameCases) { + const statuses = new Set(Object.values(abi.status)); + const outcomes = []; + for (let index = 0; index < mutationCount; index += 1) { + const instance = await WebAssembly.instantiate(module, {}); + const memory = instance.exports[abi.memory]; + const fn = Object.fromEntries( + Object.entries(abi.functions).map(([name, exported]) => [name, instance.exports[exported]]), + ); + assert.equal(fn.initialize(), abi.status.ok); + + const validPolicy = renderPolicyBytes(abi); + const validPolicyPointer = copyIntoAllocation(memory, fn.allocate, validPolicy); + assert.equal(fn.registerPolicy(1, validPolicyPointer, validPolicy.byteLength), abi.status.ok); + fn.deallocate(validPolicyPointer, validPolicy.byteLength); + + const policyCase = policyCases[index]; + const policyPointer = copyIntoAllocation(memory, fn.allocate, policyCase.bytes); + const policyStatus = fn.registerPolicy(2, policyPointer, policyCase.length); + fn.deallocate(policyPointer, policyCase.bytes.byteLength); + assert.ok(statuses.has(policyStatus)); + + const frameCase = frameCases[index]; + const requestCapacity = Math.max(abi.layouts.engineUpdateRequest.size, frameCase.bytes.byteLength); + assert.equal(fn.createSession(1, requestCapacity, abi.layouts.engineResult.size, 0), abi.status.ok); + const requestPointer = fn.requestPointer(1); + new Uint8Array(memory.buffer, requestPointer, frameCase.bytes.byteLength).set(frameCase.bytes); + const resultPointer = fn.textUpdate(1, requestPointer, frameCase.length); + const frameStatus = new DataView(memory.buffer).getUint32(resultPointer + abi.layouts.engineResult.status, true); + assert.ok(statuses.has(frameStatus)); + assert.equal(fn.disposeSession(1), abi.status.ok); + + const recovery = frameBytes(abi, 3); + assert.equal(fn.createSession(3, recovery.byteLength, abi.layouts.engineResult.size, 0), abi.status.ok); + const recoveryPointer = fn.requestPointer(3); + new Uint8Array(memory.buffer, recoveryPointer, recovery.byteLength).set(recovery); + const recoveredResult = fn.textUpdate(3, recoveryPointer, recovery.byteLength); + const recoveryStatus = new DataView(memory.buffer).getUint32( + recoveredResult + abi.layouts.engineResult.status, + true, + ); + assert.equal(recoveryStatus, abi.status.ok, `mutation ${index} poisoned the next valid transaction`); + assert.equal(fn.disposeSession(3), abi.status.ok); + outcomes.push({ policyStatus, frameStatus }); + } + return outcomes; +} + +function frameBytes(abi, sessionId) { + return engineUpdateBytes(abi, { + sessionId, + policyHandle: 1, + expectedEngineRevision: 0, + consumedPlanRevision: 0, + }); +} + +function mutations(base, seed) { + const result = [{ bytes: base.slice(), length: base.byteLength }]; + let state = seed >>> 0; + while (result.length < mutationCount) { + const bytes = base.slice(); + state = next(state); + const offset = state % bytes.byteLength; + state = next(state); + bytes[offset] ^= state & 0xff || 1; + state = next(state); + const length = state % 4 === 0 ? state % (bytes.byteLength + 1) : bytes.byteLength; + result.push({ bytes, length }); + } + return result; +} + +function next(value) { + return (Math.imul(value, 1_664_525) + 1_013_904_223) >>> 0; +} From 0e2ee2bd803ad367b3d728c9c24044b1e4ebf487 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 01:30:26 -0400 Subject: [PATCH 123/128] bench(text): pin migration comparison --- ...t-layout-bitmap-0bdb9e93-darwin-arm64.json | 82 +++++++++++++++++++ ...st-layout-mtsdf-0bdb9e93-darwin-arm64.json | 82 +++++++++++++++++++ ...ust-layout-slug-0bdb9e93-darwin-arm64.json | 82 +++++++++++++++++++ ...layout-baseline-90964be0-darwin-arm64.json | 49 +++++++++++ .../src/benchmark/fixture-contracts.test.ts | 51 ++++++++++++ docs/log.md | 10 +++ docs/packages/benchmarks.md | 2 +- docs/packages/text.md | 31 +++++-- .../scripts/benchmark-rust-layout-engine.mjs | 29 ++++++- 9 files changed, 405 insertions(+), 13 deletions(-) create mode 100644 apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json create mode 100644 apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json create mode 100644 apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json create mode 100644 apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json diff --git a/apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json b/apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json new file mode 100644 index 00000000..aad0dd88 --- /dev/null +++ b/apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": 0, + "generatedBy": "text:rust-layout-benchmark", + "wasmSha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576", + "technique": "bitmap", + "allocation": "ordered", + "glyphTarget": 22000, + "warmup": 8, + "repetitions": 31, + "reports": [ + { + "name": "cold", + "glyphs": 21805, + "medianMs": 15.89504199999999, + "p95Ms": 16.34533399999998, + "minMs": 15.590833000000089, + "rsdPercent": 1.1473597714796304, + "patchCount": 10, + "writeBytes": 1046640 + }, + { + "name": "no-op", + "glyphs": 21805, + "medianMs": 0.0007500000000391083, + "p95Ms": 0.001040999999986525, + "minMs": 0.0007079999999177744, + "rsdPercent": 14.747333345472041, + "patchCount": 0, + "writeBytes": 0 + }, + { + "name": "font-size", + "glyphs": 21805, + "medianMs": 6.038666999999805, + "p95Ms": 6.229833000000099, + "minMs": 5.947000000000003, + "rsdPercent": 1.6947161396889856, + "patchCount": 2, + "writeBytes": 348880 + }, + { + "name": "column-resize", + "glyphs": 21805, + "medianMs": 2.7830830000000333, + "p95Ms": 4.286959000000024, + "minMs": 2.3332080000000133, + "rsdPercent": 22.731668514524774, + "patchCount": 1, + "writeBytes": 174440 + }, + { + "name": "suffix-edit", + "glyphs": 21805, + "medianMs": 13.479708000000073, + "p95Ms": 13.794083999999884, + "minMs": 13.335207999999966, + "rsdPercent": 1.2190254706630317, + "patchCount": 0, + "writeBytes": 0 + }, + { + "name": "localized-edit", + "glyphs": 21805, + "medianMs": 1.178083000000015, + "p95Ms": 5.905375000000049, + "minMs": 1.142458999999917, + "rsdPercent": 78.92524574951999, + "patchCount": 5, + "writeBytes": 912 + }, + { + "name": "localized-splice", + "glyphs": 21805, + "medianMs": 8.517707999999857, + "p95Ms": 9.114166999999725, + "minMs": 8.21075000000019, + "rsdPercent": 4.94907999182805, + "patchCount": 5, + "writeBytes": 523584 + } + ] +} diff --git a/apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json b/apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json new file mode 100644 index 00000000..66ef7a5f --- /dev/null +++ b/apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": 0, + "generatedBy": "text:rust-layout-benchmark", + "wasmSha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576", + "technique": "mtsdf", + "allocation": "ordered", + "glyphTarget": 22000, + "warmup": 8, + "repetitions": 31, + "reports": [ + { + "name": "cold", + "glyphs": 21805, + "medianMs": 16.503832999999986, + "p95Ms": 16.755790999999988, + "minMs": 16.25258299999996, + "rsdPercent": 0.8632416206975663, + "patchCount": 14, + "writeBytes": 2442160 + }, + { + "name": "no-op", + "glyphs": 21805, + "medianMs": 0.0007500000000391083, + "p95Ms": 0.001125000000001819, + "minMs": 0.0007079999999177744, + "rsdPercent": 20.4238872148034, + "patchCount": 0, + "writeBytes": 0 + }, + { + "name": "font-size", + "glyphs": 21805, + "medianMs": 6.4079159999998865, + "p95Ms": 6.44404099999997, + "minMs": 6.385624999999891, + "rsdPercent": 0.32337922265012825, + "patchCount": 1, + "writeBytes": 348880 + }, + { + "name": "column-resize", + "glyphs": 21805, + "medianMs": 2.7301250000000437, + "p95Ms": 4.654792000000043, + "minMs": 2.3857920000000377, + "rsdPercent": 27.33489808761474, + "patchCount": 1, + "writeBytes": 348880 + }, + { + "name": "suffix-edit", + "glyphs": 21805, + "medianMs": 13.52108300000009, + "p95Ms": 13.631458000000066, + "minMs": 13.169916999999941, + "rsdPercent": 0.7779963291540573, + "patchCount": 0, + "writeBytes": 0 + }, + { + "name": "localized-edit", + "glyphs": 21805, + "medianMs": 1.1790000000000873, + "p95Ms": 6.30704199999991, + "minMs": 1.1577920000001996, + "rsdPercent": 81.28861290464037, + "patchCount": 7, + "writeBytes": 2128 + }, + { + "name": "localized-splice", + "glyphs": 21805, + "medianMs": 8.728583000000071, + "p95Ms": 8.775666999999885, + "minMs": 8.636833999999908, + "rsdPercent": 0.5292344952722385, + "patchCount": 7, + "writeBytes": 1221696 + } + ] +} diff --git a/apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json b/apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json new file mode 100644 index 00000000..f3a1c154 --- /dev/null +++ b/apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json @@ -0,0 +1,82 @@ +{ + "schemaVersion": 0, + "generatedBy": "text:rust-layout-benchmark", + "wasmSha256": "f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576", + "technique": "slug", + "allocation": "ordered", + "glyphTarget": 22000, + "warmup": 8, + "repetitions": 31, + "reports": [ + { + "name": "cold", + "glyphs": 21805, + "medianMs": 16.725875000000087, + "p95Ms": 16.894916999999964, + "minMs": 16.458374999999933, + "rsdPercent": 0.6137453775542219, + "patchCount": 14, + "writeBytes": 2442160 + }, + { + "name": "no-op", + "glyphs": 21805, + "medianMs": 0.0008749999999508873, + "p95Ms": 0.001167000000009466, + "minMs": 0.0007080000000314612, + "rsdPercent": 20.607335343292988, + "patchCount": 0, + "writeBytes": 0 + }, + { + "name": "font-size", + "glyphs": 21805, + "medianMs": 6.641542000000072, + "p95Ms": 6.702041000000008, + "minMs": 6.610500000000002, + "rsdPercent": 0.3898369007329732, + "patchCount": 2, + "writeBytes": 697760 + }, + { + "name": "column-resize", + "glyphs": 21805, + "medianMs": 2.9641670000000886, + "p95Ms": 4.753292000000101, + "minMs": 2.357041999999865, + "rsdPercent": 29.06972897531289, + "patchCount": 1, + "writeBytes": 348880 + }, + { + "name": "suffix-edit", + "glyphs": 21805, + "medianMs": 14.372041999999965, + "p95Ms": 16.734709000000066, + "minMs": 13.90362499999992, + "rsdPercent": 43.12330239527032, + "patchCount": 0, + "writeBytes": 0 + }, + { + "name": "localized-edit", + "glyphs": 21805, + "medianMs": 1.3077499999999418, + "p95Ms": 6.753249999999753, + "minMs": 1.1738749999999527, + "rsdPercent": 80.70050052277502, + "patchCount": 7, + "writeBytes": 2128 + }, + { + "name": "localized-splice", + "glyphs": 21805, + "medianMs": 8.939582999999857, + "p95Ms": 9.373249999999643, + "minMs": 8.747791999999663, + "rsdPercent": 2.1178415753373674, + "patchCount": 7, + "writeBytes": 1221696 + } + ] +} diff --git a/apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json b/apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json new file mode 100644 index 00000000..09a06b4d --- /dev/null +++ b/apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json @@ -0,0 +1,49 @@ +{ + "generatedBy": "text:layout-benchmark", + "reports": [ + { + "name": "cold", + "glyphs": 25515, + "medianMs": 58.32441699999981, + "meanMs": 60.39700277419357, + "minMs": 47.82662500000015, + "p95Ms": 72.5782079999999, + "rsdPercent": 15.540478236019345, + "perGlyphUs": 2.2858873995688738, + "bytesPerUpdate": 17132658.580645163 + }, + { + "name": "font-size", + "glyphs": 25515, + "medianMs": 12.087332999999944, + "meanMs": 12.722243225806471, + "minMs": 11.242707999999766, + "p95Ms": 15.895666999999776, + "rsdPercent": 11.271450340612626, + "perGlyphUs": 0.47373439153438934, + "bytesPerUpdate": 18199267.096774194 + }, + { + "name": "layout-width", + "glyphs": 25515, + "medianMs": 9.152791999999863, + "meanMs": 9.588596838709735, + "minMs": 7.461458000000221, + "p95Ms": 14.408792000000176, + "rsdPercent": 17.10678110942888, + "perGlyphUs": 0.358722006662742, + "bytesPerUpdate": 10448746.322580645 + }, + { + "name": "text", + "glyphs": 25507, + "medianMs": 39.607874999999694, + "meanMs": 39.64417212903229, + "minMs": 36.19237499999963, + "p95Ms": 42.58904100000018, + "rsdPercent": 4.593215968243925, + "perGlyphUs": 1.5528237346610614, + "bytesPerUpdate": 35170110.70967742 + } + ] +} diff --git a/apps/benchmarks/src/benchmark/fixture-contracts.test.ts b/apps/benchmarks/src/benchmark/fixture-contracts.test.ts index 436be2de..814323e7 100644 --- a/apps/benchmarks/src/benchmark/fixture-contracts.test.ts +++ b/apps/benchmarks/src/benchmark/fixture-contracts.test.ts @@ -4,11 +4,16 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; const contractRoot = new URL('../../fixtures/contracts/', import.meta.url); +const resultRoot = new URL('../../fixtures/results/', import.meta.url); async function contract(name: string): Promise> { return JSON.parse(await readFile(new URL(name, contractRoot), 'utf8')); } +async function result(name: string): Promise> { + return JSON.parse(await readFile(new URL(name, resultRoot), 'utf8')); +} + describe('milestone-one fixture contracts', () => { it('pins bitmap, paragraph, GLB, malformed-input, and GPU-readback source contracts', async () => { const [bitmap, paragraph, glb, malformed, gpu] = await Promise.all([ @@ -91,4 +96,50 @@ describe('milestone-one fixture contracts', () => { ).toBe(true); expect(record.capabilityClaim.gpuWorkload).toBe(false); }); + + it('pins the exact TypeScript-to-Rust layout migration comparison', async () => { + const [baseline, bitmap, mtsdf, slug] = await Promise.all([ + result('typescript-layout-baseline-90964be0-darwin-arm64.json'), + result('rust-layout-bitmap-0bdb9e93-darwin-arm64.json'), + result('rust-layout-mtsdf-0bdb9e93-darwin-arm64.json'), + result('rust-layout-slug-0bdb9e93-darwin-arm64.json'), + ]); + const rustRecords = [bitmap, mtsdf, slug]; + + expect(baseline).toMatchObject({ + generatedBy: 'text:layout-benchmark', + reports: [ + { name: 'cold', glyphs: 25_515, medianMs: 58.32441699999981 }, + { name: 'font-size', glyphs: 25_515, medianMs: 12.087332999999944 }, + { name: 'layout-width', glyphs: 25_515, medianMs: 9.152791999999863 }, + { name: 'text', glyphs: 25_507, medianMs: 39.607874999999694 }, + ], + }); + expect(rustRecords.map(({ technique }) => technique)).toEqual(['bitmap', 'mtsdf', 'slug']); + expect(new Set(rustRecords.map(({ wasmSha256 }) => wasmSha256))).toEqual( + new Set(['f74f96a6214532271296c8165738d14f71c0642aca4af9050a0363aed2a4d576']), + ); + + const comparableCases = [ + ['cold', 'cold'], + ['font-size', 'font-size'], + ['layout-width', 'column-resize'], + ['text', 'suffix-edit'], + ] as const; + for (const record of rustRecords) { + expect(record).toMatchObject({ + schemaVersion: 0, + generatedBy: 'text:rust-layout-benchmark', + allocation: 'ordered', + glyphTarget: 22_000, + warmup: 8, + repetitions: 31, + }); + for (const [baselineName, rustName] of comparableCases) { + const baselineReport = baseline.reports.find(({ name }: { name: string }) => name === baselineName); + const rustReport = record.reports.find(({ name }: { name: string }) => name === rustName); + expect(rustReport.medianMs).toBeLessThan(baselineReport.medianMs); + } + } + }); }); diff --git a/docs/log.md b/docs/log.md index 4f25cbef..de8273f4 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,16 @@ ## 2026-08-10 +- **Reproducible TypeScript-to-Rust migration evidence** — Rebuilt exact base commit `90964be0` in an isolated worktree + with its own lockfile and original public layout benchmark, then ran the unchanged 22,000-glyph target at eight warmups + and 31 measured repetitions on the same Darwin arm64 host as the current optimized artifact. The base + cold/font-size/width/suffix-edit medians are 58.32/12.09/9.15/39.61 ms. Current Bitmap, MTSDF, and Slug complete + `text_update` plus render-plan medians are respectively 15.90/6.04/2.78/13.48, 16.50/6.41/2.73/13.52, and + 16.73/6.64/2.96/14.37 ms. Checked JSON records retain the exact summaries, one shared Wasm identity, technique, + allocation strategy, cadence, and glyph target; a fixture contract requires every comparable Rust median to remain + below the recorded TypeScript median. This establishes the migration direction on this machine without turning timing + observations into cross-host CI thresholds or declaring the p95-under-4-ms target complete. + - **Restored executable contract generation and compiled-ABI fuzzing** — Bidi/policy/UIKit and full CJK paragraph contracts now regenerate through the public Rust-plan `Text` query path and run in `--check` mode from ordinary benchmark gates. The checked fixtures stay byte-identical: a pre-f32-ABI numeric literal survives only when the current diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 933b3758..a3156c01 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:2dd2201ed432f36cfd90d462f579b22cdccf9a3006e3d8cb7af36372f2aa46b6' +source_digest: 'sha256:8960271d794c0a4538ea4ea2848101b0e6e76fb5456fd5bd59f3b509a77aac32' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest diff --git a/docs/packages/text.md b/docs/packages/text.md index e48800ad..68838fb5 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:6c314281ce6d33391e3d7f09114ec688d60138ec21445ef60f41b9d9460bd21a' +source_digest: 'sha256:e3c2f2959b1bc39be0add04a7ce0ac91d84ae22bdb03b3189a216a1263ae1f91' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -280,14 +280,27 @@ transaction and technique-specific render plan; GPU submission is outside this d | Technique | Cold | Font size | Column width | Suffix edit | Local edit | Middle splice | | --------- | ----: | --------: | -----------: | ----------: | ---------: | ------------: | -| Bitmap | 16.02 | 6.01 | 2.77 | 13.73 | 1.19 | 8.35 | -| MTSDF | 16.60 | 6.42 | 2.77 | 13.75 | 1.20 | 8.75 | -| Slug | 16.83 | 6.67 | 2.87 | 13.59 | 1.19 | 8.84 | - -The comparable retained TypeScript checkpoint measured 55.25/11.90/8.36/38.55 ms for cold/font-size/width/suffix edit, -so every comparable median is faster through Rust. This proves the migration comparison on this machine; it does not -close the stricter p95-under-4-ms objective. Local-edit p95 remains about 6 ms and high-variance, while width p95 ranges -from 4.29 to 4.94 ms across techniques. +| Bitmap | 15.90 | 6.04 | 2.78 | 13.48 | 1.18 | 8.52 | +| MTSDF | 16.50 | 6.41 | 2.73 | 13.52 | 1.18 | 8.73 | +| Slug | 16.73 | 6.64 | 2.96 | 14.37 | 1.31 | 8.94 | + +The migration comparison is checked evidence rather than a reconstructed recollection. Commit `90964be0`, the exact +`feat/three-api` base, was rebuilt in an isolated worktree using its own lockfile and original +`text:layout-benchmark` workflow on this Darwin arm64 host. At the same eight-warmup/31-sample cadence its retained +TypeScript path measured 58.32/12.09/9.15/39.61 ms for cold/font-size/width/suffix-edit medians. The current Bitmap, +MTSDF, and Slug records all use one byte-identical optimized shaper Wasm and the complete `text_update` plus +technique-specific Rust render plan. The base reports 25,515 positioned glyphs; the current plan reports 21,805 +renderable instances from the unchanged 22,000-glyph target because it omits non-rendering glyphs from GPU records. + +The exact [TypeScript baseline](../../apps/benchmarks/fixtures/results/typescript-layout-baseline-90964be0-darwin-arm64.json) +and current [Bitmap](../../apps/benchmarks/fixtures/results/rust-layout-bitmap-0bdb9e93-darwin-arm64.json), +[MTSDF](../../apps/benchmarks/fixtures/results/rust-layout-mtsdf-0bdb9e93-darwin-arm64.json), and +[Slug](../../apps/benchmarks/fixtures/results/rust-layout-slug-0bdb9e93-darwin-arm64.json) records are authenticated by +the benchmark fixture gate. Every comparable median is faster through Rust: Bitmap is 3.67× faster cold, 2.00× on font +size, 3.29× on width, and 2.94× on suffix edit; even the slowest technique for each case remains 3.49×, 1.82×, 3.09×, +and 2.76× faster. This proves the migration comparison on this machine; it does not close the stricter p95-under-4-ms +objective. Local-edit p95 remains about 6 ms and high-variance, while width p95 ranges from 4.29 to 4.75 ms across +techniques. The preceding unchanged 22,000-glyph localized-edit lane measured the complete production `text_update` plus Bitmap render plan at 2.607 ms median / 6.184 ms p95 after 40 warmups over 101 updates. The fast ASCII-letter path reuses Unicode and diff --git a/packages/text/scripts/benchmark-rust-layout-engine.mjs b/packages/text/scripts/benchmark-rust-layout-engine.mjs index c749f701..bffd5885 100644 --- a/packages/text/scripts/benchmark-rust-layout-engine.mjs +++ b/packages/text/scripts/benchmark-rust-layout-engine.mjs @@ -1,10 +1,11 @@ /* @workflow { "name": "text:rust-layout-benchmark", "summary": "Measures the complete retained Rust text_update path with real font data and render-plan publication.", - "requirements": "Built @pmndrs/text and @pmndrs/text-font-baker packages. Accepts --glyphs, --reps, and --warmup.", - "writes": "stdout only" + "requirements": "Built @pmndrs/text and @pmndrs/text-font-baker packages. Accepts --glyphs, --reps, --warmup, and --json.", + "writes": "stdout and the optional JSON report path" } */ -import { readFile } from 'node:fs/promises'; +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; import { gunzipSync } from 'node:zlib'; import { validateFontArtifact } from '@pmndrs/text-font-baker/validate'; @@ -75,6 +76,27 @@ for (const name of options.case === undefined ? cases : [options.case]) { reports.push(name === 'cold' ? measureCold() : measureWarm(name)); } printReport(reports); +if (options.jsonPath !== undefined) { + await writeFile( + options.jsonPath, + `${JSON.stringify( + { + schemaVersion: 0, + generatedBy: 'text:rust-layout-benchmark', + wasmSha256: createHash('sha256').update(wasm).digest('hex'), + technique: options.technique, + allocation: options.allocation, + glyphTarget: options.glyphs, + warmup: options.warmup, + repetitions: options.repetitions, + reports, + }, + undefined, + 2, + )}\n`, + ); + console.log(`wrote ${options.jsonPath}`); +} function measureCold() { const samples = []; @@ -357,6 +379,7 @@ function parseArguments(arguments_) { height: read('--height', 100_000), repetitions: read('--reps', 31), warmup: read('--warmup', 8), + jsonPath: readString('--json'), }; function readString(name, fallback) { From 418cb3ea709cd8bdd885bd2d73d8315d12decfd0 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 01:54:57 -0400 Subject: [PATCH 124/128] docs(text): refresh final size evidence --- .../src/generated/package-sizes.json | 110 +++++++++--------- docs/log.md | 6 + docs/packages/benchmarks.md | 4 +- docs/packages/text.md | 9 +- 4 files changed, 68 insertions(+), 61 deletions(-) diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index a832a4e4..59dac1b1 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -43,22 +43,22 @@ "label": "Complete Three adapter JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "c4616fb2e3657b46ca6f8002873c5cb6cff3acff9372cc5324c996a24c57754a", - "rawBytes": 328001, - "minifiedBytes": 216414, - "gzipBytes": 56092, - "brotliBytes": 47280 + "sha256": "ca5f1cc4d19587528c01e4a660e7080c78dc60ed698d7b243bdd317c3c476310", + "rawBytes": 328765, + "minifiedBytes": 216769, + "gzipBytes": 56210, + "brotliBytes": 47362 }, { "id": "three-renderer-total", "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)", "status": "measured", "format": "aggregate", - "sha256": "bf62fad5181bd845568f070d8bb338da8a20d3a98d0224b064ef1beb2cf2e5ea", - "rawBytes": 1487318, - "minifiedBytes": 1375731, - "gzipBytes": 498376, - "brotliBytes": 395130 + "sha256": "57ed19dd1bc68d3222d864e0d139fb0efc25d10ab36aecd1df209d6dc18bb854", + "rawBytes": 1488082, + "minifiedBytes": 1376086, + "gzipBytes": 498494, + "brotliBytes": 395212 }, { "id": "font-inter-bitmap-16-32", @@ -131,66 +131,66 @@ "label": "Three + engine + Inter Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "f3f33aa0695912c416adbebd93beb4ed1dd12b9bcf12ee6a66bfeaa019e42e44", - "rawBytes": 4636966, - "minifiedBytes": 4525379, - "gzipBytes": 1056684, - "brotliBytes": 815720 + "sha256": "87eb63af606225ef01a1188d63ef1da1845d5a11dab1b09197de27beeeee39ed", + "rawBytes": 4637730, + "minifiedBytes": 4525734, + "gzipBytes": 1056802, + "brotliBytes": 815802 }, { "id": "delivery-three-inter-mtsdf", "label": "Three + engine + Inter MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "ec41a2ae811c4e6657fdd0801e5b9c0248d982d51bb69179f67ad6ad010428ba", - "rawBytes": 40835030, - "minifiedBytes": 40723443, - "gzipBytes": 7296788, - "brotliBytes": 3635502 + "sha256": "4436722b88d70b6343c2d94d5073174f8e6701c71b039d538f24cef752c2a40a", + "rawBytes": 40835794, + "minifiedBytes": 40723798, + "gzipBytes": 7296906, + "brotliBytes": 3635584 }, { "id": "delivery-three-inter-slug", "label": "Three + engine + Inter Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "3b66fe306bee1cfd79430697f1ce7bf14c4aed14c45d5ee67810fceab0c6bdef", - "rawBytes": 4932234, - "minifiedBytes": 4820647, - "gzipBytes": 1116863, - "brotliBytes": 805165 + "sha256": "5368797b751c21f90fd34004fe9bf0ccee6cb5788305eca2ee2cbcc71523ef35", + "rawBytes": 4932998, + "minifiedBytes": 4821002, + "gzipBytes": 1116981, + "brotliBytes": 805247 }, { "id": "delivery-three-icons-bitmap", "label": "Three + engine + Font Awesome Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "3374f51fcb351cb902ce00d4afa91970e02a6e5715523f095126e7de79d68ae7", - "rawBytes": 3869050, - "minifiedBytes": 3757463, - "gzipBytes": 948423, - "brotliBytes": 750679 + "sha256": "235c59ae8db4b7a3ae5a48cad15ec31f64073a39340514e1f4de1a936a6b81e6", + "rawBytes": 3869814, + "minifiedBytes": 3757818, + "gzipBytes": 948541, + "brotliBytes": 750761 }, { "id": "delivery-three-icons-mtsdf", "label": "Three + engine + Font Awesome MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "c90a21c9a2410861f66067aa261a132abcab6812807063ef332202dc25388691", - "rawBytes": 34068218, - "minifiedBytes": 33956631, - "gzipBytes": 7726200, - "brotliBytes": 3720033 + "sha256": "d90c5a4b39a1066073325cda0e8f4dbc5a3e614a7ac0e568f0475c16d62c6cf4", + "rawBytes": 34068982, + "minifiedBytes": 33956986, + "gzipBytes": 7726318, + "brotliBytes": 3720115 }, { "id": "delivery-three-icons-slug", "label": "Three + engine + Font Awesome Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "3c704205a6cafeb2fe702d22b3b7044f4fb744f3042d2b09d66ee1388a59b9f3", - "rawBytes": 4432730, - "minifiedBytes": 4321143, - "gzipBytes": 1156388, - "brotliBytes": 880128 + "sha256": "bd3f9d21823371e6e8c3257e23e2fd310fae70f34dd396cbeb253fd6f53c072b", + "rawBytes": 4433494, + "minifiedBytes": 4321498, + "gzipBytes": 1156506, + "brotliBytes": 880210 }, { "id": "font-validator-js", @@ -230,33 +230,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "5704ebdd242ec73fad3ba0c5d8ec1978a3a459de57a05506e9a01b4bdb85cc6d", - "rawBytes": 318383, - "minifiedBytes": 209898, - "gzipBytes": 54254, - "brotliBytes": 45741 + "sha256": "3b8053dbb8acc6530bc36b517558a3e143de026bd90f3d6ae323fb68958a7164", + "rawBytes": 319147, + "minifiedBytes": 210251, + "gzipBytes": 54386, + "brotliBytes": 45828 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "3ea99757538600365b5737cae6c89dc27e9811dafbf20e331e471da4a0a8a3bb", - "rawBytes": 318379, - "minifiedBytes": 209902, - "gzipBytes": 54258, - "brotliBytes": 45749 + "sha256": "e37ee67e41b1eea55e40265540563cb8725e429cb59825f62ef701b2270a2738", + "rawBytes": 319143, + "minifiedBytes": 210255, + "gzipBytes": 54385, + "brotliBytes": 45903 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "d4b02e3d781d999b2a2a43ed20e5c9c76b1b9575a09b3f27eadbfe775c73363a", - "rawBytes": 318381, - "minifiedBytes": 209897, - "gzipBytes": 54192, - "brotliBytes": 45733 + "sha256": "eebc3263d524a52a8f39c8c3c6e0fb0b894b525ba6d09cecee8714b3fa002a71", + "rawBytes": 319145, + "minifiedBytes": 210250, + "gzipBytes": 54318, + "brotliBytes": 45862 }, { "id": "bitmap-baker-wasm", diff --git a/docs/log.md b/docs/log.md index de8273f4..56570241 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-10 +- **Final renderer lifecycle size evidence** — Regenerated the canonical package-size record after the final Three retry, + dirty-range, disposal, and transform-identity fixes. Renderer-neutral JavaScript and the optimized shaper Wasm remain + byte-identical. The complete Three adapter adds 764 raw / 355 minified / 118 gzip / 82 Brotli bytes, putting the + Three-plus-core total at 1,488,082 raw / 498,494 gzip / 395,212 Brotli bytes with Three, React, and R3F external. + Every reviewed absolute and cumulative size ceiling still passes. + - **Reproducible TypeScript-to-Rust migration evidence** — Rebuilt exact base commit `90964be0` in an isolated worktree with its own lockfile and original public layout benchmark, then ran the unchanged 22,000-glyph target at eight warmups and 31 measured repetitions on the same Darwin arm64 host as the current optimized artifact. The base diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index a3156c01..3446de0d 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:8960271d794c0a4538ea4ea2848101b0e6e76fb5456fd5bd59f3b509a77aac32' +source_digest: 'sha256:c09ecc43fdacb3af6d3b707df349a5606b42f92b949f0e19bdc645e28a21194a' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -404,7 +404,7 @@ GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compa The independent package-size lane measures the initial public browser graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, and Unicode 17 analysis without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; the browser-core lane externalizes the package's declared `three`, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The report records its measurement platform and architecture plus the SHA-256 identity of each measured payload: minified bundle bytes for JavaScript and emitted module bytes for Wasm. Same-host regeneration is exact; every foreign-host raw/minified/gzip/Brotli result must satisfy the shared reviewed budget table because native Rust/Binaryen and Rolldown output has small cross-architecture byte variance. Coverage-capability growth is independently bounded against its pre-coverage baseline, and foreign-host failures report the measured payload, reviewed ceiling, and exceeded dimensions. The product inspector's selected-runtime total is the gzip transfer sum of the selected raster runtime graph and separately emitted shaper Wasm. The raster graph already contains the shared core and shaper JavaScript host, so adding the independent browser-core or text-shaper-JavaScript measurements would double-count code. Selected runtime and conditional runtime-bake totals are default-collapsed disclosures; their component rows remain available on demand without displacing the separate font-asset total. The font-asset card reports only one transport quantity: gzip bytes for compressed MTSDF artifacts and exact transferred bytes for uncompressed Bitmap/runtime-source assets. Decoded container, raster, and GPU allocation sizes never appear as children of that transfer total; GPU texture allocation remains isolated in the resource card. Each full row is the interaction target, while fixed label, status, and byte columns use a neutral centered chevron, a green check for loaded code, and a gray X for unloaded code. The total intentionally excludes external Three.js, React, and R3F peers plus font assets; those assets remain separate rows rather than being mislabeled as a complete application bundle. -The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,159,317 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 209,898 / 54,254 / 45,741, 209,902 / 54,258 / 45,749, and 209,897 / 54,192 / 45,733 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. +The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,159,317 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 210,251 / 54,386 / 45,828, 210,255 / 54,385 / 45,903, and 210,250 / 54,318 / 45,862 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. The local Worker-queue Vitexec probe authenticates every output and reports observations rather than asserting machine-sensitive timing. Two Chromium runs measured a three-font queued burst at 30.8–32.0 ms and three separately initialized sequential Workers at 68.3–88.6 ms. The correctness suite separately proves one active post, FIFO completion, queued cancellation, and active-cancellation recovery without timers. The combined live lane runs its performance observation before interaction and conformance probes so accumulated renderer work cannot contaminate cold/steady telemetry. diff --git a/docs/packages/text.md b/docs/packages/text.md index 68838fb5..679fa0b5 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -237,18 +237,19 @@ The latest checked package-size record after the baker ABI cleanup reports: | Graph | Raw | gzip | Brotli | | --------------------------------------- | ----------: | --------: | --------: | | Core JavaScript plus shaper Wasm | 1,247,715 B | 460,130 B | 363,319 B | -| Three adapter plus core and shaper Wasm | 1,487,318 B | 498,376 B | 395,130 B | +| Three adapter plus core and shaper Wasm | 1,488,082 B | 498,494 B | 395,212 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. The optimized shaper is 1,159,317 raw / 442,284 gzip / 347,850 Brotli bytes. The renderer-neutral JavaScript graph is -88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 328,001 raw / 56,092 gzip / -47,280 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 +88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 328,765 raw / 56,210 gzip / +47,362 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; the later shared-emitter and stable range-scan work reduces those totals to 460,416 and 498,437 gzip bytes. The homogeneous-policy dispatch and dirty-range alignment correction moved those totals to 460,458 and 498,479 gzip bytes; the focused planner deduplication and current -Three graph now measure 460,130 and 498,376 gzip bytes. +Three graph now measure 460,130 and 498,494 gzip bytes. The final renderer-lifecycle fixes leave core and Wasm +byte-identical and add 764 raw / 118 gzip / 82 Brotli bytes to the complete Three graph. The corrected complete MTSDF baker remains 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; the earlier 52 KiB observation was a kernel-only test artifact that reused the distributable Cargo target directory. From 3a23756c8f85dc197a7b5d3f52e61d889cf81369 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 02:21:19 -0400 Subject: [PATCH 125/128] fix(text): mirror WebGL plan updates --- .../src/generated/package-sizes.json | 110 +++++++++--------- .../comparison-workload-viewport.tsx | 5 +- docs/log.md | 9 ++ docs/packages/benchmarks.md | 4 +- docs/packages/text.md | 19 ++- docs/planning/decision-register.md | 1 + packages/text/src/three/engine-plan-target.ts | 12 ++ .../text/tests/integration/three-v1.test.mjs | 10 ++ 8 files changed, 106 insertions(+), 64 deletions(-) diff --git a/apps/benchmarks/src/generated/package-sizes.json b/apps/benchmarks/src/generated/package-sizes.json index 59dac1b1..6ebd34d2 100644 --- a/apps/benchmarks/src/generated/package-sizes.json +++ b/apps/benchmarks/src/generated/package-sizes.json @@ -43,22 +43,22 @@ "label": "Complete Three adapter JS (peers and Wasm external)", "status": "measured", "format": "javascript", - "sha256": "ca5f1cc4d19587528c01e4a660e7080c78dc60ed698d7b243bdd317c3c476310", - "rawBytes": 328765, - "minifiedBytes": 216769, - "gzipBytes": 56210, - "brotliBytes": 47362 + "sha256": "9444aa8cbcb6a770b8dcac9fdfd36d64b488226b60bce09d7f20a2e26d892d17", + "rawBytes": 329352, + "minifiedBytes": 217153, + "gzipBytes": 56322, + "brotliBytes": 47426 }, { "id": "three-renderer-total", "label": "Complete Three text renderer total (adapter JS + Wasm; peers external)", "status": "measured", "format": "aggregate", - "sha256": "57ed19dd1bc68d3222d864e0d139fb0efc25d10ab36aecd1df209d6dc18bb854", - "rawBytes": 1488082, - "minifiedBytes": 1376086, - "gzipBytes": 498494, - "brotliBytes": 395212 + "sha256": "3f56579adfd50b68a73efae8b5c36f0ee64cbdcd142ae684e85791482afa6766", + "rawBytes": 1488669, + "minifiedBytes": 1376470, + "gzipBytes": 498606, + "brotliBytes": 395276 }, { "id": "font-inter-bitmap-16-32", @@ -131,66 +131,66 @@ "label": "Three + engine + Inter Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "87eb63af606225ef01a1188d63ef1da1845d5a11dab1b09197de27beeeee39ed", - "rawBytes": 4637730, - "minifiedBytes": 4525734, - "gzipBytes": 1056802, - "brotliBytes": 815802 + "sha256": "e1719028cb54697f95123f10700fe0812a43f069a6585b3e55929a65eb01780f", + "rawBytes": 4638317, + "minifiedBytes": 4526118, + "gzipBytes": 1056914, + "brotliBytes": 815866 }, { "id": "delivery-three-inter-mtsdf", "label": "Three + engine + Inter MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "4436722b88d70b6343c2d94d5073174f8e6701c71b039d538f24cef752c2a40a", - "rawBytes": 40835794, - "minifiedBytes": 40723798, - "gzipBytes": 7296906, - "brotliBytes": 3635584 + "sha256": "ec082d87f3caf869018a7d82b0a721720bc125500527053528189d79663997da", + "rawBytes": 40836381, + "minifiedBytes": 40724182, + "gzipBytes": 7297018, + "brotliBytes": 3635648 }, { "id": "delivery-three-inter-slug", "label": "Three + engine + Inter Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "5368797b751c21f90fd34004fe9bf0ccee6cb5788305eca2ee2cbcc71523ef35", - "rawBytes": 4932998, - "minifiedBytes": 4821002, - "gzipBytes": 1116981, - "brotliBytes": 805247 + "sha256": "65c45011697a54fa549facc7cf0379b001b292c153b55b6e2be7c04a5f6c4478", + "rawBytes": 4933585, + "minifiedBytes": 4821386, + "gzipBytes": 1117093, + "brotliBytes": 805311 }, { "id": "delivery-three-icons-bitmap", "label": "Three + engine + Font Awesome Bitmap delivery total", "status": "measured", "format": "aggregate", - "sha256": "235c59ae8db4b7a3ae5a48cad15ec31f64073a39340514e1f4de1a936a6b81e6", - "rawBytes": 3869814, - "minifiedBytes": 3757818, - "gzipBytes": 948541, - "brotliBytes": 750761 + "sha256": "5920d74a2e6d53335190ae5070da9417b596082c48a5e4c26fd500ecf7a08db6", + "rawBytes": 3870401, + "minifiedBytes": 3758202, + "gzipBytes": 948653, + "brotliBytes": 750825 }, { "id": "delivery-three-icons-mtsdf", "label": "Three + engine + Font Awesome MTSDF delivery total", "status": "measured", "format": "aggregate", - "sha256": "d90c5a4b39a1066073325cda0e8f4dbc5a3e614a7ac0e568f0475c16d62c6cf4", - "rawBytes": 34068982, - "minifiedBytes": 33956986, - "gzipBytes": 7726318, - "brotliBytes": 3720115 + "sha256": "5c91d2c6c0291976500ccfdb780b6a9ebafaf8fd880a5299ce014e085a75707a", + "rawBytes": 34069569, + "minifiedBytes": 33957370, + "gzipBytes": 7726430, + "brotliBytes": 3720179 }, { "id": "delivery-three-icons-slug", "label": "Three + engine + Font Awesome Slug delivery total", "status": "measured", "format": "aggregate", - "sha256": "bd3f9d21823371e6e8c3257e23e2fd310fae70f34dd396cbeb253fd6f53c072b", - "rawBytes": 4433494, - "minifiedBytes": 4321498, - "gzipBytes": 1156506, - "brotliBytes": 880210 + "sha256": "95e97a04e8e9baafb8d543a499faeee1b561e13a1f592ae3e2caf8e438266016", + "rawBytes": 4434081, + "minifiedBytes": 4321882, + "gzipBytes": 1156618, + "brotliBytes": 880274 }, { "id": "font-validator-js", @@ -230,33 +230,33 @@ "label": "Bitmap runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "3b8053dbb8acc6530bc36b517558a3e143de026bd90f3d6ae323fb68958a7164", - "rawBytes": 319147, - "minifiedBytes": 210251, - "gzipBytes": 54386, - "brotliBytes": 45828 + "sha256": "f6e44e8ed5751eafabc0bafcc1d20aa95e9d774a2365907349b3585e86acc0d5", + "rawBytes": 319734, + "minifiedBytes": 210635, + "gzipBytes": 54492, + "brotliBytes": 45949 }, { "id": "mtsdf-runtime-js", "label": "MSDF runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "e37ee67e41b1eea55e40265540563cb8725e429cb59825f62ef701b2270a2738", - "rawBytes": 319143, - "minifiedBytes": 210255, - "gzipBytes": 54385, - "brotliBytes": 45903 + "sha256": "b238e093d562cc7d85bc9b802f1db5aadaa10042534c946750e6fd25b65ced1e", + "rawBytes": 319730, + "minifiedBytes": 210639, + "gzipBytes": 54494, + "brotliBytes": 45983 }, { "id": "slug-runtime-js", "label": "Slug runtime JS graph", "status": "measured", "format": "javascript", - "sha256": "eebc3263d524a52a8f39c8c3c6e0fb0b894b525ba6d09cecee8714b3fa002a71", - "rawBytes": 319145, - "minifiedBytes": 210250, - "gzipBytes": 54318, - "brotliBytes": 45862 + "sha256": "7aeb11852c0962a9eec22d75fcebd62d5d863f922a9834e124fc3ccf49626201", + "rawBytes": 319732, + "minifiedBytes": 210634, + "gzipBytes": 54431, + "brotliBytes": 45956 }, { "id": "bitmap-baker-wasm", diff --git a/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx index 48ee350b..741455a3 100644 --- a/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx +++ b/apps/benchmarks/src/surfaces/benchmark/comparison-workload-viewport.tsx @@ -234,7 +234,10 @@ export function ComparisonWorkloadViewport({ fontFixture, fontSize, iconGridView: presentationPreset === 'icon-grid-return' ? 'alternate' : 'origin', - layoutWidthRatio, + // Runtime defaults and the route update are separate reactive stores. During the transition into Off-axis / 3D, + // its authored 120% default can therefore be observed for one render with the preceding workload. Keep every + // intermediate configuration valid without weakening the scene's workload-specific contract. + layoutWidthRatio: workload === 'off-axis-3d' ? layoutWidthRatio : Math.min(layoutWidthRatio, 1), paintOpacity, paintShadowEnabled, paintStrokeWidth, diff --git a/docs/log.md b/docs/log.md index 56570241..4442ded3 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,15 @@ ## 2026-08-10 +- **Closed detached WebGL2 PBO updates** — The complete Presentation matrix exposed a deterministic transparent Zoom Text + frame on forced WebGL2 Bitmap. Three's PBO setup had replaced each storage attribute array with a padded retained copy, + while later Rust command-buffer patches still changed only canonical storage. Dirty patches now copy their exact byte + ranges into the detached upload view before texture invalidation; WebGPU retains direct aliasing. A focused integration + fixture proves canonical/upload equality and untouched padding. All 48 Bitmap/MTSDF/Slug × WebGPU/WebGL2 workload + cells remain visible with one renderer. The matrix also closed a benchmark-only transition seam where Off-axis's 120% + default could be observed for one render under the preceding workload's 100% contract. The PBO fix adds 587 raw / 112 + gzip / 64 Brotli bytes to Three; core JavaScript and Wasm remain byte-identical. + - **Final renderer lifecycle size evidence** — Regenerated the canonical package-size record after the final Three retry, dirty-range, disposal, and transform-identity fixes. Renderer-neutral JavaScript and the optimized shaper Wasm remain byte-identical. The complete Three adapter adds 764 raw / 355 minified / 118 gzip / 82 Brotli bytes, putting the diff --git a/docs/packages/benchmarks.md b/docs/packages/benchmarks.md index 3446de0d..4078ca54 100644 --- a/docs/packages/benchmarks.md +++ b/docs/packages/benchmarks.md @@ -5,7 +5,7 @@ description: Provides the shared interactive and automated benchmark product sur resource: ../../apps/benchmarks workspace_package: '@pmndrs/text-benchmarks' documentation_type: reference -source_digest: 'sha256:c09ecc43fdacb3af6d3b707df349a5606b42f92b949f0e19bdc645e28a21194a' +source_digest: 'sha256:cf2de2b128dce9da111498d7f8f18edee9a818d848ebccb604ef551e15b9f296' tags: [package, benchmarks, react, vite, product-e2e] sources: - id: manifest @@ -404,7 +404,7 @@ GitHub CI uses the Ubuntu runner's rolling system Chromium as a deliberate compa The independent package-size lane measures the initial public browser graph, lazy font validator, runtime Worker boundary, baker and shaper JavaScript/Wasm, and Unicode 17 analysis without zero-byte placeholders. Static entry closures and dynamic chunks are separated from Rollup metadata rather than conflated; the browser-core lane externalizes the package's declared `three`, React, and R3F peers, and package-owned Wasm URLs are externalized from JavaScript measurements regardless of their owning package. The report records its measurement platform and architecture plus the SHA-256 identity of each measured payload: minified bundle bytes for JavaScript and emitted module bytes for Wasm. Same-host regeneration is exact; every foreign-host raw/minified/gzip/Brotli result must satisfy the shared reviewed budget table because native Rust/Binaryen and Rolldown output has small cross-architecture byte variance. Coverage-capability growth is independently bounded against its pre-coverage baseline, and foreign-host failures report the measured payload, reviewed ceiling, and exceeded dimensions. The product inspector's selected-runtime total is the gzip transfer sum of the selected raster runtime graph and separately emitted shaper Wasm. The raster graph already contains the shared core and shaper JavaScript host, so adding the independent browser-core or text-shaper-JavaScript measurements would double-count code. Selected runtime and conditional runtime-bake totals are default-collapsed disclosures; their component rows remain available on demand without displacing the separate font-asset total. The font-asset card reports only one transport quantity: gzip bytes for compressed MTSDF artifacts and exact transferred bytes for uncompressed Bitmap/runtime-source assets. Decoded container, raster, and GPU allocation sizes never appear as children of that transfer total; GPU texture allocation remains isolated in the resource card. Each full row is the interaction target, while fixed label, status, and byte columns use a neutral centered chevron, a green check for loaded code, and a gray X for unloaded code. The total intentionally excludes external Three.js, React, and R3F peers plus font assets; those assets remain separate rows rather than being mislabeled as a complete application bundle. -The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,159,317 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 210,251 / 54,386 / 45,828, 210,255 / 54,385 / 45,903, and 210,250 / 54,318 / 45,862 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. +The current Darwin arm64 record reports a 64,157 minified / 17,846 gzip / 15,469 Brotli peer-externalized browser graph and an independently measured 141,127 / 42,406 / 31,287 Unicode analysis graph. The validator, runtime host, runtime Worker JavaScript, portable baker JavaScript, portable baker Wasm, and shaper Wasm report 584,479, 9,524, 8,880, 6,017, 422,538, and 1,159,317 minified/raw bytes respectively. The configurable MTSDF baker host measures 19,076 minified / 5,522 gzip / 4,901 Brotli bytes and its coverage-capable full Wasm measures 552,025 raw bytes; the reviewed host ceiling includes authenticated quality and coverage policy. The current Bitmap, MTSDF, and Slug runtime closures measure 210,635 / 54,492 / 45,949, 210,639 / 54,494 / 45,983, and 210,634 / 54,431 / 45,956 minified/gzip/Brotli bytes. Slug's baker host measures 12,877 / 4,116 / 3,667 and its Wasm measures 465,031 raw / 186,665 gzip / 146,606 Brotli bytes. Bitmap-only and MTSDF-only size entries inspect their initial module closures and fail if Slug runtime, shader, baker, or runtime-baker modules enter either graph. Paragraph layout hashes and the policy composite hash share one implementation over the actual normalized layouts; the generator, benchmark target, unit tests, and Vitexec probes no longer maintain parallel digest logic. The local Worker-queue Vitexec probe authenticates every output and reports observations rather than asserting machine-sensitive timing. Two Chromium runs measured a three-font queued burst at 30.8–32.0 ms and three separately initialized sequential Workers at 68.3–88.6 ms. The correctness suite separately proves one active post, FIFO completion, queued cancellation, and active-cancellation recovery without timers. The combined live lane runs its performance observation before interaction and conformance probes so accumulated renderer work cannot contaminate cold/steady telemetry. diff --git a/docs/packages/text.md b/docs/packages/text.md index 679fa0b5..9136d78e 100644 --- a/docs/packages/text.md +++ b/docs/packages/text.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/text workspace_package: '@pmndrs/text' documentation_type: reference -source_digest: 'sha256:e3c2f2959b1bc39be0add04a7ce0ac91d84ae22bdb03b3189a216a1263ae1f91' +source_digest: 'sha256:61242870023c632bd2ce25324a2d961b249c6de523013d9935cc1ec1f543319c' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest @@ -237,19 +237,26 @@ The latest checked package-size record after the baker ABI cleanup reports: | Graph | Raw | gzip | Brotli | | --------------------------------------- | ----------: | --------: | --------: | | Core JavaScript plus shaper Wasm | 1,247,715 B | 460,130 B | 363,319 B | -| Three adapter plus core and shaper Wasm | 1,488,082 B | 498,494 B | 395,212 B | +| Three adapter plus core and shaper Wasm | 1,488,669 B | 498,606 B | 395,276 B | Three, React, and React Three Fiber are optional peers and excluded from these bundle totals. JavaScript and Wasm are measured independently and then summed because browsers transfer them as separate assets. The optimized shaper is 1,159,317 raw / 442,284 gzip / 347,850 Brotli bytes. The renderer-neutral JavaScript graph is -88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 328,765 raw / 56,210 gzip / -47,362 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 +88,398 raw / 17,846 gzip / 15,469 Brotli, and the complete Three JavaScript graph is 329,352 raw / 56,322 gzip / +47,426 Brotli. Deleting the legacy TypeScript raster packing/lifecycle path reduced the measured core total from 461,917 to 460,901 gzip bytes and the complete Three total from 501,815 to 498,922 gzip bytes; the later shared-emitter and stable range-scan work reduces those totals to 460,416 and 498,437 gzip bytes. The homogeneous-policy dispatch and dirty-range alignment correction moved those totals to 460,458 and 498,479 gzip bytes; the focused planner deduplication and current -Three graph now measure 460,130 and 498,494 gzip bytes. The final renderer-lifecycle fixes leave core and Wasm -byte-identical and add 764 raw / 118 gzip / 82 Brotli bytes to the complete Three graph. +Three graph now measure 460,130 and 498,606 gzip bytes. The final renderer-lifecycle fixes and exact WebGL2 PBO range +copy leave core and Wasm byte-identical and add 1,351 raw / 230 gzip / 146 Brotli bytes to the complete Three graph. + +WebGPU continues to alias canonical plan arrays directly. Three's WebGL2 PBO builder replaces a storage attribute's +array with power-of-two-padded retained texture storage, so later Rust patches copy only their dirty byte ranges into +that detached upload view before invalidating its texture. A focused integration fixture simulates the replacement and +proves exact canonical/upload equality with untouched padding. The complete 48-cell presentation matrix keeps every +Bitmap, MTSDF, and Slug workload visible on WebGPU and forced WebGL2; this is the deliberate one-copy WebGL2 fallback, +not another renderer-side layout or packing path. The corrected complete MTSDF baker remains 552,025 raw / 215,030 gzip / 168,758 Brotli bytes; the earlier 52 KiB observation was a kernel-only test artifact that reused the distributable Cargo target directory. diff --git a/docs/planning/decision-register.md b/docs/planning/decision-register.md index db8a46b0..a8e0357a 100644 --- a/docs/planning/decision-register.md +++ b/docs/planning/decision-register.md @@ -322,6 +322,7 @@ The [architecture](architecture.md) owns loading behavior and dependency rules. | D-235 | Raster techniques stop at identity, artifact decoding, retained CPU resource ownership, and disposal. The obsolete TypeScript `RasterRuntime`, candidate/commit raster transaction, glyph `select`, storage allocation, and record writers are deleted; Rust policy programs are the only production instance packers and dirty-range publishers. A Mori 0.19.1 structural scan corroborates the removed parallel path and flags similar ordered-direct/stable-indirect draw emission for evidence-gated extraction, not deletion: the strategies have distinct slot, order-buffer, and retirement semantics. All 154 Rust engine tests, all 161 package integration tests, Unicode 17 bidi/line-break conformance, both TypeScript projects, lint, and formatting pass. The cleanup leaves Wasm unchanged and reduces measured renderer-neutral JS + Wasm from 461,917 to 460,901 gzip bytes and complete Three + Wasm from 501,815 to 498,922 gzip bytes, with Three, React, and R3F external. | Accepted | | D-236 | Ordered-direct and stable-indirect retain distinct storage engines but share one non-generic, out-of-line primitive/draw command emitter after resolving physical addressing. A symbol-bearing optimized build attributes 33.3 KiB and 50.1 KiB of function bodies to the respective planners; that is an attribution bound, not a duplicate-byte claim. Exact range partitioning also replaces stable planning's quadratic changed-range × slot-write dependency scan, reducing the 22k-target font-size median from 350.136 to 7.982 ms. The combined artifact is 1,160,323 raw / 442,570 gzip / 348,361 Brotli bytes, 220 / 485 / 423 bytes below the pre-extraction artifact. Future transfer-size work compiles separate ABI-identical runtime profiles selected at initialization; provisional `lite`, `cjk`, and `full` membership must be established by final-artifact measurement, and the scalar/SIMD build switch remains orthogonal. | Accepted | +| D-237 | WebGPU aliases canonical Rust-plan typed arrays directly. Three's WebGL2 PBO setup replaces a storage attribute array with power-of-two-padded texture storage, so each later command-buffer dirty range is copied exactly once from canonical storage into that detached upload view before texture invalidation; padding remains untouched. This is a renderer upload adaptation, not a second layout, packing, or render-plan state machine. A simulated-PBO integration regression and all 48 Bitmap/MTSDF/Slug × WebGPU/WebGL2 Presentation workload cells pass. | Accepted | The [raster contract](raster-data-contract.md) owns records. The [capability matrix](renderer-capabilities.md), [payload budget](payload-budget.md), and [compression analysis](gpu-compression.md) own evidence and limitations. diff --git a/packages/text/src/three/engine-plan-target.ts b/packages/text/src/three/engine-plan-target.ts index a3fe4b2e..5f4de5f7 100644 --- a/packages/text/src/three/engine-plan-target.ts +++ b/packages/text/src/three/engine-plan-target.ts @@ -1281,11 +1281,23 @@ function markUpdated(buffer: RetainedBuffer, byteOffset: number, byteLength: num if (byteOffset % scalarBytes !== 0 || byteLength % scalarBytes !== 0) { throw new RangeError('buffer patch is not scalar aligned'); } + syncDetachedUploadRange(buffer, byteOffset, byteLength); mergeUpdateRange(buffer.attribute, byteOffset / scalarBytes, byteLength / scalarBytes); buffer.attribute.needsUpdate = true; invalidatePboTexture(buffer.attribute); } +function syncDetachedUploadRange(buffer: RetainedBuffer, byteOffset: number, byteLength: number): void { + const upload = buffer.attribute.array; + if (upload === buffer.array) return; + if (upload.constructor !== buffer.array.constructor || upload.byteLength < buffer.array.byteLength) { + throw new TypeError('Three replaced a text-plan upload array with an incompatible view'); + } + const source = new Uint8Array(buffer.array.buffer, buffer.array.byteOffset + byteOffset, byteLength); + const destination = new Uint8Array(upload.buffer, upload.byteOffset + byteOffset, byteLength); + destination.set(source); +} + function mergeUpdateRange(attribute: THREE.BufferAttribute, start: number, count: number): void { let mergedStart = start; let mergedEnd = start + count; diff --git a/packages/text/tests/integration/three-v1.test.mjs b/packages/text/tests/integration/three-v1.test.mjs index 68b1bb7c..73e01142 100644 --- a/packages/text/tests/integration/three-v1.test.mjs +++ b/packages/text/tests/integration/three-v1.test.mjs @@ -434,6 +434,10 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn shiftedLeftX[0] += 2; shiftedRightX[0] += 4; const originsAttribute = draws[0].geometry.getAttribute('_pmndrsText_1'); + const canonicalOrigins = originsAttribute.array; + const pboUploadOrigins = new Float32Array(canonicalOrigins.length + 4); + pboUploadOrigins.set(canonicalOrigins); + originsAttribute.array = pboUploadOrigins; originsAttribute.clearUpdateRanges(); left.setGlyphOrigins({ layout: leftOrigins.layout, x: shiftedLeftX, y: leftOrigins.shapedY }); const leftUploadRanges = originsAttribute.updateRanges.map((range) => ({ ...range })); @@ -448,6 +452,12 @@ test('TextGroup realizes two public Text objects as one indexed Rust draw', asyn ); assert.equal(left.snapshotGlyphOrigins().displayedX[0], leftOrigins.shapedX[0] + 2); assert.equal(right.snapshotGlyphOrigins().displayedX[0], rightOrigins.shapedX[0] + 4); + assert.deepEqual( + pboUploadOrigins.subarray(0, canonicalOrigins.length), + canonicalOrigins, + 'WebGL2 PBO replacement storage must receive the same dirty ranges as canonical plan storage', + ); + assert.deepEqual(pboUploadOrigins.subarray(canonicalOrigins.length), new Float32Array(4)); const version = transforms.version; right.position.x = 7; From f868035108e570bf58a69dacd8ebcda671a65e1e Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 02:30:02 -0400 Subject: [PATCH 126/128] docs(text): close render-plan foundation --- docs/log.md | 5 +++-- docs/roadmap/roadmap.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/log.md b/docs/log.md index 4442ded3..dcfa7a4d 100644 --- a/docs/log.md +++ b/docs/log.md @@ -46,8 +46,9 @@ integration tests pass. The direct benchmark's default changes from 5/11 to 8/31 warmup/measured samples so p95 is no longer the maximum observation by construction. -- **Passed the complete foundation gate and refreshed release evidence** — The root check passes 158 Rust library tests, - 165 Node integration tests, unchanged Unicode 17 vectors, 111 benchmark-app tests, all 16 isolated Chromium targets, +- **Passed the complete foundation gate and refreshed release evidence** — The exact pushed foundation commit passes + 158 Rust library tests, 165 Node integration tests, unchanged Unicode 17 vectors, 112 benchmark-app tests, all 16 + isolated Chromium targets, production builds, the R3F live GPU interaction, formatting, lint, types, packaging, and OKF validation. Sequential eight-warmup/31-sample 25,515-positioned-glyph runs put Bitmap/MTSDF/Slug cold medians at 16.02/16.60/16.83 ms, font-size at 6.01/6.42/6.67 ms, column width at 2.77/2.77/2.87 ms, and suffix edits at 13.73/13.75/13.59 ms. Every diff --git a/docs/roadmap/roadmap.md b/docs/roadmap/roadmap.md index a56cf968..2442e21d 100644 --- a/docs/roadmap/roadmap.md +++ b/docs/roadmap/roadmap.md @@ -156,7 +156,7 @@ These rows replace the former separate backlog. Each is intended to become one f | 11.13 | ⬜ | Prove the shaping and layout contract can represent a break-inserted hyphen glyph that has no source cluster, and fix the contract if it cannot. Language patterns, break selection, and justification quality controls remain later work. | M | 11.6 | | 11.14 | ⬜ | Add the professional typography the editorial showcase requires: `wordSpacing`, first-line indent, paragraph space before/after, and justification controls covering minimum/maximum word-space ratio, letter-space expansion, and last-line policy. | L | 11.12–11.13 | | 11.15 | ⬜ | Settle Three material authority, so applications supply their own `NodeMaterial` and gain lighting, shadows, and depth-composited effects without implementing a raster program. Resolve the open edges in the [material authority concept](../planning/three-material-authority.md) first; it is a recorded proposal, not an accepted design. | M | 11.6 | -| 11.16 | 🟡 | Replace duplicate TypeScript shaping, layout, packing, and dirty-plan work with one retained Rust/Wasm frame transaction, validated renderer policy, and incremental render plan; land the Rust, policy/plan, and Three adapter PRs as one coordinated stack after exact Bitmap/MSDF/Slug, benchmark-app, size, and browser parity. | XL | 11.6 | +| 11.16 | ✅ | Replace duplicate TypeScript shaping, layout, packing, and dirty-plan work with one retained Rust/Wasm frame transaction, validated renderer policy, and incremental render plan; land the Rust, policy/plan, and Three adapter PRs as one coordinated stack after exact Bitmap/MSDF/Slug, benchmark-app, size, and browser parity. | XL | 11.6 | | 11.17 | ⬜ | Add paragraph-scoped synchronous prepare/query and candidate adoption: measure one pending paragraph per call without compiling a render plan, retain one session transaction with linear identity reservation, and reuse its paragraph-keyed results in the next full frame without a third full buffer. | L | 11.16 | | 11.18 | ⬜ | Complete the Rust engine's realtime publishing set over that proven path: spacing, decorations, interaction geometry, horizontal and vertical writing, one-call exclusions and sequential regions, bounded CJK tailoring, and optional color-emoji fallback, excluding every explicitly cut unbounded solver or second authored text stream. | XL | 11.16 | From 592c4876b30444141572af951802411647bd9cac Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 02:53:33 -0400 Subject: [PATCH 127/128] fix(ci): execute size adapter through shell --- .github/workflows/ci.yml | 4 ++-- docs/log.md | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76254e3e..e8e6c025 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,8 +49,8 @@ jobs: # accepts the base branch's older report, derives the same core total, # and emits Size Limit's stable [{ name, size }] protocol. script: >- - node apps/benchmarks/scripts/measure-package-sizes.mts | - node -e "let input='';process.stdin.on('data',chunk=>input+=chunk).on('end',()=>{const report=JSON.parse(input);const entries=report.entries.filter(entry=>entry.status==='measured');const byId=new Map(entries.map(entry=>[entry.id,entry]));if(!byId.has('renderer-neutral-core-total')){const js=byId.get('browser-core');const wasm=byId.get('text-shaper-wasm');if(js&&wasm)byId.set('renderer-neutral-core-total',{id:'renderer-neutral-core-total',format:'aggregate',gzipBytes:js.gzipBytes+wasm.gzipBytes,brotliBytes:js.brotliBytes+wasm.brotliBytes})}const tracked=/^(browser-core|text-shaper-wasm|renderer-neutral-core-total|three-runtime-js|three-renderer-total|font-(inter|icons)-|delivery-three-)/;const rows=[...byId.values()].filter(entry=>tracked.test(entry.id)).flatMap(entry=>entry.format==='javascript'?[{name:entry.id+' (brotli)',size:entry.brotliBytes}]:entry.format==='aggregate'?[{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]:[{name:entry.id+' (raw)',size:entry.rawBytes},{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]);process.stdout.write(JSON.stringify(rows))})" + sh -c "node apps/benchmarks/scripts/measure-package-sizes.mts | + node -e \"let input='';process.stdin.on('data',chunk=>input+=chunk).on('end',()=>{const report=JSON.parse(input);const entries=report.entries.filter(entry=>entry.status==='measured');const byId=new Map(entries.map(entry=>[entry.id,entry]));if(!byId.has('renderer-neutral-core-total')){const js=byId.get('browser-core');const wasm=byId.get('text-shaper-wasm');if(js&&wasm)byId.set('renderer-neutral-core-total',{id:'renderer-neutral-core-total',format:'aggregate',gzipBytes:js.gzipBytes+wasm.gzipBytes,brotliBytes:js.brotliBytes+wasm.brotliBytes})}const tracked=/^(browser-core|text-shaper-wasm|renderer-neutral-core-total|three-runtime-js|three-renderer-total|font-(inter|icons)-|delivery-three-)/;const rows=[...byId.values()].filter(entry=>tracked.test(entry.id)).flatMap(entry=>entry.format==='javascript'?[{name:entry.id+' (brotli)',size:entry.brotliBytes}]:entry.format==='aggregate'?[{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]:[{name:entry.id+' (raw)',size:entry.rawBytes},{name:entry.id+' (gzip)',size:entry.gzipBytes},{name:entry.id+' (brotli)',size:entry.brotliBytes}]);process.stdout.write(JSON.stringify(rows))})\"" check: name: Check diff --git a/docs/log.md b/docs/log.md index dcfa7a4d..a2910ea4 100644 --- a/docs/log.md +++ b/docs/log.md @@ -2,6 +2,12 @@ ## 2026-08-10 +- **Fixed stacked-PR size reporting at the action boundary** — The pinned Size Limit action executes its configured + command directly rather than through a shell, so the compatibility pipe had been passed to the measurement script as + inert arguments and the action received the full report object. The workflow now supplies the same base-compatible + adapter through an explicit `sh -c` boundary. Executing the exact parsed workflow command locally emits 39 validated + `{name, size}` rows. + - **Closed detached WebGL2 PBO updates** — The complete Presentation matrix exposed a deterministic transparent Zoom Text frame on forced WebGL2 Bitmap. Three's PBO setup had replaced each storage attribute array with a padded retained copy, while later Rust command-buffer patches still changed only canonical storage. Dirty patches now copy their exact byte From e4acbee6c3b8aa8b52f376b095e25b26dd503c74 Mon Sep 17 00:00:00 2001 From: Justin Walsh Date: Mon, 10 Aug 2026 03:04:05 -0400 Subject: [PATCH 128/128] fix(ci): provision example subsetter --- .github/workflows/ci.yml | 5 ++++- apps/benchmarks/scripts/provision-harfbuzz.mts | 16 +++++++++++++--- docs/log.md | 7 +++++++ docs/packages/benchmarks.md | 4 ++-- docs/packages/r3f-hello-world.md | 1 + docs/planning/version-contract.md | 8 ++++++-- 6 files changed, 33 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8e6c025..7ea88d22 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,7 +82,10 @@ jobs: run: pnpm install --frozen-lockfile - name: Provision authenticated HarfBuzz utilities - run: pnpm scripts run fixture:harfbuzz:provision + run: | + pnpm scripts run fixture:harfbuzz:provision + mise -C apps/benchmarks exec -- node ./scripts/provision-harfbuzz.mts --version=14.2.0 + printf '%s\n' "$GITHUB_WORKSPACE/apps/benchmarks/.cache/harfbuzz/14.2.0/build/util" >> "$GITHUB_PATH" - name: Select rolling runner Chromium shell: bash diff --git a/apps/benchmarks/scripts/provision-harfbuzz.mts b/apps/benchmarks/scripts/provision-harfbuzz.mts index 41f99eaf..0abb6ffa 100644 --- a/apps/benchmarks/scripts/provision-harfbuzz.mts +++ b/apps/benchmarks/scripts/provision-harfbuzz.mts @@ -3,8 +3,17 @@ import { createHash } from 'node:crypto'; import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; -const version = '13.0.0'; -const archiveSha256 = '1626ebc763d28f4bcca1531fef42e92ca995d45f8ad90ad2ae0b5d1a567fe67a'; +const archiveSha256ByVersion = { + '13.0.0': '1626ebc763d28f4bcca1531fef42e92ca995d45f8ad90ad2ae0b5d1a567fe67a', + '14.2.0': '94017020f96d025bb66ae91574e4cf334bcad23e8175a8a40565b3721bc2eaff', +} as const; +const versionArgument = process.argv.find((argument) => argument.startsWith('--version=')); +const requestedVersion = versionArgument?.slice('--version='.length) ?? '13.0.0'; +if (!(requestedVersion in archiveSha256ByVersion)) { + throw new Error(`unsupported HarfBuzz utility version ${requestedVersion}`); +} +const version = requestedVersion as keyof typeof archiveSha256ByVersion; +const archiveSha256 = archiveSha256ByVersion[version]; const cacheDirectory = resolve('.cache/harfbuzz', version); const executable = resolve(cacheDirectory, 'build/util/hb-shape'); const subsetExecutable = resolve(cacheDirectory, 'build/util/hb-subset'); @@ -108,7 +117,8 @@ async function run(command: string, arguments_: readonly string[]): Promise` through a real React Three Fiber root backed by `WebGPURenderer`, retains one forwarded core object through width reflow and canonical restoration, matches pinned natural/narrow paragraph oracles, verifies two span paints in one draw, and submits a real renderer frame over three deterministic samples. The live pending-resource probe intercepts the exact composed Inter request behind a manually released promise, observes the Suspense fallback before publication, releases the request without a timer, then proves the registered font key and all 2,937 glyphs before deterministic cleanup. The test renderer remains confined to package integration evidence and does not enter the product registry or application dependencies. diff --git a/docs/packages/r3f-hello-world.md b/docs/packages/r3f-hello-world.md index 129646df..02e3e21d 100644 --- a/docs/packages/r3f-hello-world.md +++ b/docs/packages/r3f-hello-world.md @@ -45,6 +45,7 @@ and React Three Fiber remain ordinary workspace peers rather than part of the co ## Commands ```sh +mise -C apps/benchmarks exec -- node ./scripts/provision-harfbuzz.mts --version=14.2.0 mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world dev mise exec -- pnpm --filter @pmndrs/text-r3f-hello-world check ``` diff --git a/docs/planning/version-contract.md b/docs/planning/version-contract.md index 3f2af8c9..8c4943a2 100644 --- a/docs/planning/version-contract.md +++ b/docs/planning/version-contract.md @@ -11,6 +11,9 @@ sources: - id: harfbuzz resource: https://github.com/harfbuzz/harfbuzz/releases/tag/13.0.0 title: HarfBuzz 13.0.0 + - id: harfbuzz-r3f-assets + resource: https://github.com/harfbuzz/harfbuzz/releases/tag/14.2.0 + title: HarfBuzz 14.2.0 - id: harfbuzz-utilities-build resource: https://github.com/harfbuzz/harfbuzz/blob/a0fc099681a69ae40665fbea74982a2e9d7a5260/util/meson.build title: HarfBuzz 13.0.0 utility build definition @@ -82,9 +85,10 @@ These values are exact fixture and provenance inputs. “Latest” is never a va | Surface | Pin | Source identity | | ------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Rust toolchain | `1.97.1` | `rust-toolchain.toml` | -| HarfBuzz build system | Meson `1.11.1` + Ninja `1.13.2` | exact workload-scoped `apps/benchmarks/mise.toml` pins; used only to build the authenticated HarfBuzz oracle utilities from source | +| HarfBuzz build system | Meson `1.11.1` + Ninja `1.13.2` | exact workload-scoped `apps/benchmarks/mise.toml` pins; used only to build the authenticated HarfBuzz oracle utilities from source | | HarfRust | `0.12.0` | tag commit `60b28ea22b5261710018d69c168a762bcb28794c` | | HarfBuzz oracle | `13.0.0` | tag commit `a0fc099681a69ae40665fbea74982a2e9d7a5260` | +| R3F example asset subsetter | HarfBuzz `14.2.0` | authenticated release archive; used only to reproduce the checked Inter Latin and Font Awesome globe assets | | MTSDF quality oracle | Chlumsky `msdfgen` `1.13.0` | tag `v1.13`, commit `1874bcf7d9624ccc85b4bc9a85d78116f690f35b`; source archive SHA-256 `93cd1ad8918c1a78c5c96e82d4f4c77f0eb86c2e7e8579a0967e54196c4b7167` | | Unicode | `17.0.0` | versioned UCD and UAX data | | Unicode Script/Script_Extensions data | `@unicode/unicode-17.0.0` `1.6.17` | build-only generated range-table source | @@ -111,7 +115,7 @@ These values are exact fixture and provenance inputs. “Latest” is never a va | KTX2 Rust model/parser | `ktx2` `0.5.0` | compile-time R8 DFD generation plus native artifact validation | | KTX2 JavaScript parser | `ktx-parse` `1.1.0` | package-owned artifact and runtime page validation | -GLib development metadata is a native build-host prerequisite for the HarfBuzz benchmark workload, not a root contributor requirement or fixture identity input. HarfBuzz 13.0.0 gates its `hb-shape` and `hb-subset` targets on `HAVE_GLIB`; the provisioner therefore requires `-Dglib=enabled`, and the pinned Ubuntu 24.04 CI job installs `libglib2.0-dev` explicitly and prints the resolved `glib-2.0` version. Every unrelated optional HarfBuzz backend is disabled explicitly so host-installed FreeType, Cairo, ICU, CoreText, or experimental raster/vector dependencies cannot change the source-build graph. GLib owns the utility frontend, while the authenticated HarfBuzz source and exact generated-byte comparison remain authoritative for fixture semantics. Contributors may supply the documented versions directly; the nested mise config is the reproducible installation option and does not create a second task surface. +GLib development metadata is a native build-host prerequisite for the HarfBuzz benchmark workload, not a root contributor requirement or fixture identity input. HarfBuzz 13.0.0 and 14.2.0 gate their `hb-shape` and `hb-subset` targets on `HAVE_GLIB`; the shared provisioner therefore requires `-Dglib=enabled`, authenticates each version's source archive independently, and the pinned Ubuntu 24.04 CI job installs `libglib2.0-dev` explicitly and prints the resolved `glib-2.0` version. The 13.0.0 build remains the shaping oracle and CJK fixture authority; 14.2.0 is isolated to the R3F example's checked asset subsets. Every unrelated optional HarfBuzz backend is disabled explicitly so host-installed FreeType, Cairo, ICU, CoreText, or experimental raster/vector dependencies cannot change the source-build graph. GLib owns the utility frontend, while the authenticated HarfBuzz source and exact generated-byte comparison remain authoritative for fixture semantics. Contributors may supply the documented versions directly; the nested mise config is the reproducible installation option and does not create a second task surface. ## Generated contract