Rework WebGL book playback to single ownership and fix flip/reveal pipeline
Establish book-playback-timeline as the sole playback owner driving the scene through formal webgl-book:* events (not the BookLabDebug surface), with a single reveal clock in the scene render loop and webgl-page-cache as the only texture cache. Remove the legacy dual playback path and the ownsPageFlipCommit gating. Fixes: - Flip page detached/folded at the spine: restore the raw page-cap line for flip geometry (matches the prototype/pre-regression), removing normalizeFlipLineToVisiblePage which moved the pivot off the spine arc. - Flip textures: distance-based UVs (no horizontal compression), direction-aware face material (source on the camera-facing side), source meta derived from the visible spread (manual flips), prewarm shape fix. - Reveal: flash removed on the static page and the flip back surface; spanning blocks rebuild the reveal plan at activate and continue the reveal on the next spread after the fill flip. - Cache staleness is contentVersion-primary; nav clamps to spreadCount. Docs updated to describe the intended single-owner architecture. Regression checks updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+72
-22
@@ -52,40 +52,90 @@ This section records the current state after the procedural book integration wor
|
|||||||
- The custom book material shader includes a small local indirect/bounce-light term for book surfaces. This is not emissive; it is multiplied by material albedo so paper, leather, and head/end-band colors remain distinct while shadowed side faces stay readable.
|
- The custom book material shader includes a small local indirect/bounce-light term for book surfaces. This is not emissive; it is multiplied by material albedo so paper, leather, and head/end-band colors remain distinct while shadowed side faces stay readable.
|
||||||
- Temporary screenshots and generated debug images are not product assets unless explicitly promoted.
|
- Temporary screenshots and generated debug images are not product assets unless explicitly promoted.
|
||||||
|
|
||||||
## Current Page-Flow Architecture
|
## Page-Flow Architecture
|
||||||
|
|
||||||
The 3D book pipeline is module-owned. No page content should be generated by ad hoc scene code.
|
The 3D book is the primary display. A secondary 2D DOM view may be driven from the same content,
|
||||||
|
but it is never the source of truth and must not own playback decisions. No page content is
|
||||||
|
generated by ad hoc scene code.
|
||||||
|
|
||||||
- `ui-display-handler` owns the live 3D prepare -> optional page flip -> activate sequence for story text.
|
### Single ownership
|
||||||
- `sentence-queue` may prepare future page presentations during lookahead using the same pagination and texture renderer modules.
|
|
||||||
- `book-pagination` owns page/spread construction, page metadata, widows/orphans/hyphenation decisions, image placement decisions, and explicit blank/title/body page records.
|
|
||||||
- `book-texture-renderer` owns drawing final page canvases, reveal-region coordinates, reveal timing metadata, and publishing explicit `webgl-book:page-texture-records`.
|
|
||||||
- `webgl-page-cache` owns persistent page canvases, memory canvases, prepared reveal plans, prepared GPU textures, resident VRAM textures, blank page texture, and visible texture bindings.
|
|
||||||
- `webgl-book-lab` owns the Three.js scene, materials, geometry, pointer projection, page flip meshes, and consuming page texture records. It must not become a second page cache.
|
|
||||||
|
|
||||||
Problem states must be surfaced instead of hidden:
|
There is exactly one playback owner: `book-playback-timeline`. It owns the complete content
|
||||||
|
lifecycle for story text and is the only module allowed to sequence it:
|
||||||
|
|
||||||
|
```
|
||||||
|
prepare (pagination + textures + prewarm)
|
||||||
|
-> activate (make the target spread the visible spread)
|
||||||
|
-> reveal (animate the new block's text in)
|
||||||
|
-> flip (turn the page when a spread boundary is crossed)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ui-display-handler` and `sentence-queue` are clients of the owner. They call
|
||||||
|
`book-playback-timeline.playSentence` (live) and `prepareSentence` (lookahead). They contain no
|
||||||
|
flip, reveal, or spread-transition logic of their own.
|
||||||
|
- `book-pagination` owns page/spread construction, page metadata, widows/orphans/hyphenation,
|
||||||
|
image placement, and explicit blank/title/body page records. It does not decide playback timing.
|
||||||
|
- `book-texture-renderer` owns drawing final page canvases and computing reveal-region coordinates
|
||||||
|
and per-region timing metadata. It is a pure renderer: it does **not** run a playback clock, does
|
||||||
|
**not** decide when reveals start/finish, and does **not** trigger flips. Reactive redraws keyed
|
||||||
|
off pagination events are forbidden; the owner asks it to draw.
|
||||||
|
- `webgl-page-cache` is the single texture/canvas cache: persistent canvases, memory canvases,
|
||||||
|
prepared reveal plans, prepared GPU textures, resident VRAM textures, the blank texture, and
|
||||||
|
visible texture bindings. No other module may keep a parallel cache.
|
||||||
|
- `webgl-book-scene` (implemented in `webgl-book-lab.js`) owns the Three.js scene, materials,
|
||||||
|
geometry, pointer projection, page-flip meshes, the single reveal clock, and consuming page
|
||||||
|
texture records. It must not become a second page cache and must not decide playback order.
|
||||||
|
|
||||||
|
### Single reveal clock
|
||||||
|
|
||||||
|
Reveal timing has exactly one authority: the scene render loop. It advances reveal progress,
|
||||||
|
freezes it during a physical flip, and emits `webgl-book:reveal-committed` when a side's reveal
|
||||||
|
finishes. No module runs a second `setTimeout`/`requestAnimationFrame` reveal clock in parallel.
|
||||||
|
The texture renderer supplies region timings; it does not measure their elapsed time.
|
||||||
|
|
||||||
|
### Owner-to-scene command channel
|
||||||
|
|
||||||
|
The owner drives the scene through the registered scene command interface obtained from the module
|
||||||
|
registry (`webgl-book-scene`), or through the formal `webgl-book:*` events listed below. The owner
|
||||||
|
must never reach into `window.BookLabDebug` — that object is a debug/inspection surface only and is
|
||||||
|
not part of any production control path. Production code must not throw because a debug global is
|
||||||
|
missing.
|
||||||
|
|
||||||
|
### Problem states are surfaced, never hidden
|
||||||
|
|
||||||
- A missing persistent page canvas during prewarm is a `db-cache-miss`.
|
- A missing persistent page canvas during prewarm is a `db-cache-miss`.
|
||||||
- A missing source or required back texture before a page flip is a `flip-source-texture-missing` or `flip-back-texture-missing`.
|
- A missing source or required back texture before a page flip is a `flip-source-texture-missing`
|
||||||
- These problem states must appear in `webglPageCacheProblems` and must not be silently fixed by borrowing unrelated visible stack textures.
|
or `flip-back-texture-missing`.
|
||||||
|
- These appear in `webglPageCacheProblems` and must not be silently fixed by borrowing unrelated
|
||||||
|
visible stack textures. Surfacing a problem must not abort live reading: a transient miss degrades
|
||||||
|
(blank/last-good texture) and is logged, rather than throwing out of the playback path.
|
||||||
|
|
||||||
## Event Surface
|
## Event Surface
|
||||||
|
|
||||||
The preferred 3D book content path is direct module calls through `ui-display-handler` for live playback and `sentence-queue` for lookahead preparation.
|
The owner controls the scene through the scene command interface (preferred for direct,
|
||||||
|
ordered calls) and through these formal events. Each event has one producer and stable meaning:
|
||||||
|
|
||||||
The following events remain formal integration events and must keep their meaning stable while they exist:
|
- `webgl-book:page-texture-records` (owner/renderer -> scene): publishes explicit page texture
|
||||||
|
records for a spread, carrying `phase` (`prepare`|`activate`) and per-side `visibility`.
|
||||||
|
- `webgl-book:page-reveal-start` (owner -> scene): starts the scene reveal clock for a block.
|
||||||
|
- `webgl-book:page-reveal-fast-forward` (owner -> scene): accelerates reveal timing without
|
||||||
|
replacing the page pipeline.
|
||||||
|
- `webgl-book:reveal-committed` (scene -> owner): a page-side reveal completed. The owner — not the
|
||||||
|
scene — decides whether a flip follows.
|
||||||
|
- `webgl-book:request-page-flip` (owner -> scene): requests a physical page flip.
|
||||||
|
- `webgl-book:page-flip-started`, `webgl-book:page-flip-near-end`, `webgl-book:page-flip-finished`
|
||||||
|
(scene -> owner): the physical flip lifecycle, each carrying the resolved `targetSpread`.
|
||||||
|
|
||||||
- `webgl-book:page-texture-records` publishes explicit page texture records from `book-texture-renderer` to the WebGL scene.
|
The scene reacts to these events; it does not originate flip decisions from `reveal-committed`.
|
||||||
- `webgl-book:page-reveal-start` starts shader reveal timing for the prepared block.
|
|
||||||
- `webgl-book:page-reveal-fast-forward` accelerates reveal timing without replacing the page pipeline.
|
|
||||||
- `webgl-book:reveal-committed` reports that a page-side reveal completed; if `pageFlipAfterReveal` is true, the WebGL scene may arm a page flip.
|
|
||||||
- `webgl-book:request-page-flip` requests a physical page flip through the WebGL scene.
|
|
||||||
- `webgl-book:page-flip-started`, `webgl-book:page-flip-near-end`, and `webgl-book:page-flip-finished` describe the physical flip lifecycle.
|
|
||||||
|
|
||||||
Deprecated or forbidden event contracts:
|
Deprecated or forbidden contracts:
|
||||||
|
|
||||||
- `webgl-book:page-canvases` is obsolete. New code must use `webgl-book:page-texture-records`.
|
- `webgl-book:page-canvases` is obsolete; use `webgl-book:page-texture-records`.
|
||||||
- `preloadOnly` and `allowFutureUnrendered` are obsolete boolean flags. New code must use explicit `phase` and `visibility` values.
|
- `preloadOnly` and `allowFutureUnrendered` boolean flags are obsolete; use explicit `phase` and
|
||||||
|
`visibility` values.
|
||||||
|
- The legacy `ownsPageFlipCommit` toggle and the `book-pagination:spread-updated`-driven reveal/flip
|
||||||
|
path in `book-texture-renderer` and the scene are removed. There is no second playback path to
|
||||||
|
gate, so no gating flag exists.
|
||||||
|
|
||||||
## Non-Negotiable Workflow Rules
|
## Non-Negotiable Workflow Rules
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,44 @@
|
|||||||
/**
|
/**
|
||||||
* Book Playback Timeline Module
|
* Book Playback Timeline Module
|
||||||
* Owns prepared WebGL book playback order: pagination, texture readiness,
|
*
|
||||||
* reveal start, page-flip timing, and visual completion.
|
* The single owner of WebGL book playback. It sequences the full content
|
||||||
|
* lifecycle for story text:
|
||||||
|
*
|
||||||
|
* prepare (pagination + textures + prewarm)
|
||||||
|
* -> commit (resolve the authoritative target spread)
|
||||||
|
* -> flip (animate a page turn when a spread boundary is crossed)
|
||||||
|
* -> activate (upload the visible textures for the target spread)
|
||||||
|
* -> reveal (animate the new block's text in)
|
||||||
|
*
|
||||||
|
* It drives the scene exclusively through the formal `webgl-book:*` events and
|
||||||
|
* the registered `webgl-book-scene` accessor. It never touches `window.BookLabDebug`
|
||||||
|
* (debug-only) and never throws out of the live playback path: a transient cache
|
||||||
|
* miss is surfaced as a problem state and playback degrades gracefully.
|
||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
|
|
||||||
class BookPlaybackTimelineModule extends BaseModule {
|
class BookPlaybackTimelineModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
super('book-playback-timeline', 'Book Playback Timeline');
|
super('book-playback-timeline', 'Book Playback Timeline');
|
||||||
this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator'];
|
this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator', 'webgl-book-scene'];
|
||||||
this.pagination = null;
|
this.pagination = null;
|
||||||
this.textureRenderer = null;
|
this.textureRenderer = null;
|
||||||
this.pageCache = null;
|
this.pageCache = null;
|
||||||
this.playbackCoordinator = null;
|
this.playbackCoordinator = null;
|
||||||
|
this.scene = null;
|
||||||
this.activeSegment = null;
|
this.activeSegment = null;
|
||||||
this.preparedSegments = new Map();
|
this.preparedSegments = new Map();
|
||||||
|
this.maxPreparedSegments = 48;
|
||||||
|
this.paginationGeneration = 0;
|
||||||
|
this.visibleSpreadIndex = 0;
|
||||||
this.timelineDiagnostics = [];
|
this.timelineDiagnostics = [];
|
||||||
this.benchmarkEntries = [];
|
this.benchmarkEntries = [];
|
||||||
this.ownsPageFlipCommit = true;
|
|
||||||
|
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'initialize',
|
'initialize',
|
||||||
'playSentence',
|
'playSentence',
|
||||||
'prepareSentence',
|
'prepareSentence',
|
||||||
|
'commitSegmentSpread',
|
||||||
'activatePreparedSegment',
|
'activatePreparedSegment',
|
||||||
'ensureAnimationTimings',
|
'ensureAnimationTimings',
|
||||||
'calculateAnimationTiming',
|
'calculateAnimationTiming',
|
||||||
@@ -37,6 +53,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
'requiresRightPageFlipAfterReveal',
|
'requiresRightPageFlipAfterReveal',
|
||||||
'getBlockRevealSides',
|
'getBlockRevealSides',
|
||||||
'waitForVisualCompletion',
|
'waitForVisualCompletion',
|
||||||
|
'revealContinuationSpread',
|
||||||
|
'waitForPlannedRightReveal',
|
||||||
'waitForRevealCommit',
|
'waitForRevealCommit',
|
||||||
'requestPageFlip',
|
'requestPageFlip',
|
||||||
'prepareFlipPlan',
|
'prepareFlipPlan',
|
||||||
@@ -45,6 +63,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
'getPageMetaForIndex',
|
'getPageMetaForIndex',
|
||||||
'getVisibleSpreadIndex',
|
'getVisibleSpreadIndex',
|
||||||
'isChoiceAwaitingPlayer',
|
'isChoiceAwaitingPlayer',
|
||||||
|
'invalidatePreparedSegments',
|
||||||
|
'rememberPreparedSegment',
|
||||||
'markBenchmark',
|
'markBenchmark',
|
||||||
'timeStage',
|
'timeStage',
|
||||||
'recordDiagnostic',
|
'recordDiagnostic',
|
||||||
@@ -57,10 +77,10 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
this.textureRenderer = this.getModule('book-texture-renderer');
|
this.textureRenderer = this.getModule('book-texture-renderer');
|
||||||
this.pageCache = this.getModule('webgl-page-cache');
|
this.pageCache = this.getModule('webgl-page-cache');
|
||||||
this.playbackCoordinator = this.getModule('playback-coordinator');
|
this.playbackCoordinator = this.getModule('playback-coordinator');
|
||||||
|
this.scene = this.getModule('webgl-book-scene');
|
||||||
|
this.visibleSpreadIndex = Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
|
||||||
this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => {
|
this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => {
|
||||||
this.markBenchmark('reveal-start', {
|
this.markBenchmark('reveal-start', { blockId: event.detail?.blockId ?? null });
|
||||||
blockId: event.detail?.blockId ?? null
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
||||||
this.markBenchmark('reveal-committed', {
|
this.markBenchmark('reveal-committed', {
|
||||||
@@ -73,8 +93,12 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
this.markBenchmark('flip-started', event.detail || {});
|
this.markBenchmark('flip-started', event.detail || {});
|
||||||
});
|
});
|
||||||
this.addEventListener(document, 'webgl-book:page-flip-finished', (event) => {
|
this.addEventListener(document, 'webgl-book:page-flip-finished', (event) => {
|
||||||
|
const targetSpread = Number(event.detail?.targetSpread);
|
||||||
|
if (Number.isFinite(targetSpread)) this.visibleSpreadIndex = Math.max(0, Math.round(targetSpread));
|
||||||
this.markBenchmark('flip-finished', event.detail || {});
|
this.markBenchmark('flip-finished', event.detail || {});
|
||||||
});
|
});
|
||||||
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.invalidatePreparedSegments);
|
||||||
|
this.addEventListener(document, 'story:history-restoring', this.invalidatePreparedSegments);
|
||||||
window.BookPlaybackTimeline = this;
|
window.BookPlaybackTimeline = this;
|
||||||
this.reportProgress(100, 'Book playback timeline ready');
|
this.reportProgress(100, 'Book playback timeline ready');
|
||||||
return true;
|
return true;
|
||||||
@@ -89,12 +113,22 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activeSegment = segment;
|
this.activeSegment = segment;
|
||||||
|
document.documentElement.dataset.webglBookPlaybackActive = 'true';
|
||||||
this.recordDiagnostic('segment-play:start', segment);
|
this.recordDiagnostic('segment-play:start', segment);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Commit pagination first so the flip targets the authoritative spread,
|
||||||
|
// not the predicted preview spread.
|
||||||
|
await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
|
||||||
|
|
||||||
if (this.requiresSpreadTransition(segment)) {
|
if (this.requiresSpreadTransition(segment)) {
|
||||||
const flipped = await this.timeStage('preplay-flip', segment, () => this.requestPageFlip(1, {
|
const flipped = await this.timeStage('preplay-flip', segment, () => this.requestPageFlip(1, {
|
||||||
reason: 'timeline-preplay-spread-transition',
|
reason: 'timeline-preplay-spread-transition',
|
||||||
targetSpread: segment.targetSpreadIndex,
|
targetSpread: segment.targetSpreadIndex,
|
||||||
|
// The block reveals on these sides right after the flip; the scene must
|
||||||
|
// not flash their full (unmasked) content during the flip's near-end
|
||||||
|
// texture swap — activate will land the masked reveal instead.
|
||||||
|
revealSides: segment.revealSides,
|
||||||
force: true
|
force: true
|
||||||
}));
|
}));
|
||||||
if (!flipped) {
|
if (!flipped) {
|
||||||
@@ -114,24 +148,27 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
const visualPromise = this.waitForVisualCompletion(segment);
|
const visualPromise = this.waitForVisualCompletion(segment);
|
||||||
await Promise.all([playbackPromise, visualPromise]);
|
await Promise.all([playbackPromise, visualPromise]);
|
||||||
|
} finally {
|
||||||
this.recordDiagnostic('segment-play:end', segment);
|
this.recordDiagnostic('segment-play:end', segment);
|
||||||
if (this.activeSegment?.key === segment.key) this.activeSegment = null;
|
if (this.activeSegment?.key === segment.key) this.activeSegment = null;
|
||||||
|
delete document.documentElement.dataset.webglBookPlaybackActive;
|
||||||
|
}
|
||||||
return segment;
|
return segment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareSentence(sentence = {}, options = {}) {
|
async prepareSentence(sentence = {}, options = {}) {
|
||||||
if (!sentence || sentence.blockId == null || !this.pagination || !this.textureRenderer) return null;
|
if (!sentence || sentence.blockId == null || !this.pagination || !this.textureRenderer) return null;
|
||||||
const key = `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`;
|
const key = `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`;
|
||||||
const existing = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key);
|
const cached = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key);
|
||||||
if (existing && options.force !== true) return existing;
|
const reusable = cached && cached.generation === this.paginationGeneration;
|
||||||
|
if (reusable && options.force !== true) return cached;
|
||||||
this.ensureAnimationTimings(sentence);
|
this.ensureAnimationTimings(sentence);
|
||||||
const segment = await this.timeStage(options.immediate === true ? 'segment-prepare-immediate' : 'segment-prepare-lookahead', {
|
const segment = await this.timeStage(options.immediate === true ? 'segment-prepare-immediate' : 'segment-prepare-lookahead', {
|
||||||
blockId: sentence.blockId,
|
blockId: sentence.blockId,
|
||||||
id: sentence.id
|
id: sentence.id
|
||||||
}, () => this.createPreparedSegment(sentence, options));
|
}, () => this.createPreparedSegment(sentence, options));
|
||||||
if (!segment) return null;
|
if (!segment) return null;
|
||||||
this.preparedSegments.set(segment.key, segment);
|
this.rememberPreparedSegment(segment);
|
||||||
sentence.webglBookPresentation = {
|
sentence.webglBookPresentation = {
|
||||||
...(sentence.webglBookPresentation || {}),
|
...(sentence.webglBookPresentation || {}),
|
||||||
prepared: true,
|
prepared: true,
|
||||||
@@ -143,6 +180,21 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
return segment;
|
return segment;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rememberPreparedSegment(segment = {}) {
|
||||||
|
if (!segment?.key) return;
|
||||||
|
this.preparedSegments.delete(segment.key);
|
||||||
|
this.preparedSegments.set(segment.key, segment);
|
||||||
|
while (this.preparedSegments.size > this.maxPreparedSegments) {
|
||||||
|
const oldestKey = this.preparedSegments.keys().next().value;
|
||||||
|
this.preparedSegments.delete(oldestKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidatePreparedSegments() {
|
||||||
|
this.paginationGeneration += 1;
|
||||||
|
this.preparedSegments.clear();
|
||||||
|
}
|
||||||
|
|
||||||
async createPreparedSegment(sentence = {}, options = {}) {
|
async createPreparedSegment(sentence = {}, options = {}) {
|
||||||
const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, {
|
const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, {
|
||||||
activate: false,
|
activate: false,
|
||||||
@@ -165,6 +217,7 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
id: sentence.id,
|
id: sentence.id,
|
||||||
blockId: sentence.blockId,
|
blockId: sentence.blockId,
|
||||||
sentence,
|
sentence,
|
||||||
|
generation: this.paginationGeneration,
|
||||||
previewSpread,
|
previewSpread,
|
||||||
targetSpreadIndex,
|
targetSpreadIndex,
|
||||||
currentSpreadIndex,
|
currentSpreadIndex,
|
||||||
@@ -191,7 +244,7 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
return segment;
|
return segment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
|
async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
|
||||||
if (!segment || !sentence) return null;
|
if (!segment || !sentence) return null;
|
||||||
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
|
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
|
||||||
includeUnrenderedHistory: true
|
includeUnrenderedHistory: true
|
||||||
@@ -199,19 +252,40 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
segment.activeSpread = activeSpread || segment.previewSpread;
|
segment.activeSpread = activeSpread || segment.previewSpread;
|
||||||
segment.targetSpreadIndex = Math.max(0, Number(segment.activeSpread?.index ?? segment.targetSpreadIndex ?? 0));
|
segment.targetSpreadIndex = Math.max(0, Number(segment.activeSpread?.index ?? segment.targetSpreadIndex ?? 0));
|
||||||
segment.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId);
|
segment.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId);
|
||||||
|
// Does the block overflow onto the next spread? The committed pagination now knows
|
||||||
|
// this (during lookahead it was not yet committed), so detect it here.
|
||||||
|
const nextSpread = typeof this.pagination?.getSpread === 'function'
|
||||||
|
? this.pagination.getSpread(segment.targetSpreadIndex + 1)
|
||||||
|
: this.pagination?.spreads?.[segment.targetSpreadIndex + 1];
|
||||||
|
segment.spansToNextSpread = Boolean(nextSpread)
|
||||||
|
&& this.getBlockRevealSides(nextSpread, sentence.blockId).length > 0;
|
||||||
|
// A spanning block, or one that fills the right page, must flip to keep revealing
|
||||||
|
// its continuation rather than leaving the right page's last line to absorb the
|
||||||
|
// whole TTS while the rest pops in complete after the flip.
|
||||||
segment.requiresRightFlip = segment.revealSides.includes('right')
|
segment.requiresRightFlip = segment.revealSides.includes('right')
|
||||||
&& this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread);
|
&& (segment.spansToNextSpread || this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread));
|
||||||
|
this.recordDiagnostic('segment-commit:end', segment);
|
||||||
|
return segment.activeSpread;
|
||||||
|
}
|
||||||
|
|
||||||
const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate');
|
async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
|
||||||
|
if (!segment || !sentence) return null;
|
||||||
|
const spread = segment.activeSpread || segment.previewSpread;
|
||||||
|
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||||
|
// For a spanning block the prepared reveal plan was built during lookahead before
|
||||||
|
// the continuation was committed, so it is right-only. Rebuild from the committed
|
||||||
|
// spreads so the reveal timing spans both pages (right page no longer absorbs the
|
||||||
|
// whole duration) and the continuation has reveal regions.
|
||||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
|
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
|
||||||
publishEvent: false
|
publishEvent: false,
|
||||||
|
forceRebuild: segment.spansToNextSpread === true
|
||||||
});
|
});
|
||||||
segment.activeTexturePlan = texturePlan;
|
segment.activeTexturePlan = texturePlan;
|
||||||
this.applyTexturePlan(texturePlan, segment, 'activate');
|
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||||
await this.assertSegmentReady(segment, 'activate');
|
await this.assertSegmentReady(segment, 'activate');
|
||||||
segment.status = 'activated';
|
segment.status = 'activated';
|
||||||
this.recordDiagnostic('segment-activate:end', segment);
|
this.recordDiagnostic('segment-activate:end', segment);
|
||||||
return segment.activeSpread;
|
return spread;
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureAnimationTimings(sentence = {}) {
|
ensureAnimationTimings(sentence = {}) {
|
||||||
@@ -285,31 +359,37 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
|
|
||||||
applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') {
|
applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') {
|
||||||
if (!texturePlan) {
|
if (!texturePlan) {
|
||||||
throw new Error(`BookPlaybackTimeline: Missing texture plan for block ${segment.blockId ?? 'unknown'} during ${phase}`);
|
this.pageCache?.recordProblem?.({
|
||||||
|
type: 'timeline-missing-texture-plan',
|
||||||
|
blockId: segment.blockId ?? null,
|
||||||
|
phase
|
||||||
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (typeof window.BookLabDebug?.applyPageTextureRecords !== 'function') {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', {
|
||||||
throw new Error('BookPlaybackTimeline: WebGL book lab cannot apply prepared texture plans');
|
detail: {
|
||||||
}
|
|
||||||
window.BookLabDebug.applyPageTextureRecords({
|
|
||||||
...texturePlan,
|
...texturePlan,
|
||||||
phase: phase === 'prepare' ? 'prepare' : 'activate'
|
phase: phase === 'prepare' ? 'prepare' : 'activate'
|
||||||
});
|
}
|
||||||
|
}));
|
||||||
this.recordDiagnostic(`texture-plan-applied:${phase}`, segment);
|
this.recordDiagnostic(`texture-plan-applied:${phase}`, segment);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
startRevealForSegment(segment = {}) {
|
startRevealForSegment(segment = {}) {
|
||||||
if (!segment?.blockId) return false;
|
if (!segment?.blockId) return false;
|
||||||
|
// Mark the renderer animation as started, then let the scene render loop —
|
||||||
|
// the single reveal clock — drive timing via the dispatched reveal-start event.
|
||||||
const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, {
|
const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, {
|
||||||
publishEvent: false
|
publishEvent: true
|
||||||
});
|
});
|
||||||
if (!revealStart) {
|
if (!revealStart) {
|
||||||
throw new Error(`BookPlaybackTimeline: Prepared reveal animation is missing for block ${segment.blockId}`);
|
this.pageCache?.recordProblem?.({
|
||||||
|
type: 'timeline-prepared-reveal-missing',
|
||||||
|
blockId: segment.blockId
|
||||||
|
});
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
if (typeof window.BookLabDebug?.startRevealForBlock !== 'function') {
|
|
||||||
throw new Error('BookPlaybackTimeline: WebGL book lab cannot start prepared reveals explicitly');
|
|
||||||
}
|
|
||||||
window.BookLabDebug.startRevealForBlock(segment.blockId);
|
|
||||||
segment.revealStartedAt = performance.now();
|
segment.revealStartedAt = performance.now();
|
||||||
if (typeof segment.resolveRevealStarted === 'function') {
|
if (typeof segment.resolveRevealStarted === 'function') {
|
||||||
segment.resolveRevealStarted(segment.revealStartedAt);
|
segment.resolveRevealStarted(segment.revealStartedAt);
|
||||||
@@ -351,11 +431,46 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForPlannedRightReveal(segment));
|
const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForPlannedRightReveal(segment));
|
||||||
if (!committed || this.isChoiceAwaitingPlayer()) return;
|
if (!committed || this.isChoiceAwaitingPlayer()) return;
|
||||||
await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, {
|
const continuationSpreadIndex = Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1);
|
||||||
|
const continuationSpread = typeof this.pagination?.getSpread === 'function'
|
||||||
|
? this.pagination.getSpread(continuationSpreadIndex)
|
||||||
|
: this.pagination?.spreads?.[continuationSpreadIndex];
|
||||||
|
// If the block continues onto the next spread, that page must keep revealing the
|
||||||
|
// carried-over lines after the flip instead of appearing already complete.
|
||||||
|
const continuationSides = continuationSpread ? this.getBlockRevealSides(continuationSpread, segment.blockId) : [];
|
||||||
|
const flipped = await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, {
|
||||||
reason: 'timeline-right-page-filled',
|
reason: 'timeline-right-page-filled',
|
||||||
targetSpread: Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1),
|
targetSpread: continuationSpreadIndex,
|
||||||
|
revealSides: continuationSides,
|
||||||
force: true
|
force: true
|
||||||
}));
|
}));
|
||||||
|
if (flipped && continuationSides.length > 0) {
|
||||||
|
await this.timeStage('reveal-continuation', segment, () => this.revealContinuationSpread(segment, continuationSpread));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-apply the active block's reveal on the spread it continues onto. The renderer
|
||||||
|
// already produces reveal regions for that spread with global (continuous) timing;
|
||||||
|
// the scene resumes the same reveal clock (the block's original start persists), so
|
||||||
|
// the carried-over lines animate in instead of popping in fully revealed.
|
||||||
|
async revealContinuationSpread(segment = {}, spread = null) {
|
||||||
|
const sentence = segment.sentence;
|
||||||
|
if (!sentence || !spread) return false;
|
||||||
|
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||||
|
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
||||||
|
if (!texturePlan) {
|
||||||
|
this.pageCache?.recordProblem?.({
|
||||||
|
type: 'timeline-reveal-continuation-missing',
|
||||||
|
blockId: segment.blockId,
|
||||||
|
spreadIndex: Number(spread.index ?? null)
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
segment.activeTexturePlan = texturePlan;
|
||||||
|
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||||
|
await this.assertSegmentReady(segment, 'activate');
|
||||||
|
this.recordDiagnostic('reveal-continuation:applied', segment);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitForPlannedRightReveal(segment = {}) {
|
async waitForPlannedRightReveal(segment = {}) {
|
||||||
@@ -419,23 +534,25 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
|
|
||||||
async requestPageFlip(direction = 1, options = {}) {
|
async requestPageFlip(direction = 1, options = {}) {
|
||||||
if (this.isChoiceAwaitingPlayer()) return false;
|
if (this.isChoiceAwaitingPlayer()) return false;
|
||||||
const flipPlan = await this.prepareFlipPlan(direction, options);
|
// Warm the texture cache for the navigation window and verify the target pages
|
||||||
|
// are resident before asking the scene to flip. The scene performs its own
|
||||||
|
// flip-specific prewarm (drawing the spreads), so we do not pass this through.
|
||||||
|
await this.prepareFlipPlan(direction, options);
|
||||||
await this.assertSegmentReady({
|
await this.assertSegmentReady({
|
||||||
blockId: options.blockId ?? null,
|
blockId: options.blockId ?? null,
|
||||||
targetSpreadIndex: options.targetSpread,
|
targetSpreadIndex: options.targetSpread,
|
||||||
revealSides: []
|
revealSides: []
|
||||||
}, 'flip');
|
}, 'flip');
|
||||||
const wait = this.waitForPageFlipFinished(options.targetSpread);
|
const wait = this.waitForPageFlipFinished(options.targetSpread);
|
||||||
if (typeof window.BookLabDebug?.requestPageFlip !== 'function') {
|
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
|
||||||
throw new Error('BookPlaybackTimeline: WebGL book lab cannot execute prepared flip plans');
|
detail: {
|
||||||
}
|
direction,
|
||||||
window.BookLabDebug.requestPageFlip(direction, {
|
|
||||||
force: options.force === true,
|
force: options.force === true,
|
||||||
reason: options.reason || 'timeline',
|
reason: options.reason || 'timeline',
|
||||||
targetSpread: options.targetSpread,
|
targetSpread: options.targetSpread,
|
||||||
prewarm: flipPlan.prewarm,
|
revealSides: Array.isArray(options.revealSides) ? options.revealSides : null
|
||||||
flipPlan
|
}
|
||||||
});
|
}));
|
||||||
return wait;
|
return wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,7 +642,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
|
|
||||||
async assertSegmentReady(segment = {}, phase = 'play') {
|
async assertSegmentReady(segment = {}, phase = 'play') {
|
||||||
if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') {
|
if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') {
|
||||||
throw new Error('BookPlaybackTimeline: Page texture cache is not available');
|
this.recordDiagnostic(`cache-unavailable:${phase}`, segment);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
const metas = this.collectRequiredPageMetas(segment, phase);
|
const metas = this.collectRequiredPageMetas(segment, phase);
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@@ -536,13 +654,16 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
if (!texture) missing.push(meta);
|
if (!texture) missing.push(meta);
|
||||||
}));
|
}));
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
|
// Surface the problem but do not throw out of the live playback path.
|
||||||
this.pageCache.recordProblem?.({
|
this.pageCache.recordProblem?.({
|
||||||
type: 'timeline-cache-readiness-failed',
|
type: 'timeline-cache-readiness-failed',
|
||||||
phase,
|
phase,
|
||||||
blockId: segment.blockId ?? null,
|
blockId: segment.blockId ?? null,
|
||||||
missingPages: missing.map(meta => meta.pageIndex ?? null)
|
missingPages: missing.map(meta => meta.pageIndex ?? null)
|
||||||
});
|
});
|
||||||
throw new Error(`BookPlaybackTimeline: Cache readiness failed during ${phase} for pages ${missing.map(meta => meta.pageIndex).join(', ')}`);
|
segment.cacheReady = false;
|
||||||
|
segment.cacheReadyPhase = phase;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
segment.cacheReady = true;
|
segment.cacheReady = true;
|
||||||
segment.cacheReadyPhase = phase;
|
segment.cacheReadyPhase = phase;
|
||||||
@@ -623,8 +744,9 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getVisibleSpreadIndex() {
|
getVisibleSpreadIndex() {
|
||||||
const labSpread = window.BookLabDebug?.getBookState?.()?.spreadIndex;
|
const sceneSpread = this.scene?.getVisibleSpreadIndex?.();
|
||||||
if (Number.isFinite(Number(labSpread))) return Math.max(0, Math.round(Number(labSpread)));
|
if (Number.isFinite(Number(sceneSpread))) return Math.max(0, Math.round(Number(sceneSpread)));
|
||||||
|
if (Number.isFinite(Number(this.visibleSpreadIndex))) return Math.max(0, Math.round(Number(this.visibleSpreadIndex)));
|
||||||
return Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
|
return Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0)));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -692,7 +814,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
return {
|
return {
|
||||||
activeBlockId: this.activeSegment?.blockId ?? null,
|
activeBlockId: this.activeSegment?.blockId ?? null,
|
||||||
preparedSegmentCount: this.preparedSegments.size,
|
preparedSegmentCount: this.preparedSegments.size,
|
||||||
ownsPageFlipCommit: this.ownsPageFlipCommit,
|
paginationGeneration: this.paginationGeneration,
|
||||||
|
visibleSpreadIndex: this.visibleSpreadIndex,
|
||||||
diagnostics: this.timelineDiagnostics.slice(-20),
|
diagnostics: this.timelineDiagnostics.slice(-20),
|
||||||
benchmark: this.benchmarkEntries.slice(-40)
|
benchmark: this.benchmarkEntries.slice(-40)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,14 +29,10 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.currentSpread = null;
|
this.currentSpread = null;
|
||||||
this.activeAnimations = new Map();
|
this.activeAnimations = new Map();
|
||||||
this.revealedBlockIds = new Set();
|
this.revealedBlockIds = new Set();
|
||||||
this.pendingRevealBlockIds = new Set();
|
|
||||||
this.revealBaseCanvases = null;
|
this.revealBaseCanvases = null;
|
||||||
this.revealPublishBlockIds = null;
|
this.revealPublishBlockIds = null;
|
||||||
this.lastDrawSignature = null;
|
this.lastDrawSignature = null;
|
||||||
this.lastDrawSkipLoggedAt = 0;
|
this.lastDrawSkipLoggedAt = 0;
|
||||||
this.animationFrameId = null;
|
|
||||||
this.lastAnimationFrameAt = 0;
|
|
||||||
this.targetFrameDurationMs = 1000 / 60;
|
|
||||||
this.pipelineTimings = [];
|
this.pipelineTimings = [];
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
this.pageContentVersions = new Map();
|
this.pageContentVersions = new Map();
|
||||||
@@ -76,7 +72,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'applyTextStyle',
|
'applyTextStyle',
|
||||||
'getPageContent',
|
'getPageContent',
|
||||||
'buildLineSegments',
|
'buildLineSegments',
|
||||||
'startRevealAnimation',
|
|
||||||
'prepareRevealBlock',
|
'prepareRevealBlock',
|
||||||
'preloadAdditionalRevealSpreads',
|
'preloadAdditionalRevealSpreads',
|
||||||
'spreadContainsBlock',
|
'spreadContainsBlock',
|
||||||
@@ -88,9 +83,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'stopAnimations',
|
'stopAnimations',
|
||||||
'getBlockSides',
|
'getBlockSides',
|
||||||
'getAnimatedSides',
|
'getAnimatedSides',
|
||||||
'markPendingReveal',
|
|
||||||
'requestAnimationFrame',
|
|
||||||
'tickAnimations',
|
|
||||||
'publishSpread',
|
'publishSpread',
|
||||||
'buildPageTextureRecords',
|
'buildPageTextureRecords',
|
||||||
'cachePublishedPages',
|
'cachePublishedPages',
|
||||||
@@ -114,62 +106,9 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.reportProgress(20, 'Preparing page texture canvases');
|
this.reportProgress(20, 'Preparing page texture canvases');
|
||||||
this.createPageCanvases();
|
this.createPageCanvases();
|
||||||
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
||||||
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
|
// The renderer is a pure renderer. It does not react to pagination spread
|
||||||
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.();
|
// updates with draws or reveals — the playback owner (book-playback-timeline)
|
||||||
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0));
|
// drives every draw explicitly. See docs/webgl-3d-ui-spec.md "Single ownership".
|
||||||
const latestBlockId = event.detail?.latestBlockId;
|
|
||||||
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
|
|
||||||
const visibility = event.detail?.visibility || 'current';
|
|
||||||
this.currentSpread = spread || { left: [], right: [] };
|
|
||||||
const timelineOwnsPlayback = window.BookPlaybackTimeline?.ownsPageFlipCommit === true;
|
|
||||||
if (document.documentElement.dataset.webglPageFlipActive === 'true' && this.activeAnimations.size === 0) {
|
|
||||||
this.markPipelineTiming('spreadUpdate:skip-during-flip', {
|
|
||||||
spreadIndex,
|
|
||||||
visibility
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
|
|
||||||
this.markPendingReveal(latestBlockId);
|
|
||||||
const id = String(latestBlockId);
|
|
||||||
if (timelineOwnsPlayback && visibility !== 'future-ready') {
|
|
||||||
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-reveal', {
|
|
||||||
spreadIndex,
|
|
||||||
latestBlockId: id,
|
|
||||||
visibility
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (visibility === 'future-ready' && !this.activeAnimations.has(id)) {
|
|
||||||
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right'], {
|
|
||||||
phase: 'prepare',
|
|
||||||
publishEvent: !timelineOwnsPlayback
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.activeAnimations.has(id)) {
|
|
||||||
this.revealPublishBlockIds = new Set([id]);
|
|
||||||
const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0));
|
|
||||||
const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true';
|
|
||||||
if (!flipActive && visibility !== 'future-ready' && spreadIndex > visibleSpread) {
|
|
||||||
this.drawSpread(this.currentSpread, ['left', 'right'], { phase: 'prepare' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.drawSpread(this.currentSpread, ['left', 'right']);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (timelineOwnsPlayback && visibility !== 'future-ready' && latestBlockId) {
|
|
||||||
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-commit', {
|
|
||||||
spreadIndex,
|
|
||||||
latestBlockId,
|
|
||||||
latestRenderedBlockId,
|
|
||||||
visibility
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.drawSpread(this.currentSpread);
|
|
||||||
});
|
|
||||||
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
|
||||||
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
||||||
this.completeRevealBlockIds(event.detail?.blockIds || []);
|
this.completeRevealBlockIds(event.detail?.blockIds || []);
|
||||||
@@ -185,18 +124,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
stripUnrenderedLines(spread = {}, latestRenderedBlockId = 0) {
|
|
||||||
const latestRendered = Math.max(0, Number(latestRenderedBlockId || 0));
|
|
||||||
return {
|
|
||||||
...spread,
|
|
||||||
left: (Array.isArray(spread.left) ? spread.left : [])
|
|
||||||
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
|
|
||||||
right: (Array.isArray(spread.right) ? spread.right : [])
|
|
||||||
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
|
|
||||||
pageMeta: spread.pageMeta || { left: null, right: null }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
markPipelineTiming(name, detail = {}) {
|
markPipelineTiming(name, detail = {}) {
|
||||||
const entry = {
|
const entry = {
|
||||||
name,
|
name,
|
||||||
@@ -916,35 +843,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
|
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
|
||||||
}
|
}
|
||||||
|
|
||||||
startRevealAnimation(detail = {}) {
|
|
||||||
const blockId = detail.blockId ?? detail.id ?? null;
|
|
||||||
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
|
||||||
const existing = this.activeAnimations.get(String(blockId));
|
|
||||||
if (existing && existing.prepared) {
|
|
||||||
this.startPreparedRevealAnimation(blockId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.activeAnimations.set(String(blockId), {
|
|
||||||
blockId,
|
|
||||||
wordTimings: detail.wordTimings,
|
|
||||||
startedAt: performance.now(),
|
|
||||||
totalDuration: Math.max(
|
|
||||||
Number(detail.totalDuration || 0),
|
|
||||||
...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
|
|
||||||
),
|
|
||||||
completed: false
|
|
||||||
});
|
|
||||||
this.pendingRevealBlockIds.delete(String(blockId));
|
|
||||||
this.revealPublishBlockIds = new Set([String(blockId)]);
|
|
||||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']);
|
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
|
||||||
detail: {
|
|
||||||
blockId
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
this.requestAnimationFrame();
|
|
||||||
}
|
|
||||||
|
|
||||||
createAnimationState(blockId, wordTimings = [], detail = {}) {
|
createAnimationState(blockId, wordTimings = [], detail = {}) {
|
||||||
return {
|
return {
|
||||||
blockId,
|
blockId,
|
||||||
@@ -972,10 +870,12 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
wordTimingCount: wordTimings.length,
|
wordTimingCount: wordTimings.length,
|
||||||
phase
|
phase
|
||||||
});
|
});
|
||||||
if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
// forceRebuild: the cached plan was built before the block's continuation was
|
||||||
|
// committed (it would be right-only). Discard it and redraw from current spreads.
|
||||||
|
if (options.forceRebuild === true) this.pageCache?.takePreparedRevealPlan?.(id);
|
||||||
|
if (phase === 'activate' && options.forceRebuild !== true && this.pageCache?.hasPreparedRevealPlan?.(id)) {
|
||||||
const cached = this.pageCache.takePreparedRevealPlan(id);
|
const cached = this.pageCache.takePreparedRevealPlan(id);
|
||||||
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||||
this.pendingRevealBlockIds.delete(id);
|
|
||||||
this.publishPreparedReveal(cached, options);
|
this.publishPreparedReveal(cached, options);
|
||||||
this.markPipelineTiming('prepareRevealBlock:end', {
|
this.markPipelineTiming('prepareRevealBlock:end', {
|
||||||
blockId: id,
|
blockId: id,
|
||||||
@@ -990,7 +890,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||||
this.pendingRevealBlockIds.delete(id);
|
|
||||||
this.revealPublishBlockIds = new Set([id]);
|
this.revealPublishBlockIds = new Set([id]);
|
||||||
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
||||||
const sides = ['left', 'right'];
|
const sides = ['left', 'right'];
|
||||||
@@ -1079,7 +978,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
this.requestAnimationFrame();
|
|
||||||
return {
|
return {
|
||||||
blockId: animation.blockId,
|
blockId: animation.blockId,
|
||||||
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
|
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
|
||||||
@@ -1098,7 +996,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (changed) {
|
if (changed) {
|
||||||
this.pendingRevealBlockIds.clear();
|
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
||||||
detail: {
|
detail: {
|
||||||
blockIds
|
blockIds
|
||||||
@@ -1115,17 +1012,11 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
const animation = this.activeAnimations.get(id);
|
const animation = this.activeAnimations.get(id);
|
||||||
if (animation) animation.completed = true;
|
if (animation) animation.completed = true;
|
||||||
this.revealedBlockIds.add(id);
|
this.revealedBlockIds.add(id);
|
||||||
this.pendingRevealBlockIds.delete(id);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
stopAnimations() {
|
stopAnimations() {
|
||||||
this.activeAnimations.clear();
|
this.activeAnimations.clear();
|
||||||
this.pendingRevealBlockIds.clear();
|
|
||||||
if (this.animationFrameId) {
|
|
||||||
clearTimeout(this.animationFrameId);
|
|
||||||
this.animationFrameId = null;
|
|
||||||
}
|
|
||||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1151,62 +1042,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
return sides.length ? sides : ['left', 'right'];
|
return sides.length ? sides : ['left', 'right'];
|
||||||
}
|
}
|
||||||
|
|
||||||
markPendingReveal(blockId) {
|
|
||||||
const id = String(blockId ?? '');
|
|
||||||
if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return;
|
|
||||||
this.pendingRevealBlockIds.add(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame() {
|
|
||||||
if (this.animationFrameId) return;
|
|
||||||
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
isWebGLPageFlipActive() {
|
|
||||||
return document.documentElement.dataset.webglPageFlipActive === 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
tickAnimations(now) {
|
|
||||||
this.animationFrameId = null;
|
|
||||||
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
|
|
||||||
this.requestAnimationFrame();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.lastAnimationFrameAt = now;
|
|
||||||
|
|
||||||
let hasActive = false;
|
|
||||||
const currentNow = performance.now();
|
|
||||||
if (this.isWebGLPageFlipActive()) {
|
|
||||||
this.activeAnimations.forEach((animation) => {
|
|
||||||
if (animation.completed) return;
|
|
||||||
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
|
||||||
hasActive = true;
|
|
||||||
if (animation.startedAt != null) {
|
|
||||||
animation.startedAt += this.targetFrameDurationMs;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (hasActive) this.requestAnimationFrame();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.activeAnimations.forEach((animation) => {
|
|
||||||
if (animation.completed) return;
|
|
||||||
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
|
|
||||||
if (animation.startedAt == null) {
|
|
||||||
hasActive = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const lastTiming = animation.wordTimings.at(-1);
|
|
||||||
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
|
|
||||||
if (currentNow - animation.startedAt >= total + 50) {
|
|
||||||
animation.completed = true;
|
|
||||||
this.revealedBlockIds.add(String(animation.blockId ?? ''));
|
|
||||||
} else {
|
|
||||||
hasActive = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (hasActive) this.requestAnimationFrame();
|
|
||||||
}
|
|
||||||
|
|
||||||
publishSpread(sides = null, options = {}) {
|
publishSpread(sides = null, options = {}) {
|
||||||
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
||||||
const phase = this.getDrawPhase(options);
|
const phase = this.getDrawPhase(options);
|
||||||
|
|||||||
@@ -526,9 +526,7 @@ class GameLoopModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getWebGLBookState() {
|
getWebGLBookState() {
|
||||||
return window.WebGLBookPreferenceBridge?.getBookState?.()
|
return window.WebGLBookPreferenceBridge?.getBookState?.() || null;
|
||||||
|| window.BookLabDebug?.getBookState?.()
|
|
||||||
|| null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyWebGLBookState(state = null) {
|
applyWebGLBookState(state = null) {
|
||||||
|
|||||||
@@ -309,29 +309,15 @@ class PlaybackCoordinatorModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
scheduleWebGLReveal(sentence, animQueue) {
|
scheduleWebGLReveal(sentence, animQueue) {
|
||||||
let wordTimings = Array.isArray(sentence.animation?.wordTimings)
|
// The book playback timeline is the single owner of reveal timing. It guarantees
|
||||||
|
// sentence.animation is populated (ensureAnimationTimings) before playback. The
|
||||||
|
// coordinator trusts those timings and never recomputes them here.
|
||||||
|
const wordTimings = Array.isArray(sentence.animation?.wordTimings)
|
||||||
? sentence.animation.wordTimings
|
? sentence.animation.wordTimings
|
||||||
: [];
|
: [];
|
||||||
let cueTimings = Array.isArray(sentence.animation?.cueTimings)
|
const cueTimings = Array.isArray(sentence.animation?.cueTimings)
|
||||||
? sentence.animation.cueTimings
|
? sentence.animation.cueTimings
|
||||||
: [];
|
: [];
|
||||||
const timingDuration = wordTimings.reduce((max, timing) => Math.max(
|
|
||||||
max,
|
|
||||||
Number(timing?.delay || 0) + Number(timing?.duration || 0)
|
|
||||||
), Number(sentence.animation?.totalDuration || 0));
|
|
||||||
const ttsDuration = Number(sentence.tts?.duration || sentence.animation?.totalDuration || 0);
|
|
||||||
if (wordTimings.length === 0 || (timingDuration <= 0 && ttsDuration > 0)) {
|
|
||||||
const words = String(sentence.text || '').match(/\S+/g) || [];
|
|
||||||
const calculated = this.calculateWordTimings(words, ttsDuration);
|
|
||||||
wordTimings = calculated.wordTimings;
|
|
||||||
cueTimings = [];
|
|
||||||
sentence.animation = {
|
|
||||||
...(sentence.animation || {}),
|
|
||||||
wordTimings,
|
|
||||||
cueTimings,
|
|
||||||
totalDuration: calculated.totalDuration
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof sentence.webglRevealController !== 'function') {
|
if (typeof sentence.webglRevealController !== 'function') {
|
||||||
throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller');
|
throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller');
|
||||||
|
|||||||
+118
-115
@@ -275,8 +275,6 @@ let currentPageMeta = {
|
|||||||
left: null,
|
left: null,
|
||||||
right: null
|
right: null
|
||||||
};
|
};
|
||||||
let pendingRightPageFlip = false;
|
|
||||||
let pendingRightPageFlipAutoplay = false;
|
|
||||||
const pageRevealState = {
|
const pageRevealState = {
|
||||||
left: null,
|
left: null,
|
||||||
right: null
|
right: null
|
||||||
@@ -655,6 +653,24 @@ window.BookLabDebug = {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Publish the visible spread as a production accessor on the scene module so the
|
||||||
|
// playback owner can read it without touching the debug surface (window.BookLabDebug).
|
||||||
|
const webglBookSceneModule = window.moduleRegistry?.getModule?.('webgl-book-scene') || null;
|
||||||
|
if (webglBookSceneModule) {
|
||||||
|
webglBookSceneModule.getVisibleSpreadIndex = () => Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||||
|
// Production control surface for the scene host (webgl-book-scene) and save/restore.
|
||||||
|
// window.BookLabDebug remains a debug/inspection-only alias; production code uses this.
|
||||||
|
webglBookSceneModule.sceneControl = {
|
||||||
|
getBookState: () => window.BookLabDebug.getBookState(),
|
||||||
|
setReadingProgress: (value) => setReadingProgress(value),
|
||||||
|
setBookPageCount: (value) => setBookPageCount(value),
|
||||||
|
setPageReserve: (value) => setPageReserve(value),
|
||||||
|
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
|
||||||
|
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
|
||||||
|
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
|
document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
|
||||||
document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
||||||
@@ -663,27 +679,38 @@ document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
|||||||
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
||||||
fastForwardPageReveals(event.detail?.blockIds || []);
|
fastForwardPageReveals(event.detail?.blockIds || []);
|
||||||
});
|
});
|
||||||
document.addEventListener('webgl-book:reveal-committed', (event) => {
|
document.addEventListener('webgl-book:request-page-flip', (event) => {
|
||||||
handleRevealCommittedForPageFlip(event.detail || {});
|
const detail = event.detail || {};
|
||||||
|
const direction = Number(detail.direction) || (detail.targetSpread > bookPaginationState.spreadIndex ? 1 : -1);
|
||||||
|
// Let the scene own flip prewarming via prewarmFlipTextures (which draws and
|
||||||
|
// makes resident the current + target spreads). The owner's cache-warming plan
|
||||||
|
// is a different shape and must not be passed through as the flip prewarm.
|
||||||
|
startPageFlip(direction, {
|
||||||
|
force: detail.force === true,
|
||||||
|
reason: detail.reason,
|
||||||
|
targetSpread: detail.targetSpread,
|
||||||
|
deferRevealSides: Array.isArray(detail.revealSides) ? detail.revealSides : null
|
||||||
|
});
|
||||||
});
|
});
|
||||||
document.addEventListener('webgl-book:page-cache-problem', (event) => {
|
document.addEventListener('webgl-book:page-cache-problem', (event) => {
|
||||||
pageTextureStore?.recordProblem?.(event.detail || {});
|
pageTextureStore?.recordProblem?.(event.detail || {});
|
||||||
});
|
});
|
||||||
|
// Pagination spread updates only carry state. The playback owner decides when the
|
||||||
|
// visible spread changes (via flips). The scene jumps directly only for non-playback
|
||||||
|
// commits such as history restore. See docs/webgl-3d-ui-spec.md "Single ownership".
|
||||||
document.addEventListener('book-pagination:spread-updated', (event) => {
|
document.addEventListener('book-pagination:spread-updated', (event) => {
|
||||||
const detail = event.detail || {};
|
const detail = event.detail || {};
|
||||||
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
|
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
|
||||||
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0));
|
const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||||
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
|
const stateOnly = playbackActive
|
||||||
if (
|
|| activeFlips.length > 0
|
||||||
window.BookPlaybackTimeline?.ownsPageFlipCommit === true
|
|| detail.visibility === 'future-ready';
|
||||||
&& detail.visibility !== 'future-ready'
|
if (stateOnly) {
|
||||||
&& latestBlockId > 0
|
markPageTextureTiming('spreadUpdate:state-only', {
|
||||||
) {
|
|
||||||
markPageTextureTiming('spreadUpdate:timeline-owned-state-only', {
|
|
||||||
incomingSpreadIndex,
|
incomingSpreadIndex,
|
||||||
visibleSpreadIndex: bookPaginationState.spreadIndex,
|
visibleSpreadIndex: bookPaginationState.spreadIndex,
|
||||||
latestBlockId,
|
visibility: detail.visibility || 'current',
|
||||||
latestRenderedBlockId
|
playbackActive
|
||||||
});
|
});
|
||||||
bookPaginationState = {
|
bookPaginationState = {
|
||||||
...bookPaginationState,
|
...bookPaginationState,
|
||||||
@@ -695,23 +722,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
|
|||||||
};
|
};
|
||||||
growBookIfWritableLimitReached();
|
growBookIfWritableLimitReached();
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
latestBlockId > latestRenderedBlockId
|
|
||||||
&& detail.visibility !== 'future-ready'
|
|
||||||
&& activeFlips.length === 0
|
|
||||||
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
|
|
||||||
) {
|
|
||||||
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
|
|
||||||
incomingSpreadIndex,
|
|
||||||
visibleSpreadIndex: bookPaginationState.spreadIndex,
|
|
||||||
latestBlockId,
|
|
||||||
latestRenderedBlockId
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Non-playback committed update (history restore, continuation reload): jump
|
||||||
|
// directly to the committed spread and paint it.
|
||||||
const previousPageCount = bookPageCount;
|
const previousPageCount = bookPageCount;
|
||||||
bookPaginationState = {
|
bookPaginationState = {
|
||||||
spreadIndex: incomingSpreadIndex,
|
spreadIndex: incomingSpreadIndex,
|
||||||
@@ -724,8 +738,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
|
|||||||
notifyBookPageCountChanged();
|
notifyBookPageCountChanged();
|
||||||
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
|
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
|
||||||
}
|
}
|
||||||
|
const spread = detail.spread || getPaginationSpread(incomingSpreadIndex);
|
||||||
|
if (spread) window.BookTextureRenderer?.drawSpread?.(spread, ['left', 'right'], { force: true });
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
|
markPageTextureTiming('spreadUpdate:jump', { incomingSpreadIndex });
|
||||||
});
|
});
|
||||||
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
||||||
const detail = event.detail || {};
|
const detail = event.detail || {};
|
||||||
@@ -736,11 +752,6 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
|||||||
: Math.round(value);
|
: Math.round(value);
|
||||||
setPageReserve(nextReserve);
|
setPageReserve(nextReserve);
|
||||||
});
|
});
|
||||||
document.addEventListener('ui:command', (event) => {
|
|
||||||
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
|
|
||||||
tryStartPendingRightPageFlip('continue', { force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
installBookControls();
|
installBookControls();
|
||||||
installCameraControls();
|
installCameraControls();
|
||||||
resize();
|
resize();
|
||||||
@@ -1881,6 +1892,17 @@ function getCurrentPagePosition() {
|
|||||||
return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
|
return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual navigation must not run past the spreads that actually exist (so a stale
|
||||||
|
// restored maxVisitedPagePosition cannot enable a flip into empty pages), but it must
|
||||||
|
// still reach the last existing spread. spreadCount is the real spread count; the last
|
||||||
|
// navigable spread is spreadCount - 1. (writtenPageLimit deliberately under-counts by a
|
||||||
|
// spread, so it must not be used for this.)
|
||||||
|
function getNavigablePageLimit() {
|
||||||
|
const lastSpreadIndex = Math.max(0, Math.round(Number(bookPaginationState.spreadCount || 1)) - 1);
|
||||||
|
const contentNavigable = spreadIndexToPagePosition(lastSpreadIndex);
|
||||||
|
return Math.min(maxVisitedPagePosition, getWritablePageLimit(), contentNavigable);
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleBookRebuild(reason = 'scheduled') {
|
function scheduleBookRebuild(reason = 'scheduled') {
|
||||||
if (scheduledBookRebuildFrame !== null) return;
|
if (scheduledBookRebuildFrame !== null) return;
|
||||||
const scheduler = typeof window.requestIdleCallback === 'function'
|
const scheduler = typeof window.requestIdleCallback === 'function'
|
||||||
@@ -2090,7 +2112,7 @@ function syncBottomNavigation() {
|
|||||||
if (!bottomNavigation) return;
|
if (!bottomNavigation) return;
|
||||||
const currentPage = getCurrentPagePosition();
|
const currentPage = getCurrentPagePosition();
|
||||||
const writableLimit = getWritablePageLimit();
|
const writableLimit = getWritablePageLimit();
|
||||||
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit);
|
const navigableLimit = getNavigablePageLimit();
|
||||||
const reservedStart = Math.max(0, writableLimit);
|
const reservedStart = Math.max(0, writableLimit);
|
||||||
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
|
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
|
||||||
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
|
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
|
||||||
@@ -2938,12 +2960,10 @@ function startPageFlipPrepared(direction, options = {}) {
|
|||||||
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
||||||
if (!flip) return false;
|
if (!flip) return false;
|
||||||
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
|
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
|
||||||
|
flip.deferRevealSides = Array.isArray(options.deferRevealSides) ? options.deferRevealSides : null;
|
||||||
if (!prepareStaticPageForFlip(flip, options.prewarm || null)) {
|
if (!prepareStaticPageForFlip(flip, options.prewarm || null)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
pendingRightPageFlip = false;
|
|
||||||
pendingRightPageFlipAutoplay = false;
|
|
||||||
delete document.documentElement.dataset.webglPendingPageFlip;
|
|
||||||
activeFlips.push(flip);
|
activeFlips.push(flip);
|
||||||
setPageFlipActiveFlag();
|
setPageFlipActiveFlag();
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
|
||||||
@@ -3007,8 +3027,12 @@ function startFastPageFlipPrepared(direction, options = {}) {
|
|||||||
function createPageFlip(direction, startTime, duration) {
|
function createPageFlip(direction, startTime, duration) {
|
||||||
const sourceSide = direction > 0 ? 1 : -1;
|
const sourceSide = direction > 0 ? 1 : -1;
|
||||||
const sourcePageSide = direction > 0 ? 'right' : 'left';
|
const sourcePageSide = direction > 0 ? 'right' : 'left';
|
||||||
const sourceLine = normalizeFlipLineToVisiblePage(topVisibleLine(sourceSide), sourceSide);
|
// Use the raw page-cap line (as the working prototype / pre-ef358c5 lab did). Each
|
||||||
const destinationLine = normalizeFlipLineToVisiblePage(topVisibleLine(-sourceSide), -sourceSide);
|
// line's points[0] === its spine-arc anchor (spineCurvePoint(t)), so the flip sheet
|
||||||
|
// hinges at the spine. Rewriting the line to the "visible page width" moved the pivot
|
||||||
|
// off the spine arc and folded the inner spine-wall climb into a crease at the spine.
|
||||||
|
const sourceLine = topVisibleLine(sourceSide);
|
||||||
|
const destinationLine = topVisibleLine(-sourceSide);
|
||||||
if (!sourceLine || !destinationLine) return null;
|
if (!sourceLine || !destinationLine) return null;
|
||||||
return {
|
return {
|
||||||
direction,
|
direction,
|
||||||
@@ -3024,34 +3048,11 @@ function createPageFlip(direction, startTime, duration) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFlipLineToVisiblePage(line, side) {
|
|
||||||
if (!line || !currentProceduralBookModel) return line;
|
|
||||||
const points = Array.isArray(line.points) ? line.points : [];
|
|
||||||
if (points.length < 2) return line;
|
|
||||||
const pageStartX = side * Math.max(0, Number(currentProceduralBookModel.spineHalf || 0));
|
|
||||||
const endpoint = points[points.length - 1];
|
|
||||||
const sourceStart = points[0];
|
|
||||||
const sourceSpan = Math.max(0.0001, side * (endpoint.x - sourceStart.x));
|
|
||||||
const normalizedPoints = points.map((point) => {
|
|
||||||
const u = THREE.MathUtils.clamp(side * (point.x - sourceStart.x) / sourceSpan, 0, 1);
|
|
||||||
return {
|
|
||||||
x: THREE.MathUtils.lerp(pageStartX, endpoint.x, u),
|
|
||||||
y: point.y
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
...line,
|
|
||||||
anchor: normalizedPoints[0],
|
|
||||||
points: normalizedPoints,
|
|
||||||
endpoint: normalizedPoints[normalizedPoints.length - 1]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function prepareStaticPageForFlip(flip, prewarm = null) {
|
function prepareStaticPageForFlip(flip, prewarm = null) {
|
||||||
if (!flip) return false;
|
if (!flip) return false;
|
||||||
const sourceSide = flip.direction > 0 ? 'right' : 'left';
|
const sourceSide = flip.direction > 0 ? 'right' : 'left';
|
||||||
const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
|
const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
|
||||||
const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || null;
|
const sourcePageMeta = getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || currentPageMeta?.[sourceSide] || null;
|
||||||
const targetSpread = Number.isFinite(Number(flip.targetSpread))
|
const targetSpread = Number.isFinite(Number(flip.targetSpread))
|
||||||
? Math.max(0, Math.round(Number(flip.targetSpread)))
|
? Math.max(0, Math.round(Number(flip.targetSpread)))
|
||||||
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
|
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
|
||||||
@@ -3077,10 +3078,15 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
|||||||
});
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// If the page the flip lands on will be revealed right after (a block reveals on
|
||||||
|
// that side), do not show its full text on the turning page's back face — that
|
||||||
|
// flashes the not-yet-revealed content. Show blank during the turn; the masked
|
||||||
|
// reveal lands on the static page once the flip finishes.
|
||||||
|
const backDeferred = Array.isArray(flip.deferRevealSides) && flip.deferRevealSides.includes(targetBackSide);
|
||||||
materials.flipPageSurface.map = sourceTexture;
|
materials.flipPageSurface.map = sourceTexture;
|
||||||
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
|
materials.flipPageBackSurface.map = backDeferred ? getBlankPageTexture() : (backTexture || getBlankPageTexture());
|
||||||
materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null;
|
materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null;
|
||||||
materials.flipPageBackSurface.userData.sourceRevealSide = revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null;
|
materials.flipPageBackSurface.userData.sourceRevealSide = backDeferred ? null : (revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null);
|
||||||
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
||||||
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
|
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
|
||||||
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||||
@@ -3131,7 +3137,11 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveCurrentFlipSourceTexture(side) {
|
function resolveCurrentFlipSourceTexture(side) {
|
||||||
const pageMeta = currentPageMeta?.[side] || null;
|
// Derive the source page meta from the actually-visible spread. currentPageMeta is
|
||||||
|
// only refreshed by the activate pipeline, so it is stale after manual navigation —
|
||||||
|
// using it here resolved the wrong source texture for the next flip.
|
||||||
|
const visiblePageIndex = spreadPageIndices(bookPaginationState.spreadIndex)[side];
|
||||||
|
const pageMeta = getPaginationPageMeta(visiblePageIndex) || currentPageMeta?.[side] || null;
|
||||||
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
|
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
|
||||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||||
if (revealStateMatchesPage(side, pageMeta)) return material?.map || null;
|
if (revealStateMatchesPage(side, pageMeta)) return material?.map || null;
|
||||||
@@ -3149,53 +3159,16 @@ function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
|
|||||||
function canPageFlip(direction) {
|
function canPageFlip(direction) {
|
||||||
if (!currentProceduralBookModel) return false;
|
if (!currentProceduralBookModel) return false;
|
||||||
const currentPage = getCurrentPagePosition();
|
const currentPage = getCurrentPagePosition();
|
||||||
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit());
|
if (direction > 0) return currentPage < getNavigablePageLimit();
|
||||||
if (direction > 0) return currentPage < maxNavigablePage;
|
|
||||||
return currentPage > 0;
|
return currentPage > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRevealCommittedForPageFlip(detail = {}) {
|
|
||||||
if (window.BookPlaybackTimeline?.ownsPageFlipCommit === true) return;
|
|
||||||
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
|
|
||||||
if (activeFlips.length > 0 || pendingRightPageFlip) return;
|
|
||||||
if (isChoiceAwaitingPlayer()) return;
|
|
||||||
const autoplayFlip = isTtsPlaybackActive();
|
|
||||||
pendingRightPageFlip = true;
|
|
||||||
pendingRightPageFlipAutoplay = autoplayFlip;
|
|
||||||
document.documentElement.dataset.webglPendingPageFlip = 'right';
|
|
||||||
if (autoplayFlip) {
|
|
||||||
tryStartPendingRightPageFlip('tts-active');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
|
|
||||||
if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false;
|
|
||||||
if (!options.force && !pendingRightPageFlipAutoplay) return false;
|
|
||||||
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
|
|
||||||
const flipped = await startPageFlip(1, {
|
|
||||||
force: options.force === true || pendingRightPageFlipAutoplay,
|
|
||||||
reason,
|
|
||||||
targetSpread
|
|
||||||
});
|
|
||||||
if (flipped) {
|
|
||||||
pendingRightPageFlip = false;
|
|
||||||
pendingRightPageFlipAutoplay = false;
|
|
||||||
delete document.documentElement.dataset.webglPendingPageFlip;
|
|
||||||
}
|
|
||||||
return flipped;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isChoiceAwaitingPlayer() {
|
function isChoiceAwaitingPlayer() {
|
||||||
return document.documentElement.dataset.choiceAwaiting === 'true'
|
return document.documentElement.dataset.choiceAwaiting === 'true'
|
||||||
|| document.body?.dataset?.choiceAwaiting === 'true'
|
|| document.body?.dataset?.choiceAwaiting === 'true'
|
||||||
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
|
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTtsPlaybackActive() {
|
|
||||||
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
|
|
||||||
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
function topVisibleLine(side) {
|
function topVisibleLine(side) {
|
||||||
const sideLines = currentProceduralBookModel.lines
|
const sideLines = currentProceduralBookModel.lines
|
||||||
.filter((line) => line.side === side)
|
.filter((line) => line.side === side)
|
||||||
@@ -3205,9 +3178,15 @@ function topVisibleLine(side) {
|
|||||||
|
|
||||||
function updateActiveFlips(now) {
|
function updateActiveFlips(now) {
|
||||||
if (!activeFlips.length || !currentProceduralBookModel) return;
|
if (!activeFlips.length || !currentProceduralBookModel) return;
|
||||||
|
// Debug/isolation hook: when window.__debugFlipFreezeT is a finite number in [0,1],
|
||||||
|
// hold every active flip at that progress so a single frame can be inspected.
|
||||||
|
const freezeT = Number(window.__debugFlipFreezeT);
|
||||||
|
const frozen = Number.isFinite(freezeT);
|
||||||
const completed = [];
|
const completed = [];
|
||||||
activeFlips.forEach((flip) => {
|
activeFlips.forEach((flip) => {
|
||||||
const elapsed = (now - flip.startTime) / flip.duration;
|
const elapsed = frozen
|
||||||
|
? THREE.MathUtils.clamp(freezeT, 0, 1)
|
||||||
|
: (now - flip.startTime) / flip.duration;
|
||||||
if (elapsed < 0) return;
|
if (elapsed < 0) return;
|
||||||
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
||||||
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
|
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
|
||||||
@@ -3218,7 +3197,10 @@ function updateActiveFlips(now) {
|
|||||||
? Math.max(0, Math.round(Number(flip.targetSpread)))
|
? Math.max(0, Math.round(Number(flip.targetSpread)))
|
||||||
: null;
|
: null;
|
||||||
if (targetSpread !== null && !hasActivePageReveal()) {
|
if (targetSpread !== null && !hasActivePageReveal()) {
|
||||||
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end');
|
// Skip the revealing side(s): the timeline's activate lands the masked
|
||||||
|
// reveal for them right after the flip. Showing the full resident texture
|
||||||
|
// here would flash the not-yet-revealed block.
|
||||||
|
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end', { skipSides: flip.deferRevealSides });
|
||||||
}
|
}
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
|
||||||
detail: {
|
detail: {
|
||||||
@@ -3242,9 +3224,11 @@ function hasActivePageReveal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread') {
|
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
|
||||||
|
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
|
||||||
const pageIndices = spreadPageIndices(spreadIndex);
|
const pageIndices = spreadPageIndices(spreadIndex);
|
||||||
['left', 'right'].forEach((side) => {
|
['left', 'right'].forEach((side) => {
|
||||||
|
if (skipSides.includes(side)) return;
|
||||||
const pageIndex = pageIndices[side];
|
const pageIndex = pageIndices[side];
|
||||||
const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex);
|
const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex);
|
||||||
const texture = pageMeta.kind === 'blank'
|
const texture = pageMeta.kind === 'blank'
|
||||||
@@ -3389,8 +3373,9 @@ function lineYAtX(points, x) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setActivePageGeometry(flip, surface) {
|
function setActivePageGeometry(flip, surface) {
|
||||||
|
const widthRatios = flipWidthRatios(flip.sourceLine?.points);
|
||||||
if (!flip.mesh) {
|
if (!flip.mesh) {
|
||||||
const geometry = createFlippingPageGeometry(surface, flip.direction);
|
const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
|
||||||
flip.mesh = new THREE.Mesh(geometry, [
|
flip.mesh = new THREE.Mesh(geometry, [
|
||||||
materials.flipPageSurface,
|
materials.flipPageSurface,
|
||||||
materials.flipPageBackSurface,
|
materials.flipPageBackSurface,
|
||||||
@@ -3404,13 +3389,25 @@ function setActivePageGeometry(flip, surface) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
|
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
|
||||||
const geometry = createFlippingPageGeometry(surface, flip.direction);
|
const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
|
||||||
flip.mesh.geometry.dispose();
|
flip.mesh.geometry.dispose();
|
||||||
flip.mesh.geometry = geometry;
|
flip.mesh.geometry = geometry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFlippingPageGeometry(surface, direction = 1) {
|
// Texture U coordinates must follow physical page width (the spline uses short
|
||||||
|
// segments near the spine and long segments near the fore-edge), not the uniform
|
||||||
|
// vertex index, otherwise the flip texture is horizontally compressed relative to
|
||||||
|
// the static stack cap.
|
||||||
|
function flipWidthRatios(points) {
|
||||||
|
if (!Array.isArray(points) || points.length < 2) return null;
|
||||||
|
const lengths = cumulativeLineLengths(points);
|
||||||
|
const total = lengths[lengths.length - 1];
|
||||||
|
if (!(total > 0)) return null;
|
||||||
|
return lengths.map(length => length / total);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFlippingPageGeometry(surface, direction = 1, widthRatios = null) {
|
||||||
const positions = [];
|
const positions = [];
|
||||||
const uvs = [];
|
const uvs = [];
|
||||||
const indices = [];
|
const indices = [];
|
||||||
@@ -3426,8 +3423,12 @@ function createFlippingPageGeometry(surface, direction = 1) {
|
|||||||
const targetSide = -sourceSide;
|
const targetSide = -sourceSide;
|
||||||
const topPageSide = direction > 0 ? targetSide : sourceSide;
|
const topPageSide = direction > 0 ? targetSide : sourceSide;
|
||||||
const bottomPageSide = direction > 0 ? sourceSide : targetSide;
|
const bottomPageSide = direction > 0 ? sourceSide : targetSide;
|
||||||
const topMaterialIndex = 0;
|
// The page's width index runs spine->right for forward flips and spine->left for
|
||||||
const bottomMaterialIndex = 1;
|
// backward flips, which inverts the computed face normals. Assign the source
|
||||||
|
// texture to the face that actually points at the camera at the start of the turn
|
||||||
|
// so the lifting page shows the page it came from (not the page it lands on).
|
||||||
|
const topMaterialIndex = direction > 0 ? 1 : 0;
|
||||||
|
const bottomMaterialIndex = direction > 0 ? 0 : 1;
|
||||||
const push = (point, yOffset, uv) => {
|
const push = (point, yOffset, uv) => {
|
||||||
const index = positions.length / 3;
|
const index = positions.length / 3;
|
||||||
positions.push(point.x, point.y + yOffset, point.z);
|
positions.push(point.x, point.y + yOffset, point.z);
|
||||||
@@ -3438,7 +3439,9 @@ function createFlippingPageGeometry(surface, direction = 1) {
|
|||||||
surface.forEach((rowPoints, widthIndex) => {
|
surface.forEach((rowPoints, widthIndex) => {
|
||||||
const topRow = [];
|
const topRow = [];
|
||||||
const bottomRow = [];
|
const bottomRow = [];
|
||||||
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
|
const u = Array.isArray(widthRatios) && widthRatios.length === surface.length
|
||||||
|
? widthRatios[widthIndex]
|
||||||
|
: (widthSegments <= 0 ? 0 : widthIndex / widthSegments);
|
||||||
rowPoints.forEach((point, depthIndex) => {
|
rowPoints.forEach((point, depthIndex) => {
|
||||||
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
||||||
topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v)));
|
topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v)));
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
this.gameConfig = null;
|
this.gameConfig = null;
|
||||||
this.mode = '2d';
|
this.mode = '2d';
|
||||||
this.is3dSupported = false;
|
this.is3dSupported = false;
|
||||||
|
// Production control surface + visible-spread accessor for the dynamically
|
||||||
|
// imported webgl-book-lab. Populated by the lab once the scene is built.
|
||||||
|
this.sceneControl = null;
|
||||||
|
this.getVisibleSpreadIndex = null;
|
||||||
this.labImportPromise = null;
|
this.labImportPromise = null;
|
||||||
this.textureRefreshTimer = null;
|
this.textureRefreshTimer = null;
|
||||||
this.textureRefreshAnimationId = null;
|
this.textureRefreshAnimationId = null;
|
||||||
@@ -330,7 +334,7 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', value);
|
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', value);
|
||||||
this.preferenceWriteGuard = false;
|
this.preferenceWriteGuard = false;
|
||||||
},
|
},
|
||||||
getBookState: () => window.BookLabDebug?.getBookState?.() || {
|
getBookState: () => this.sceneControl?.getBookState?.() || {
|
||||||
pageCount: this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT,
|
pageCount: this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT,
|
||||||
pageReserve: this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE,
|
pageReserve: this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE,
|
||||||
progress: this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS
|
progress: this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS
|
||||||
@@ -341,19 +345,19 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
const progress = Number(state.progress);
|
const progress = Number(state.progress);
|
||||||
if (Number.isFinite(pageCount)) {
|
if (Number.isFinite(pageCount)) {
|
||||||
this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', pageCount);
|
this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', pageCount);
|
||||||
window.BookLabDebug?.setBookPageCount?.(pageCount);
|
this.sceneControl?.setBookPageCount?.(pageCount);
|
||||||
}
|
}
|
||||||
if (Number.isFinite(pageReserve)) {
|
if (Number.isFinite(pageReserve)) {
|
||||||
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', pageReserve);
|
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', pageReserve);
|
||||||
window.BookLabDebug?.setPageReserve?.(pageReserve);
|
this.sceneControl?.setPageReserve?.(pageReserve);
|
||||||
}
|
}
|
||||||
if (Number.isFinite(progress)) {
|
if (Number.isFinite(progress)) {
|
||||||
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
|
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
|
||||||
window.BookLabDebug?.setReadingProgress?.(progress);
|
this.sceneControl?.setReadingProgress?.(progress);
|
||||||
}
|
}
|
||||||
const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition);
|
const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition);
|
||||||
if (Number.isFinite(maxVisitedPagePosition)) {
|
if (Number.isFinite(maxVisitedPagePosition)) {
|
||||||
window.BookLabDebug?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
|
this.sceneControl?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -456,7 +460,7 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
projectCanvasEventTarget(event) {
|
projectCanvasEventTarget(event) {
|
||||||
const projection = window.BookLabDebug?.projectPointerToPage?.(event.clientX, event.clientY);
|
const projection = this.sceneControl?.projectPointerToPage?.(event.clientX, event.clientY);
|
||||||
if (!projection) {
|
if (!projection) {
|
||||||
document.documentElement.dataset.webglLastProjection = JSON.stringify({
|
document.documentElement.dataset.webglLastProjection = JSON.stringify({
|
||||||
hit: false,
|
hit: false,
|
||||||
@@ -531,11 +535,11 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
this.initializeScene();
|
this.initializeScene();
|
||||||
}
|
}
|
||||||
} else if (key === 'bookProgress' && !this.preferenceWriteGuard) {
|
} else if (key === 'bookProgress' && !this.preferenceWriteGuard) {
|
||||||
window.BookLabDebug?.setReadingProgress?.(value);
|
this.sceneControl?.setReadingProgress?.(value);
|
||||||
} else if (key === 'bookPageCount' && !this.preferenceWriteGuard) {
|
} else if (key === 'bookPageCount' && !this.preferenceWriteGuard) {
|
||||||
window.BookLabDebug?.setBookPageCount?.(value);
|
this.sceneControl?.setBookPageCount?.(value);
|
||||||
} else if (key === 'pageReserve' && !this.preferenceWriteGuard) {
|
} else if (key === 'pageReserve' && !this.preferenceWriteGuard) {
|
||||||
window.BookLabDebug?.setPageReserve?.(value);
|
this.sceneControl?.setPageReserve?.(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,7 +560,7 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
triggerTextureRefresh() {
|
triggerTextureRefresh() {
|
||||||
clearTimeout(this.textureRefreshTimer);
|
clearTimeout(this.textureRefreshTimer);
|
||||||
this.textureRefreshTimer = setTimeout(() => {
|
this.textureRefreshTimer = setTimeout(() => {
|
||||||
window.BookLabDebug?.redrawPageTextures?.();
|
this.sceneControl?.redrawPageTextures?.();
|
||||||
}, 60);
|
}, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -552,26 +552,27 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// contentVersion is the monotonic authority: a higher version is always newer and
|
||||||
|
// wins, even when the re-typeset page legitimately has fewer lines (lower
|
||||||
|
// completeness). completenessScore only breaks ties when versions are equal/absent.
|
||||||
isOlderPageEntry(pageMeta = {}, oldEntry = null) {
|
isOlderPageEntry(pageMeta = {}, oldEntry = null) {
|
||||||
if (!oldEntry) return false;
|
if (!oldEntry) return false;
|
||||||
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
|
|
||||||
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
|
|
||||||
if (incomingCompleteness < existingCompleteness) return true;
|
|
||||||
if (incomingCompleteness > existingCompleteness) return false;
|
|
||||||
const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0));
|
const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0));
|
||||||
const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0));
|
const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0));
|
||||||
return incomingVersion > 0 && existingVersion > incomingVersion;
|
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
|
||||||
|
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
|
||||||
|
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
|
||||||
|
return incomingCompleteness < existingCompleteness;
|
||||||
}
|
}
|
||||||
|
|
||||||
isOlderPageMeta(incoming = {}, existing = null) {
|
isOlderPageMeta(incoming = {}, existing = null) {
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
|
|
||||||
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
|
|
||||||
if (incomingCompleteness < existingCompleteness) return true;
|
|
||||||
if (incomingCompleteness > existingCompleteness) return false;
|
|
||||||
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
|
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
|
||||||
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
|
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
|
||||||
return incomingVersion > 0 && existingVersion > incomingVersion;
|
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
|
||||||
|
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
|
||||||
|
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
|
||||||
|
return incomingCompleteness < existingCompleteness;
|
||||||
}
|
}
|
||||||
|
|
||||||
recordProblem(detail = {}) {
|
recordProblem(detail = {}) {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ const checks = [
|
|||||||
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
|
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
|
||||||
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
|
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
|
||||||
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
|
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
|
||||||
['3D overflow reveal waits for an explicit timeline flip plan before activating future spread', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /BookLabDebug\?\.requestPageFlip/.test(bookPlaybackTimelineSource) && /requestPageFlip\(direction = 1, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
|
['3D overflow reveal commits the spread then requests a timeline flip via event before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /addEventListener\('webgl-book:request-page-flip'/.test(source) && /startPageFlip\(direction, \{/.test(source)],
|
||||||
['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
|
['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
|
||||||
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
|
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
|
||||||
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
||||||
@@ -183,22 +183,23 @@ const checks = [
|
|||||||
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
||||||
['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
||||||
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
|
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
|
||||||
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
|
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
|
||||||
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||||
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
||||||
['texture renderer publishes both spread sides for reveal preparation and fallback start', /this\.drawSpread\(this\.currentSpread \|\| this\.pagination\?\.getCurrentSpread\?\.\(\), \['left', 'right'\]\)/.test(textureRendererSource) && /const sides = \['left', 'right'\]/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /const published = this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
||||||
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
||||||
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
||||||
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
||||||
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = 0/.test(source) && /const bottomMaterialIndex = 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)],
|
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)],
|
||||||
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture \|\| getBlankPageTexture\(\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
|
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
|
||||||
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
|
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
|
||||||
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
|
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
|
||||||
['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)],
|
['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)],
|
||||||
['webgl flip geometry samples the same visible page plane as the static stack', /normalizeFlipLineToVisiblePage/.test(source) && /currentProceduralBookModel\.spineHalf/.test(source) && /const pageStartX = side \* Math\.max/.test(source) && /normalizeFlipLineToVisiblePage\(topVisibleLine\(sourceSide\), sourceSide\)/.test(source)],
|
['webgl flip geometry hinges the flip sheet at the spine using the raw page line', !/normalizeFlipLineToVisiblePage/.test(source) && /const sourceLine = topVisibleLine\(sourceSide\)/.test(source) && /const destinationLine = topVisibleLine\(-sourceSide\)/.test(source) && /lerp\(sourceLine\.anchor\.x, destinationLine\.anchor\.x, t\)/.test(source)],
|
||||||
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
|
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
|
||||||
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
|
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
|
||||||
['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
|
['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
|
||||||
|
['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)],
|
||||||
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
|
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
|
||||||
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
|
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
|
||||||
['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)],
|
['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)],
|
||||||
@@ -210,21 +211,21 @@ const checks = [
|
|||||||
['webgl page reserve persists with sane defaults', /bookPageCount: 300/.test(persistenceSource) && /bookProgress: 0/.test(persistenceSource) && /pageReserve: 50/.test(persistenceSource)],
|
['webgl page reserve persists with sane defaults', /bookPageCount: 300/.test(persistenceSource) && /bookProgress: 0/.test(persistenceSource) && /pageReserve: 50/.test(persistenceSource)],
|
||||||
['markup parser strips and stores pagereserve directives', /parsePageReserveDirective/.test(markupParserSource) && /#pagereserve\\\[/.test(markupParserSource) && /unit: match\[2\] === '%' \? 'percent' : 'pages'/.test(markupParserSource)],
|
['markup parser strips and stores pagereserve directives', /parsePageReserveDirective/.test(markupParserSource) && /#pagereserve\\\[/.test(markupParserSource) && /unit: match\[2\] === '%' \? 'percent' : 'pages'/.test(markupParserSource)],
|
||||||
['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))],
|
['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))],
|
||||||
['webgl right-page reveal records arm a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /pageFlipAfterReveal/.test(textureRendererSource) && /tryStartPendingRightPageFlip/.test(source) && /pendingRightPageFlipAutoplay/.test(source) && /const targetSpread = Math\.max\(0, Math\.round\(Number\(bookPaginationState\.spreadIndex \|\| 0\)\) \+ 1\)/.test(source) && /force: options\.force === true \|\| pendingRightPageFlipAutoplay/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip = true/.test(source)],
|
['webgl right-page reveal flips are owned by the timeline, not the scene', !/pendingRightPageFlip/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && /waitForVisualCompletion/.test(bookPlaybackTimelineSource) && /reason: 'timeline-right-page-filled'/.test(bookPlaybackTimelineSource) && /requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /isChoiceAwaitingPlayer/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
|
['webgl reveal clock follows absolute playback time and continues across page flips', /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/Math\.min\(revealFrameDeltaMs, targetFrameDurationMs\)/.test(source) && /prewarmFlipTextures\(1, targetSpread\)/.test(source)],
|
||||||
['webgl line reveal timing uses area-weighted regions instead of word-span timing', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)],
|
['webgl line reveal timing uses area-weighted regions instead of word-span timing', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /timingArea/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.timingArea \|\| region\.area\) \/ totalArea\)/.test(textureRendererSource) && !/const canUseLineWordSpans/.test(textureRendererSource)],
|
||||||
['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)],
|
['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)],
|
||||||
['webgl ordinary flip near-end uses resident target textures instead of renderer redraw', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end'\)/.test(source) && /function applyResidentSpreadTextures/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:skip-during-flip/.test(textureRendererSource)],
|
['webgl ordinary flip near-end uses resident target textures and defers revealing sides', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end', \{ skipSides: flip\.deferRevealSides \}\)/.test(source) && /function applyResidentSpreadTextures\(spreadIndex, reason = 'resident-spread', options = \{\}\)/.test(source) && /const skipSides = Array\.isArray\(options\.skipSides\)/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:state-only/.test(source)],
|
||||||
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
|
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
|
||||||
['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
|
['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
|
||||||
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
|
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
|
||||||
['webgl scene force-redraws current pagination spread for initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)],
|
['webgl scene force-redraws current pagination spread for initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)],
|
||||||
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.pendingRevealBlockIds\.delete\(id\)/.test(textureRendererSource)],
|
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)],
|
||||||
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
|
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl playback coordinator rejects placeholder zero-duration reveal timings', /timingDuration/.test(playbackCoordinatorSource) && /ttsDuration/.test(playbackCoordinatorSource) && /timingDuration <= 0 && ttsDuration > 0/.test(playbackCoordinatorSource) && /sentence\.animation = \{[\s\S]*wordTimings,[\s\S]*totalDuration: calculated\.totalDuration/.test(playbackCoordinatorSource)],
|
['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)],
|
||||||
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /this\.pagination\.spreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
|
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /this\.pagination\.spreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
|
||||||
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource) && /this\.activeAnimations\.has\(id\)/.test(textureRendererSource)],
|
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
|
||||||
['webgl visible spread state ignores future prepared publishes before flip', /spreadUpdate:deferred-future-unrendered/.test(source) && /incomingSpreadIndex > Math\.max\(0, Number\(bookPaginationState\.spreadIndex/.test(source) && /this\.drawSpread\(this\.currentSpread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
|
['webgl visible spread is owned by scene flips, not pagination publishes', /spreadUpdate:state-only/.test(source) && /webglBookPlaybackActive/.test(source) && /spreadUpdate:jump/.test(source) && /window\.BookTextureRenderer\?\.drawSpread\?\.\(spread, \['left', 'right'\], \{ force: true \}\)/.test(source)],
|
||||||
['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(revealDetail, \{[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
|
['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(revealDetail, \{[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)],
|
['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)],
|
||||||
@@ -232,14 +233,14 @@ const checks = [
|
|||||||
['sentence queue lookahead prepares 3D book timeline segments', /book-playback-timeline/.test(sentenceQueueSource) && /bookPlaybackTimeline\.prepareSentence\(sentence/.test(sentenceQueueSource) && /timelineSegment: segment/.test(sentenceQueueSource)],
|
['sentence queue lookahead prepares 3D book timeline segments', /book-playback-timeline/.test(sentenceQueueSource) && /bookPlaybackTimeline\.prepareSentence\(sentence/.test(sentenceQueueSource) && /timelineSegment: segment/.test(sentenceQueueSource)],
|
||||||
['book playback timeline prewarms texture window before prepared playback and flips', /prewarmSegmentTextures/.test(bookPlaybackTimelineSource) && /pageCache\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)],
|
['book playback timeline prewarms texture window before prepared playback and flips', /prewarmSegmentTextures/.test(bookPlaybackTimelineSource) && /pageCache\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline enforces resident page textures before prepared playback', /assertSegmentReady/.test(bookPlaybackTimelineSource) && /collectRequiredPageMetas/.test(bookPlaybackTimelineSource) && /collectTexturePlanPageMetas/.test(bookPlaybackTimelineSource) && /this\.pageCache\.ensurePageTexture\(meta/.test(bookPlaybackTimelineSource) && /timeline-cache-readiness-failed/.test(bookPlaybackTimelineSource) && !/spreads\.add\(currentSpread \+ 1\)/.test(bookPlaybackTimelineSource)],
|
['book playback timeline enforces resident page textures before prepared playback', /assertSegmentReady/.test(bookPlaybackTimelineSource) && /collectRequiredPageMetas/.test(bookPlaybackTimelineSource) && /collectTexturePlanPageMetas/.test(bookPlaybackTimelineSource) && /this\.pageCache\.ensurePageTexture\(meta/.test(bookPlaybackTimelineSource) && /timeline-cache-readiness-failed/.test(bookPlaybackTimelineSource) && !/spreads\.add\(currentSpread \+ 1\)/.test(bookPlaybackTimelineSource)],
|
||||||
['3D reveal start is controlled by the prepared timeline instead of texture events', /sentence\.webglRevealController = \(\) => this\.startRevealForSegment\(segment\)/.test(bookPlaybackTimelineSource) && /startPreparedRevealAnimation\?\.\(segment\.blockId, \{[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller/.test(playbackCoordinatorSource) && !/document\.dispatchEvent\(new CustomEvent\('book-texture:reveal-block'/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal'))],
|
['3D reveal start is owned by the timeline and dispatched to the single scene clock', /sentence\.webglRevealController = \(\) => this\.startRevealForSegment\(segment\)/.test(bookPlaybackTimelineSource) && /startPreparedRevealAnimation\?\.\(segment\.blockId, \{[\s\S]*publishEvent: true/.test(bookPlaybackTimelineSource) && /PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller/.test(playbackCoordinatorSource) && !/document\.dispatchEvent\(new CustomEvent\('book-texture:reveal-block'/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal'))],
|
||||||
['webgl lab delegates right-page reveal commits to timeline owner', /BookPlaybackTimeline\?\.ownsPageFlipCommit === true/.test(source) && /handleRevealCommittedForPageFlip/.test(source)],
|
['webgl scene reports reveal commits but does not own flips and no ownership flag survives', /dispatchEvent\(new CustomEvent\('webgl-book:reveal-committed'/.test(source) && !/handleRevealCommittedForPageFlip/.test(source) && !/ownsPageFlipCommit/.test(source) && !/ownsPageFlipCommit/.test(textureRendererSource) && !/ownsPageFlipCommit/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl reveal clock explicitly freezes during physical flips', /pageRevealFreezeAt/.test(source) && /state\.startedAt \+= frozenMs/.test(source) && /activeRevealBlockStarts\.set\(blockId, Number\(value\) \+ frozenMs\)/.test(source)],
|
['webgl reveal clock explicitly freezes during physical flips', /pageRevealFreezeAt/.test(source) && /state\.startedAt \+= frozenMs/.test(source) && /activeRevealBlockStarts\.set\(blockId, Number\(value\) \+ frozenMs\)/.test(source)],
|
||||||
['book playback timeline waits for right reveal only when current block is on right page', /getBlockRevealSides/.test(bookPlaybackTimelineSource) && /revealSides\.includes\('right'\) && this\.requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /visual-completion:no-right-flip-wait/.test(bookPlaybackTimelineSource)],
|
['book playback timeline waits for right reveal only when current block is on right page', /getBlockRevealSides/.test(bookPlaybackTimelineSource) && /revealSides\.includes\('right'\) && this\.requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /visual-completion:no-right-flip-wait/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline flips at planned right-page fragment time instead of full TTS completion', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /Promise\.race\(\[[\s\S]*this\.waitForRevealCommit\(segment\)/.test(bookPlaybackTimelineSource)],
|
['book playback timeline flips at planned right-page fragment time instead of full TTS completion', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /Promise\.race\(\[[\s\S]*this\.waitForRevealCommit\(segment\)/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
|
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
|
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
|
||||||
['webgl navigation buttons use visited page limit instead of future prepared pages', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /const navigableLimit = Math\.min\(maxVisitedPagePosition, writableLimit\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)],
|
['webgl navigation buttons cap at visited page and written content limit', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /function getNavigablePageLimit\(\)/.test(source) && /const navigableLimit = getNavigablePageLimit\(\)/.test(source) && /Math\.min\(maxVisitedPagePosition, getWritablePageLimit\(\), contentNavigable\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)],
|
||||||
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
||||||
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
||||||
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],
|
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],
|
||||||
|
|||||||
Reference in New Issue
Block a user