diff --git a/docs/packages/glyph.md b/docs/packages/glyph.md index f052dffdd..868e5cd65 100644 --- a/docs/packages/glyph.md +++ b/docs/packages/glyph.md @@ -5,7 +5,7 @@ description: Implements portable font loading, retained Rust shaping and layout, resource: ../../packages/glyph workspace_package: '@pmndrs/glyph' documentation_type: reference -source_digest: 'sha256:f0d02444415a88d11af570a4dd49ef7681316756fd3aa9e3a6f7d7ea09d96c82' +source_digest: 'sha256:5d9e3d71131a7bc1206842e1be7e9a5799a27ec8e2a64831e78acb158aa4ff96' tags: [package, public-api, rust, wasm, threejs, typography] sources: - id: manifest diff --git a/packages/glyph/tests/integration/three-engine-runtime.test.mjs b/packages/glyph/tests/integration/three-engine-runtime.test.mjs index fc701fefd..9cb35216a 100644 --- a/packages/glyph/tests/integration/three-engine-runtime.test.mjs +++ b/packages/glyph/tests/integration/three-engine-runtime.test.mjs @@ -1043,6 +1043,131 @@ test('Three coordinator shares shaping data across technique bindings and refere decorationTarget.dispose(); decorationSession.dispose(); + // Regression canary: a colored span whose fontSize animates through value-equality + // with its root keeps its style segment (the paint differs) while the shaping-run + // table merges across it -- `same_layout_style` compares layout scalars and ignores + // paint, and `font_size` is one of those scalars. When the next tick moves the size + // off equality the table splits again under metrics-only invalidation. The engine + // once retained the merged shape against the rebuilt table and rejected every later + // frame with invalidRequest, poisoning the session; `shaping_run_topology_stable` + // in engine/state.rs was added to stop that. + // + // This drives the merge -> split path end to end and asserts the session stays + // healthy across it. It does not isolate that one guard: forcing + // `shaping_run_topology_stable` to return `true` -- the pre-fix behaviour -- leaves + // this green, so a later path absorbs the split as well. Treat it as a canary over + // the whole retained-shape path rather than as proof of a single predicate. + const topologySession = coordinator.createSession({ + requestCapacity: 8_192, + resultCapacity: 1024 * 1024, + textCapacity: 64, + }); + const topologyStyles = (spanSize) => [ + { + opcode: 'upsert', + paragraphId: 1, + styleId: 1, + cascadeOrder: 0, + start: 0, + end: 24, + root: true, + value: { + fontStackHandle: first.handle, + materialId: primaryMaterial.id, + fontSize: 16, + rasterPixelRatio: 1, + foregroundRgba: 0xffff_ffff, + }, + }, + { + opcode: 'upsert', + paragraphId: 1, + styleId: 2, + cascadeOrder: 1, + start: 8, + end: 16, + value: { fontSize: spanSize, foregroundRgba: 0xff44_22ff }, + }, + ]; + const topologyFrame = (previous, spanSize, extra = {}) => + compileTextEngineFrameUpdate({ + sessionId: topologySession.handle, + policyHandle: coordinator.policyHandle, + capabilitySet: 1, + expectedEngineRevision: previous?.engineRevision ?? 0, + consumedPlanRevision: previous?.planRevision ?? 0, + acknowledgedPublicationGeneration: previous?.publicationGeneration ?? 0, + limits: { + maxParagraphs: 1, + maxClusters: 32, + maxLines: 8, + maxRegions: 1, + maxExclusions: 1, + maxInlineObjects: 1, + maxSlotsPerBand: 2, + maxOutputBytes: 1024 * 1024, + }, + styleMutations: topologyStyles(spanSize), + ...extra, + }); + // Frame 1: the span size equals the root — shaping runs merge across the span. + const topologyFirst = topologySession.update( + topologyFrame(undefined, 16, { + paragraphMutations: [{ opcode: 'upsert', paragraphId: 1, order: 0 }], + textMutations: [{ paragraphId: 1, start: 0, deleteCount: 0, insert: 'alpha beta gamma epsilon' }], + constraints: [ + { + paragraphId: 1, + flowThreadId: 1, + geometryRevision: 1, + width: 512, + 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: 512, + blockEnd: 128, + clipInlineStart: 0, + clipBlockStart: 0, + clipInlineEnd: 512, + clipBlockEnd: 128, + }, + ], + }), + ); + // Frame 2: the span size moves off equality — the run table splits again and the + // retained shape must be rebuilt, not reused. + const topologySecond = topologySession.update(topologyFrame(topologyFirst, 17.5)); + assert.equal(topologySecond.engineRevision, topologyFirst.engineRevision + 1); + // Frame 3: and the session must remain healthy afterwards. + const topologyThird = topologySession.update(topologyFrame(topologySecond, 18.25)); + assert.equal(topologyThird.engineRevision, topologySecond.engineRevision + 1); + topologySession.dispose(); + target.dispose(); session.dispose(); first.release();