Stabilize WebGL book pagination restore

This commit is contained in:
2026-06-09 16:42:12 +02:00
parent fe51410a3b
commit 171cafeb65
7 changed files with 315 additions and 85 deletions
+84 -6
View File
@@ -19,11 +19,16 @@ class BookPaginationModule extends BaseModule {
this.latestBlockId = 0;
this.latestRenderedBlockId = 0;
this.appliedPageReserveBlocks = new Set();
this.preparedBlockCache = new Map();
this.bindMethods([
'initialize',
'refreshFromHistory',
'preparePendingBlock',
'getPreparedBlockCacheKey',
'rememberPreparedBlock',
'takePreparedBlock',
'clearPreparedBlocks',
'buildSpreads',
'buildPages',
'buildSpreadsFromPages',
@@ -86,6 +91,10 @@ class BookPaginationModule extends BaseModule {
this.setCurrentSpread(this.currentSpreadIndex + direction);
}
});
this.pages = this.buildPages([]);
this.spreads = this.buildSpreadsFromPages(this.pages);
this.currentSpreadIndex = 0;
this.publish({ reason: 'initial-title-spread', allowFutureUnrendered: true });
this.reportProgress(100, 'Book pagination ready');
return true;
}
@@ -93,11 +102,13 @@ class BookPaginationModule extends BaseModule {
handlePageCountChanged(event) {
this.pageFormat?.setPageCount?.(event.detail?.pageCount);
this.metrics = this.pageFormat.getTextureMetrics(this.pageFormat.getTextureWidth?.());
this.clearPreparedBlocks();
this.refreshFromHistory();
}
async refreshFromHistory(event = null) {
const token = ++this.refreshToken;
this.clearPreparedBlocks();
const detail = event?.detail || {};
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
const latestRenderedBlockId = Math.max(
@@ -117,7 +128,7 @@ class BookPaginationModule extends BaseModule {
this.latestRenderedBlockId = 0;
this.currentSpreadIndex = 0;
this.appliedPageReserveBlocks.clear();
this.publish();
this.publish({ reason: 'empty-history' });
return;
}
@@ -135,7 +146,7 @@ class BookPaginationModule extends BaseModule {
: renderedSpreadIndex >= 0
? renderedSpreadIndex
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
this.publish();
this.publish({ reason: 'history-refresh', allowFutureUnrendered: true });
}
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
@@ -158,6 +169,31 @@ class BookPaginationModule extends BaseModule {
const historyEndBlockId = options.includeUnrenderedHistory
? Math.max(0, pendingBlockId - 1)
: latestRenderedBlockId;
const cacheKey = this.getPreparedBlockCacheKey(gameId, pendingBlockId, historyEndBlockId, latestRenderedBlockId, options);
const cached = options.activate !== false ? this.takePreparedBlock(cacheKey) : null;
if (cached) {
this.latestBlockId = pendingBlockId;
this.latestRenderedBlockId = latestRenderedBlockId;
this.pages = cached.pages;
this.spreads = cached.spreads;
this.currentSpreadIndex = cached.targetSpread
? cached.targetSpread.index
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
if (options.publish !== false) this.publish({ reason: 'prepared-cache-activate' });
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
detail: {
blockId: pendingBlockId,
spread: cached.targetSpread || this.getCurrentSpread(),
spreadIndex: cached.targetSpread?.index ?? this.currentSpreadIndex,
latestBlockId: pendingBlockId,
latestRenderedBlockId,
preloadOnly: false,
reusedPreparedPagination: true
}
}));
return cached.targetSpread || this.getCurrentSpread();
}
const historyBlocks = historyEndBlockId > 0
? await this.storyHistory.getBlocksRange(gameId, 1, historyEndBlockId)
: [];
@@ -181,6 +217,13 @@ class BookPaginationModule extends BaseModule {
const lines = Array.isArray(spread?.[side]) ? spread[side] : [];
return lines.some(line => Number(line?.blockId || 0) === pendingBlockId);
}));
if (options.activate === false) {
this.rememberPreparedBlock(cacheKey, {
pages: preparedPages,
spreads: preparedSpreads,
targetSpread: targetSpread || null
});
}
if (options.activate !== false) {
this.latestBlockId = pendingBlockId;
this.latestRenderedBlockId = latestRenderedBlockId;
@@ -189,7 +232,7 @@ class BookPaginationModule extends BaseModule {
this.currentSpreadIndex = Math.max(0, Math.min(this.spreads.length - 1, this.currentSpreadIndex));
if (targetSpread) this.currentSpreadIndex = targetSpread.index;
}
if (options.publish !== false) this.publish();
if (options.publish !== false) this.publish({ reason: options.activate === false ? 'prepare-preload' : 'prepare-activate' });
document.dispatchEvent(new CustomEvent('book-pagination:block-prepared', {
detail: {
blockId: pendingBlockId,
@@ -203,6 +246,39 @@ class BookPaginationModule extends BaseModule {
return targetSpread || (options.activate === false ? preparedSpreads[0] : this.getCurrentSpread());
}
getPreparedBlockCacheKey(gameId, blockId, historyEndBlockId, latestRenderedBlockId, options = {}) {
const includeUnrendered = options.includeUnrenderedHistory === true ? 'unrendered' : 'rendered';
return [
gameId || 'game',
Math.max(0, Number(blockId || 0)),
Math.max(0, Number(historyEndBlockId || 0)),
Math.max(0, Number(latestRenderedBlockId || 0)),
includeUnrendered,
this.metrics?.width || 0,
this.metrics?.height || 0
].join(':');
}
rememberPreparedBlock(key, prepared) {
if (!key || !prepared?.pages || !prepared?.spreads) return;
this.preparedBlockCache.set(key, prepared);
while (this.preparedBlockCache.size > 12) {
const oldestKey = this.preparedBlockCache.keys().next().value;
this.preparedBlockCache.delete(oldestKey);
}
}
takePreparedBlock(key) {
if (!key || !this.preparedBlockCache.has(key)) return null;
const prepared = this.preparedBlockCache.get(key);
this.preparedBlockCache.delete(key);
return prepared;
}
clearPreparedBlocks() {
this.preparedBlockCache.clear();
}
buildSpreads(blocks = []) {
this.pages = this.buildPages(blocks);
return this.buildSpreadsFromPages(this.pages);
@@ -891,11 +967,11 @@ class BookPaginationModule extends BaseModule {
setCurrentSpread(index = 0) {
this.currentSpreadIndex = Math.max(0, Math.min(Math.round(Number(index || 0)), Math.max(0, this.spreads.length - 1)));
this.publish();
this.publish({ reason: 'set-current-spread', allowFutureUnrendered: true });
return this.currentSpreadIndex;
}
publish() {
publish(options = {}) {
const writtenPageLimit = Math.max(0, (Math.max(0, this.spreads.length - 1) * 2) - 1);
document.dispatchEvent(new CustomEvent('book-pagination:spread-updated', {
detail: {
@@ -904,7 +980,9 @@ class BookPaginationModule extends BaseModule {
spreadCount: this.spreads.length,
writtenPageLimit,
latestBlockId: this.latestBlockId,
latestRenderedBlockId: this.latestRenderedBlockId
latestRenderedBlockId: this.latestRenderedBlockId,
reason: options.reason || 'publish',
allowFutureUnrendered: options.allowFutureUnrendered === true
}
}));
}
+46 -30
View File
@@ -117,14 +117,25 @@ class BookTextureRendererModule extends BaseModule {
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.();
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0));
const latestBlockId = event.detail?.latestBlockId;
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
this.currentSpread = spread || { left: [], right: [] };
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId);
const id = String(latestBlockId);
if (event.detail?.allowFutureUnrendered === true && !this.activeAnimations.has(id)) {
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right']);
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 && event.detail?.allowFutureUnrendered !== true && spreadIndex > visibleSpread) {
this.drawSpread(this.currentSpread, ['left', 'right'], { preloadOnly: true });
return;
}
this.drawSpread(this.currentSpread, ['left', 'right']);
}
return;
@@ -143,10 +154,24 @@ class BookTextureRendererModule extends BaseModule {
});
this.addEventListener(document, 'story:manual-scroll', this.fastForwardAnimations);
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
this.drawSpread(this.currentSpread);
this.reportProgress(100, 'Book texture renderer ready');
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 = {}) {
const entry = {
name,
@@ -602,7 +627,8 @@ class BookTextureRendererModule extends BaseModule {
if (!animation || animation.completed) return;
regions.push(...this.assignRevealTiming(blockRegions, animation));
});
const sideRegions = regions.filter(region => region.side === side);
const currentSpreadIndex = Math.max(0, Number(this.currentSpread?.index ?? this.pagination?.currentSpreadIndex ?? 0));
const sideRegions = regions.filter(region => region.side === side && Math.max(0, Number(region.spreadIndex || 0)) === currentSpreadIndex);
if (!sideRegions.length) return null;
const bounds = sideRegions.reduce((box, region) => ({
x: Math.min(box.x, region.pixelRect.x),
@@ -636,26 +662,30 @@ class BookTextureRendererModule extends BaseModule {
collectRevealRegionCandidates() {
const candidates = [];
const sourceSpreads = Array.isArray(this.pagination?.spreads) && this.pagination.spreads.length
? this.pagination.spreads
: [this.currentSpread || { index: 0, left: [], right: [] }];
sourceSpreads.forEach((spread) => {
['left', 'right'].forEach((side) => {
const spreadLines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
const spreadLines = Array.isArray(spread?.[side]) ? spread[side] : [];
spreadLines.forEach((lineRecord) => {
const region = this.createRevealRegionForLine(side, lineRecord);
const region = this.createRevealRegionForLine(side, lineRecord, spread?.index);
if (region) candidates.push(region);
});
});
});
return candidates;
}
assignRevealTiming(blockRegions = [], animation = {}) {
const wordTimings = Array.isArray(animation.wordTimings) ? animation.wordTimings : [];
const totalDuration = Math.max(
Number(animation.totalDuration || 0),
...wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
...((Array.isArray(animation.wordTimings) ? animation.wordTimings : []).map(timing => Number(timing.delay || 0) + Number(timing.duration || 0)))
);
const sortedRegions = [...blockRegions].sort((a, b) => {
const aStart = Math.max(0, Number(a.wordStart || 0));
const bStart = Math.max(0, Number(b.wordStart || 0));
if (aStart !== bStart) return aStart - bStart;
const aSpread = Math.max(0, Number(a.spreadIndex || 0));
const bSpread = Math.max(0, Number(b.spreadIndex || 0));
if (aSpread !== bSpread) return aSpread - bSpread;
const aLine = Math.max(0, Number(a.lineIndex || 0));
const bLine = Math.max(0, Number(b.lineIndex || 0));
return aLine - bLine;
@@ -667,25 +697,14 @@ class BookTextureRendererModule extends BaseModule {
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
textRegions.forEach((region) => {
const wordStart = Math.max(0, Number(region.wordStart || 0));
const wordEnd = Math.max(wordStart + 1, Number(region.wordEnd || wordStart + 1));
const firstTiming = wordTimings[wordStart] || null;
const lastTiming = wordTimings[Math.min(wordTimings.length - 1, wordEnd - 1)] || firstTiming;
let delay = firstTiming ? Math.max(0, Number(firstTiming.delay || 0)) : fallbackDelay;
let duration = lastTiming
? Math.max(1, (Number(lastTiming.delay || 0) + Number(lastTiming.duration || 0)) - delay)
: 0;
if (!Number.isFinite(duration) || duration <= 0) {
duration = totalArea > 0
const duration = totalArea > 0
? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea))
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
delay = fallbackDelay;
}
timedRegions.push({
...region,
timing: { delay, duration }
timing: { delay: fallbackDelay, duration }
});
fallbackDelay = Math.max(fallbackDelay, delay + duration);
fallbackDelay += duration;
});
fixedRegions.forEach((region) => {
@@ -707,7 +726,7 @@ class BookTextureRendererModule extends BaseModule {
});
}
createRevealRegionForLine(side, lineRecord = {}) {
createRevealRegionForLine(side, lineRecord = {}, spreadIndex = null) {
const blockId = String(lineRecord?.blockId ?? '');
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
const animation = this.activeAnimations.get(blockId);
@@ -719,16 +738,14 @@ class BookTextureRendererModule extends BaseModule {
const y = content.y + Number(rect.y || 0);
const width = Math.max(1, Number(rect.width || content.width));
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord));
return this.normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, this.getImageRevealDurationMs(lineRecord), spreadIndex);
}
const rect = this.getLineInkRect(side, lineRecord);
if (!rect) return null;
const wordStart = Math.max(0, Number(lineRecord.blockWordStart || 0));
const wordCount = Math.max(1, this.getLineWordCount(lineRecord.line || {}));
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, wordStart, wordStart + wordCount);
return this.normalizeRevealRegion(side, blockId, lineRecord, rect.x, rect.y, rect.width, rect.height, 0, spreadIndex);
}
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, wordStart = 0, wordEnd = 0) {
normalizeRevealRegion(side, blockId, lineRecord, x, y, width, height, fixedDurationMs = 0, spreadIndex = null) {
const padding = Math.max(2, Number(lineRecord.fontPx || 18) * 0.12);
const left = Math.max(0, x - padding);
const top = Math.max(0, y - padding);
@@ -738,11 +755,10 @@ class BookTextureRendererModule extends BaseModule {
const rectHeight = Math.max(1, bottom - top);
return {
side,
spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)),
blockId,
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
fixedDurationMs,
wordStart,
wordEnd,
area: rectWidth * rectHeight,
pixelRect: { x: left, y: top, right, bottom },
rect: {
+44 -11
View File
@@ -1074,14 +1074,31 @@ class UIDisplayHandlerModule extends BaseModule {
publish: false,
includeUnrenderedHistory: true
});
const previewRevealDetail = {
id: sentence.id,
blockId: sentence.blockId,
wordTimings: sentence.animation?.wordTimings || [],
cueTimings: sentence.animation?.cueTimings || [],
totalDuration: sentence.animation?.totalDuration || 0,
spread: previewSpread,
preloadOnly: true
};
if (previewSpread && typeof bookTextureRenderer.prepareRevealBlock === 'function') {
bookTextureRenderer.prepareRevealBlock(previewRevealDetail, { preloadOnly: true });
}
if (Number(previewSpread?.index || 0) > currentSpreadIndex) {
await this.waitForWebGLPageFlip({
const flipped = await this.waitForWebGLPageFlip({
direction: 1,
reason: 'pending-block-overflow',
targetSpread: previewSpread.index
});
if (!flipped) {
throw new Error(`WebGL book page flip did not start for prepared spread ${previewSpread.index}`);
}
preparedSpread = await bookPagination.preparePendingBlock(sentence);
}
preparedSpread = await bookPagination.preparePendingBlock(sentence, {
includeUnrenderedHistory: true
});
} else {
document.dispatchEvent(new CustomEvent('book-pagination:prepare-block', {
detail: {
@@ -1110,23 +1127,39 @@ class UIDisplayHandlerModule extends BaseModule {
waitForWebGLPageFlip(detail = {}) {
return new Promise((resolve) => {
let resolved = false;
const finish = () => {
const cleanup = () => {
window.clearTimeout(timeout);
document.removeEventListener('webgl-book:page-flip-started', onStarted);
document.removeEventListener('webgl-book:page-flip-finished', onFinished);
};
const finish = (result) => {
if (resolved) return;
resolved = true;
window.clearTimeout(timeout);
document.removeEventListener('webgl-book:page-flip-finished', finish);
resolve(true);
cleanup();
resolve(result);
};
const timeout = window.setTimeout(finish, 1400);
document.addEventListener('webgl-book:page-flip-finished', finish, { once: true });
const requestedTargetSpread = Number.isFinite(Number(detail.targetSpread))
? Math.max(0, Math.round(Number(detail.targetSpread)))
: null;
const matchesTarget = (eventDetail = {}) => requestedTargetSpread == null
|| Math.max(0, Math.round(Number(eventDetail.targetSpread || 0))) === requestedTargetSpread;
const onStarted = (event) => {
if (!matchesTarget(event.detail || {})) return;
document.documentElement.dataset.webglLastStartedPageFlip = JSON.stringify(event.detail || {});
};
const onFinished = (event) => {
if (!matchesTarget(event.detail || {})) return;
finish(true);
};
const timeout = window.setTimeout(() => finish(false), 2400);
document.addEventListener('webgl-book:page-flip-started', onStarted);
document.addEventListener('webgl-book:page-flip-finished', onFinished);
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
detail: {
direction: Math.sign(Number(detail.direction || 1)) || 1,
reason: detail.reason || 'pending-block-overflow',
force: true,
targetSpread: Number.isFinite(Number(detail.targetSpread))
? Math.max(0, Math.round(Number(detail.targetSpread)))
: null
targetSpread: requestedTargetSpread
}
}));
});
+112 -27
View File
@@ -204,6 +204,7 @@ let bookPaginationState = {
spreadCount: 1,
writtenPageLimit: 0
};
let maxVisitedPagePosition = 0;
const normalFlipDuration = 900;
const fastFlipDuration = 520;
const fastFlipCount = 10;
@@ -364,7 +365,7 @@ const materials = {
emissive: 0x100d08,
emissiveIntensity: 0.004,
envMapIntensity: 0.01,
side: THREE.DoubleSide
side: THREE.FrontSide
}),
leftPage: new THREE.MeshStandardMaterial({
color: 0xffffff,
@@ -414,7 +415,7 @@ const materials = {
};
materials.flipPageBackSurface = materials.flipPageSurface.clone();
materials.flipPageBackSurface.map = getBlankPageTexture();
materials.flipPageBackSurface.side = THREE.DoubleSide;
materials.flipPageBackSurface.side = THREE.FrontSide;
materials.flipPageEdge = materials.pageSurface.clone();
materials.flipPageEdge.map = paperTextures.edge;
materials.flipPageEdge.normalMap = paperTextures.normal;
@@ -531,6 +532,7 @@ window.BookLabDebug = {
pageReserve,
progress: readingProgress,
pagePosition: getCurrentPagePosition(),
maxVisitedPagePosition,
spreadIndex: bookPaginationState.spreadIndex,
writtenPageLimit: bookPaginationState.writtenPageLimit
};
@@ -541,10 +543,17 @@ window.BookLabDebug = {
spreadCount: Math.max(1, Number(state.spreadCount ?? bookPaginationState.spreadCount ?? 1)),
writtenPageLimit: Math.max(0, Number(state.writtenPageLimit ?? bookPaginationState.writtenPageLimit ?? 0))
};
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition());
growBookIfWritableLimitReached();
syncBookControls();
return this.getBookState();
},
setMaxVisitedPagePosition(value) {
const page = Math.max(0, Math.round(Number(value || 0)));
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, page);
syncBookControls();
return maxVisitedPagePosition;
},
navigateToPagePosition(value) {
return navigateToPagePosition(value);
},
@@ -616,9 +625,26 @@ document.addEventListener('webgl-book:page-cache-problem', (event) => {
});
document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {};
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0));
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
if (
latestBlockId > latestRenderedBlockId
&& detail.allowFutureUnrendered !== true
&& activeFlips.length === 0
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
) {
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
incomingSpreadIndex,
visibleSpreadIndex: bookPaginationState.spreadIndex,
latestBlockId,
latestRenderedBlockId
});
return;
}
const previousPageCount = bookPageCount;
bookPaginationState = {
spreadIndex: Math.max(0, Number(detail.spreadIndex || 0)),
spreadIndex: incomingSpreadIndex,
spreadCount: Math.max(1, Number(detail.spreadCount || 1)),
writtenPageLimit: Math.max(0, Number(detail.writtenPageLimit || 0))
};
@@ -1920,10 +1946,10 @@ function ensureBottomNavigation() {
startButton.addEventListener('click', () => navigateToPagePosition(0));
backButton.addEventListener('click', () => navigateByPageDelta(-1));
forwardButton.addEventListener('click', () => navigateByPageDelta(1));
endButton.addEventListener('click', () => navigateToPagePosition(bookPaginationState.writtenPageLimit));
endButton.addEventListener('click', () => navigateToPagePosition(maxVisitedPagePosition));
slider.addEventListener('input', () => {
const requested = Number(slider.value);
const clamped = Math.min(requested, Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
const clamped = Math.min(requested, maxVisitedPagePosition, getWritablePageLimit());
if (requested !== clamped) slider.value = String(clamped);
pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`;
});
@@ -1952,8 +1978,7 @@ function navigateByPageDelta(delta) {
function navigateToPagePosition(pagePosition) {
const writableLimit = getWritablePageLimit();
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, writtenLimit));
const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, maxVisitedPagePosition));
const currentPage = getCurrentPagePosition();
if (targetPage === currentPage) {
syncBookControls();
@@ -1984,9 +2009,8 @@ function syncBookControls() {
function syncBottomNavigation() {
if (!bottomNavigation) return;
const currentPage = getCurrentPagePosition();
const writtenLimit = Math.max(0, bookPaginationState.writtenPageLimit || 0);
const writableLimit = getWritablePageLimit();
const navigableLimit = Math.min(writtenLimit, writableLimit);
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit);
const reservedStart = Math.max(0, writableLimit);
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
@@ -1994,7 +2018,7 @@ function syncBottomNavigation() {
bottomNavigation.maxLabel.textContent = String(bookPageCount);
bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${Math.min(currentPage, navigableLimit)}`;
bottomNavigation.root.style.setProperty('--book-nav-position', `${bookPageCount > 0 ? currentPage / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? writtenLimit / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-written', `${bookPageCount > 0 ? maxVisitedPagePosition / bookPageCount : 0}`);
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`);
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
@@ -2132,7 +2156,7 @@ function recordPageCacheProblem(detail = {}) {
function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
const key = makePageMetaForCache(pageIndex).pageIndex;
const key = makeResidentPageTextureKey(pageMeta);
const existing = residentPageTextures.get(key);
if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
@@ -2167,13 +2191,26 @@ function isOlderPageTextureMeta(incoming = {}, existing = null) {
}
function makePageMetaForCache(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const paginationMeta = getPaginationPageMeta(index) || {};
return {
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
...paginationMeta,
pageIndex: index,
width: pageTextureWidth,
height: leftCanvas?.height || Math.round(pageTextureWidth * PROCEDURAL_BOOK.PAGE_DEPTH / PROCEDURAL_BOOK.PAGE_WIDTH)
};
}
function makeResidentPageTextureKey(pageMetaOrIndex = {}) {
const pageMeta = typeof pageMetaOrIndex === 'number'
? makePageMetaForCache(pageMetaOrIndex)
: pageMetaOrIndex || {};
const pageIndex = Math.max(0, Math.round(Number(pageMeta.pageIndex || 0)));
const kind = String(pageMeta.kind || 'content');
const section = String(pageMeta.section || 'body');
return `${pageIndex}:${kind}:${section}`;
}
function spreadPageIndices(spreadIndex) {
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
return {
@@ -2183,7 +2220,7 @@ function spreadPageIndices(spreadIndex) {
}
function getResidentPageTexture(pageIndex) {
const key = makePageMetaForCache(pageIndex).pageIndex;
const key = makeResidentPageTextureKey(pageIndex);
const resident = residentPageTextures.get(key);
if (!resident) return null;
resident.lastUsedAt = performance.now();
@@ -2195,7 +2232,7 @@ function getResidentPageTexture(pageIndex) {
function getResidentPageTextureForMeta(pageMeta = null) {
const pageIndex = Number(pageMeta?.pageIndex);
if (!Number.isFinite(pageIndex)) return null;
const key = makePageMetaForCache(pageIndex).pageIndex;
const key = makeResidentPageTextureKey(pageMeta);
const resident = residentPageTextures.get(key);
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
return getResidentPageTexture(pageIndex);
@@ -2203,13 +2240,20 @@ function getResidentPageTextureForMeta(pageMeta = null) {
async function preloadCachedPageTexture(pageIndex) {
const meta = makePageMetaForCache(pageIndex);
if (residentPageTextures.has(meta.pageIndex)) {
const residentKey = makeResidentPageTextureKey(meta);
if (residentPageTextures.has(residentKey)) {
getResidentPageTexture(meta.pageIndex);
return residentPageTextures.get(meta.pageIndex)?.texture || null;
return residentPageTextures.get(residentKey)?.texture || null;
}
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
const sourceCanvas = await cache?.getPageCanvas?.(meta);
if (!sourceCanvas) {
const pageMeta = getPaginationPageMeta(meta.pageIndex);
if (pageMeta?.kind === 'blank') {
const blankTexture = getBlankPageTexture();
rememberResidentPageTexture({ ...meta, ...pageMeta }, blankTexture, null, false);
return blankTexture;
}
recordPageCacheProblem({
type: 'db-cache-miss',
pageIndex: meta.pageIndex,
@@ -2220,7 +2264,7 @@ async function preloadCachedPageTexture(pageIndex) {
}
const texture = createPageCanvasTexture(sourceCanvas);
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
residentPageTextures.set(meta.pageIndex, {
residentPageTextures.set(residentKey, {
texture,
sourceCanvas,
lastUsedAt: performance.now(),
@@ -2236,6 +2280,19 @@ async function preloadCachedPageTexture(pageIndex) {
return texture;
}
function getPaginationPageMeta(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const spreadIndex = Math.floor(index / 2);
const side = index % 2 === 0 ? 'left' : 'right';
const pagination = window.moduleRegistry?.getModule?.('book-pagination') || null;
const spread = typeof pagination?.getSpread === 'function'
? pagination.getSpread(spreadIndex)
: Array.isArray(pagination?.spreads)
? pagination.spreads[spreadIndex]
: null;
return spread?.pageMeta?.[side] || null;
}
async function prewarmSpreadTextures(spreadIndex) {
const indices = spreadPageIndices(spreadIndex);
const [left, right] = await Promise.all([
@@ -2276,7 +2333,8 @@ function takePreparedPageTexture(side, revealDetail = {}) {
function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
const texture = side === 'left' ? leftTexture : rightTexture;
const material = side === 'left' ? materials.leftPage : materials.rightPage;
const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex))
const shouldUseResidentTexture = pageMeta?.kind !== 'title';
const residentTexture = shouldUseResidentTexture && Number.isFinite(Number(pageMeta?.pageIndex))
? getResidentPageTextureForMeta(pageMeta)
: null;
markPageTextureTiming('directUpload:start', {
@@ -2703,6 +2761,13 @@ function startPageFlipPrepared(direction, options = {}) {
delete document.documentElement.dataset.webglPendingPageFlip;
activeFlips.push(flip);
setPageFlipActiveFlag();
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
detail: {
direction: flip.direction,
sourceSide: flip.sourcePageSide || (flip.direction > 0 ? 'right' : 'left'),
targetSpread: Number.isFinite(Number(flip.targetSpread)) ? Math.max(0, Math.round(Number(flip.targetSpread))) : null
}
}));
syncBookControls();
updateActiveFlips(flip.startTime);
return true;
@@ -2723,6 +2788,7 @@ function startFastPageFlipPrepared(direction, options = {}) {
if (activeFlips.length || !currentProceduralBookModel || !canPageFlip(direction)) return false;
const firstFlip = createPageFlip(direction, performance.now(), fastFlipDuration);
if (!firstFlip) return false;
firstFlip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
if (!prepareStaticPageForFlip(firstFlip, options.prewarm || null)) return false;
const startTime = firstFlip.startTime;
const interval = fastFlipDuration / fastFlipOverlap;
@@ -2740,6 +2806,14 @@ function startFastPageFlipPrepared(direction, options = {}) {
});
}
setPageFlipActiveFlag();
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
detail: {
direction: firstFlip.direction,
sourceSide: firstFlip.sourcePageSide || (firstFlip.direction > 0 ? 'right' : 'left'),
targetSpread: Number.isFinite(Number(firstFlip.targetSpread)) ? Math.max(0, Math.round(Number(firstFlip.targetSpread))) : null,
fast: true
}
}));
syncBookControls();
updateActiveFlips(startTime);
return true;
@@ -2774,7 +2848,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
const targetPages = spreadPageIndices(targetSpread);
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
const residentBackTexture = getResidentPageTexture(targetBackPageIndex);
const prewarmedBackTexture = flip.direction > 0 ? prewarm?.next?.left : prewarm?.next?.right;
const residentBackTexture = prewarmedBackTexture || getResidentPageTexture(targetBackPageIndex);
const requiresWrittenTexture = targetBackPageIndex <= Math.max(2, Number(bookPaginationState.writtenPageLimit || 0));
if (!residentBackTexture && requiresWrittenTexture) {
recordPageCacheProblem({
@@ -2827,7 +2902,7 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
const currentPage = getCurrentPagePosition();
const maxNavigablePage = Math.min(Math.max(0, bookPaginationState.writtenPageLimit || 0), getWritablePageLimit());
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit());
if (direction > 0) return currentPage < maxNavigablePage;
return currentPage > 0;
}
@@ -3031,7 +3106,7 @@ function lineYAtX(points, x) {
function setActivePageGeometry(flip, surface) {
if (!flip.mesh) {
const geometry = createFlippingPageGeometry(surface);
const geometry = createFlippingPageGeometry(surface, flip.direction);
flip.mesh = new THREE.Mesh(geometry, [
materials.flipPageSurface,
materials.flipPageBackSurface,
@@ -3045,13 +3120,13 @@ function setActivePageGeometry(flip, surface) {
return;
}
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
const geometry = createFlippingPageGeometry(surface);
const geometry = createFlippingPageGeometry(surface, flip.direction);
flip.mesh.geometry.dispose();
flip.mesh.geometry = geometry;
}
}
function createFlippingPageGeometry(surface) {
function createFlippingPageGeometry(surface, direction = 1) {
const positions = [];
const uvs = [];
const indices = [];
@@ -3063,10 +3138,12 @@ function createFlippingPageGeometry(surface) {
const pageThickness = Math.max(0.0008, Number(PROCEDURAL_BOOK.SHEET_THICKNESS_MODEL || 0.001));
const widthSegments = surface.length - 1;
const depthSegments = surface[0].length - 1;
const push = (point, yOffset, u, v) => {
const sourceSide = direction > 0 ? 1 : -1;
const targetSide = -sourceSide;
const push = (point, yOffset, uv) => {
const index = positions.length / 3;
positions.push(point.x, point.y + yOffset, point.z);
uvs.push(u, v);
uvs.push(uv.x, uv.y);
return index;
};
@@ -3076,8 +3153,8 @@ function createFlippingPageGeometry(surface) {
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
rowPoints.forEach((point, depthIndex) => {
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
topRow.push(push(point, pageThickness, u, v));
bottomRow.push(push(point, 0, u, 1 - v));
topRow.push(push(point, pageThickness, pageUvForSide(sourceSide, u, v)));
bottomRow.push(push(point, 0, pageUvForSide(targetSide, u, v)));
});
topGrid.push(topRow);
bottomGrid.push(bottomRow);
@@ -3123,6 +3200,13 @@ function createFlippingPageGeometry(surface) {
}
}
function pageUvForSide(side, u, v) {
return {
x: side < 0 ? 1 - u : u,
y: 1 - v
};
}
function updateFlippingPageGeometry(geometry, surface) {
const position = geometry?.getAttribute?.('position');
if (!position || !surface?.length || !surface[0]?.length) return false;
@@ -3160,6 +3244,7 @@ function finishActiveFlip(flip) {
...bookPaginationState,
spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread)))
};
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition());
syncReadingProgressToCurrentPage();
}
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
+4
View File
@@ -351,6 +351,10 @@ class WebGLBookSceneModule extends BaseModule {
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
window.BookLabDebug?.setReadingProgress?.(progress);
}
const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition);
if (Number.isFinite(maxVisitedPagePosition)) {
window.BookLabDebug?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
}
}
};
}
+10 -2
View File
@@ -84,11 +84,13 @@ class WebGLPageCacheModule extends BaseModule {
return this.db.transaction([this.storeName], mode).objectStore(this.storeName);
}
makePageKey({ pageIndex, width, height, cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) {
makePageKey({ pageIndex, width, height, kind = 'content', section = 'body', cacheKey = window.MODULE_CACHE_BUSTER || 'dev' } = {}) {
const safePage = Math.max(0, Math.round(Number(pageIndex || 0)));
const safeWidth = Math.max(1, Math.round(Number(width || 0)));
const safeHeight = Math.max(1, Math.round(Number(height || 0)));
return `${cacheKey}:page:${safePage}:${safeWidth}x${safeHeight}`;
const safeKind = String(kind || 'content').replace(/[^a-z0-9_-]/gi, '');
const safeSection = String(section || 'body').replace(/[^a-z0-9_-]/gi, '');
return `${cacheKey}:page:${safePage}:${safeKind}:${safeSection}:${safeWidth}x${safeHeight}`;
}
async cachePageCanvas(pageMeta = {}, canvas = null) {
@@ -99,6 +101,8 @@ class WebGLPageCacheModule extends BaseModule {
pageIndex,
width: canvas.width,
height: canvas.height,
kind: pageMeta.kind,
section: pageMeta.section,
cacheKey: pageMeta.cacheKey
});
try {
@@ -119,6 +123,8 @@ class WebGLPageCacheModule extends BaseModule {
height: canvas.height,
contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)),
completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)),
kind: pageMeta.kind || 'content',
section: pageMeta.section || 'body',
maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)),
lineCount: Math.max(0, Number(pageMeta.lineCount || 0)),
blob,
@@ -168,6 +174,8 @@ class WebGLPageCacheModule extends BaseModule {
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
if (canvas) canvas.__webglPageCacheMeta = {
pageIndex: entry.pageIndex,
kind: entry.kind || pageMeta.kind || 'content',
section: entry.section || pageMeta.section || 'body',
contentVersion: entry.contentVersion,
completenessScore: entry.completenessScore,
maxBlockId: entry.maxBlockId,
+10 -4
View File
@@ -163,7 +163,7 @@ const checks = [
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
['webgl reveal shader masks antialiased ink and uses smooth line-dominant scan', /smoothstep\(0\.52, 0\.9, luminance\)/.test(source) && /local\.x \* 0\.96/.test(source) && /bookRevealSoftness = \{ value: 0\.025 \}/.test(source)],
['webgl reveal line timings use absolute block word timing across split pages', /assignRevealTiming/.test(textureRendererSource) && /wordStart/.test(textureRendererSource) && /blockWordStart/.test(textureRendererSource) && /wordTimings\[wordStart\]/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)],
['webgl reveal line timings use global area timing across split-page spreads', /assignRevealTiming/.test(textureRendererSource) && /sourceSpreads/.test(textureRendererSource) && /this\.pagination\?\.spreads/.test(textureRendererSource) && /spreadIndex/.test(textureRendererSource) && /totalDuration \* \(Math\.max\(1, region\.area\) \/ totalArea\)/.test(textureRendererSource) && /durationMs: sideRegions\.reduce/.test(textureRendererSource)],
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
@@ -175,12 +175,13 @@ const checks = [
['webgl scene does not republish 3D page textures from DOM refresh events', !/addEventListener\(document, 'story:turn-start', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:turn-complete', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'story:history-updated', this\.triggerTextureRefresh\)/.test(webglSceneSource) && !/addEventListener\(document, 'input', this\.triggerTextureRefresh/.test(webglSceneSource) && !/addEventListener\(document, 'change', this\.triggerTextureRefresh/.test(webglSceneSource)],
['webgl scene adoptPageContent does not republish 3D page textures', !/triggerTextureRefresh/.test(methodBody(webglSceneSource, 'adoptPageContent'))],
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource)],
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.pages = this\.buildPages\(\[\]\);/.test(bookPaginationSource) && /this\.currentSpreadIndex = 0;[\s\S]*this\.publish\(\{ reason: 'initial-title-spread', allowFutureUnrendered: true \}\);/.test(bookPaginationSource)],
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
['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 uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
['webgl flip borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.test(source)],
['webgl flip never falls back to the opposite visible stack for target back texture', /const residentBackTexture = getResidentPageTexture\(targetBackPageIndex\)/.test(source) && /const backTexture = residentBackTexture \|\| getBlankPageTexture\(\)/.test(source) && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
['webgl flip never falls back to the opposite visible stack for target back texture', /const prewarmedBackTexture = flip\.direction > 0 \? prewarm\?\.next\?\.left : prewarm\?\.next\?\.right/.test(source) && /const residentBackTexture = prewarmedBackTexture \|\| getResidentPageTexture\(targetBackPageIndex\)/.test(source) && /const backTexture = residentBackTexture \|\| getBlankPageTexture\(\)/.test(source) && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
['webgl page canvas metadata accepts explicit blank sides instead of retaining stale pages', /hasLeftMeta/.test(source) && /hasRightMeta/.test(source) && /Object\.prototype\.hasOwnProperty\.call\(detail\.pageMeta, 'right'\)/.test(source)],
['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 marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
@@ -188,7 +189,7 @@ const checks = [
['webgl resident page texture cache rejects older page versions before direct reuse', /isOlderPageTextureMeta/.test(source) && /getResidentPageTextureForMeta/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.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) && /geometry\.addGroup\(0, topIndices\.length, 0\)/.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/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture/.test(source)],
['webgl animated page back face uses its own unflipped page orientation', /bottomRow\.push\(push\(point, 0, u, 1 - v\)\)/.test(source)],
['webgl animated page back face uses destination-side page UV orientation', /pageUvForSide\(targetSide, u, v\)/.test(source) && /pageUvForSide\(sourceSide, u, v\)/.test(source) && /side < 0 \? 1 - u : u/.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 scene targets 60fps with browser-frame scheduling live mirror and static heavy refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source) && !/setTimeout\(animate/.test(source)],
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 1/.test(source) && /const tableReflectionBaseWidth = 2048/.test(source) && /const tableReflectionBaseHeight = 1152/.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)],
@@ -205,7 +206,12 @@ const checks = [
['webgl right-page completion arms a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /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 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)],
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ preloadOnly: true \}\)/.test(textureRendererSource) && /this\.activeAnimations\.has\(id\)/.test(textureRendererSource)],
['webgl visible spread state ignores future unrendered prepare publishes before flip', /spreadUpdate:deferred-future-unrendered/.test(source) && /incomingSpreadIndex > Math\.max\(0, Number\(bookPaginationState\.spreadIndex/.test(source) && /this\.drawSpread\(this\.currentSpread, \['left', 'right'\], \{ preloadOnly: true \}\)/.test(textureRendererSource)],
['3D overflow reveal preloads target spread before forced page flip', /previewRevealDetail/.test(uiDisplayHandlerSource) && /preloadOnly: true/.test(uiDisplayHandlerSource) && /bookTextureRenderer\.prepareRevealBlock\(previewRevealDetail, \{ preloadOnly: true \}\)/.test(uiDisplayHandlerSource) && /await this\.waitForWebGLPageFlip/.test(uiDisplayHandlerSource)],
['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 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 back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.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)],
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
];