diff --git a/public/js/book-pagination-module.js b/public/js/book-pagination-module.js index 6289424..6319c24 100644 --- a/public/js/book-pagination-module.js +++ b/public/js/book-pagination-module.js @@ -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 } })); } diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 5069817..991cf7d 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -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 = []; - ['left', 'right'].forEach((side) => { - const spreadLines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : []; - spreadLines.forEach((lineRecord) => { - const region = this.createRevealRegionForLine(side, lineRecord); - if (region) candidates.push(region); + 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(spread?.[side]) ? spread[side] : []; + spreadLines.forEach((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 - ? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea)) - : Math.max(1, totalDuration / Math.max(1, textRegions.length)); - delay = fallbackDelay; - } + const duration = totalArea > 0 + ? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea)) + : Math.max(1, totalDuration / Math.max(1, textRegions.length)); 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: { diff --git a/public/js/ui-display-handler-module.js b/public/js/ui-display-handler-module.js index 3840222..39de72e 100644 --- a/public/js/ui-display-handler-module.js +++ b/public/js/ui-display-handler-module.js @@ -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 } })); }); diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 91cf111..28060bc 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -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', { diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index ea1b8bf..0bfba7b 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -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); + } } }; } diff --git a/public/js/webgl-page-cache-module.js b/public/js/webgl-page-cache-module.js index 22ef1cf..b3328d7 100644 --- a/public/js/webgl-page-cache-module.js +++ b/public/js/webgl-page-cache-module.js @@ -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, diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 0b669ea..824de14 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -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)] ];