diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index 0520b13..1c9fd59 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -108,10 +108,10 @@ class BookPlaybackTimelineModule extends BaseModule { await this.timeStage('activate', segment, () => this.activatePreparedSegment(segment, sentence)); sentence.webglRevealController = () => this.startRevealForSegment(segment); - const visualPromise = this.waitForVisualCompletion(segment); const playbackPromise = this.timeStage('playback', segment, () => { return this.playbackCoordinator?.play?.(sentence) || Promise.resolve(); }); + const visualPromise = this.waitForVisualCompletion(segment); await Promise.all([playbackPromise, visualPromise]); this.recordDiagnostic('segment-play:end', segment); @@ -172,8 +172,14 @@ class BookPlaybackTimelineModule extends BaseModule { requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread), preparedTexturePlan: texturePlan, preparedAt: performance.now(), + revealStartedAt: null, + revealStartedPromise: null, + resolveRevealStarted: null, status: 'prepared' }; + segment.revealStartedPromise = new Promise(resolve => { + segment.resolveRevealStarted = resolve; + }); this.applyTexturePlan(texturePlan, segment, 'prepare'); await this.timeStage('texture-prewarm', segment, () => this.prewarmSegmentTextures(segment)); @@ -303,6 +309,11 @@ class BookPlaybackTimelineModule extends BaseModule { throw new Error('BookPlaybackTimeline: WebGL book lab cannot start prepared reveals explicitly'); } window.BookLabDebug.startRevealForBlock(segment.blockId); + segment.revealStartedAt = performance.now(); + if (typeof segment.resolveRevealStarted === 'function') { + segment.resolveRevealStarted(segment.revealStartedAt); + segment.resolveRevealStarted = null; + } this.markBenchmark('reveal-start', segment); this.recordDiagnostic('reveal-started', segment); return true; @@ -337,7 +348,7 @@ class BookPlaybackTimelineModule extends BaseModule { this.recordDiagnostic('visual-completion:no-right-flip-wait', segment); return; } - const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForRevealCommit(segment)); + const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForPlannedRightReveal(segment)); if (!committed || this.isChoiceAwaitingPlayer()) return; await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, { reason: 'timeline-right-page-filled', @@ -346,6 +357,29 @@ class BookPlaybackTimelineModule extends BaseModule { })); } + async waitForPlannedRightReveal(segment = {}) { + const startedAt = Number(segment.revealStartedAt) + || await (segment.revealStartedPromise || Promise.resolve(performance.now())); + const duration = this.getRightRevealDurationMs(segment); + const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now())); + const remaining = Math.max(0, duration - elapsed); + const planned = new Promise(resolve => { + setTimeout(() => resolve(true), remaining); + }); + return Promise.race([ + planned, + this.waitForRevealCommit(segment) + ]); + } + + getRightRevealDurationMs(segment = {}) { + const duration = Number(segment.activeTexturePlan?.reveal?.right?.durationMs + ?? segment.preparedTexturePlan?.reveal?.right?.durationMs + ?? 0); + if (Number.isFinite(duration) && duration > 0) return duration; + return Math.max(1, Number(segment.sentence?.animation?.totalDuration || 1)); + } + waitForRevealCommit(segment = {}) { const blockId = String(segment.blockId ?? ''); if (!blockId) return Promise.resolve(false); diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index a23112e..b95b8f3 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -148,6 +148,9 @@ class BookTextureRendererModule extends BaseModule { this.drawSpread(this.currentSpread); }); this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations); + this.addEventListener(document, 'webgl-book:reveal-committed', (event) => { + this.completeRevealBlockIds(event.detail?.blockIds || []); + }); this.addEventListener(document, 'ui:command', (event) => { if (event.detail?.type === 'continue') this.fastForwardAnimations(); }); @@ -1081,6 +1084,18 @@ class BookTextureRendererModule extends BaseModule { } } + completeRevealBlockIds(blockIds = []) { + const ids = Array.isArray(blockIds) ? blockIds : []; + ids.forEach((blockId) => { + const id = String(blockId ?? ''); + if (!id) return; + const animation = this.activeAnimations.get(id); + if (animation) animation.completed = true; + this.revealedBlockIds.add(id); + this.pendingRevealBlockIds.delete(id); + }); + } + stopAnimations() { this.activeAnimations.clear(); this.pendingRevealBlockIds.clear(); diff --git a/public/js/game-loop-module.js b/public/js/game-loop-module.js index b6d93d9..7ab9eab 100644 --- a/public/js/game-loop-module.js +++ b/public/js/game-loop-module.js @@ -76,7 +76,7 @@ class GameLoopModule extends BaseModule { return true; } - start() { + async start() { console.log("GameLoop: Starting game sequence..."); try { @@ -87,12 +87,14 @@ class GameLoopModule extends BaseModule { console.log("GameLoop: Setting up socket listeners and connecting..."); // Set up socket event listeners and connect - this.setupSocketEventListeners(); + const connected = await this.setupSocketEventListeners(); // Set the game loop as running this.isRunning = true; + return connected; } catch (error) { console.error("Error starting game loop:", error); + return false; } } @@ -133,7 +135,7 @@ class GameLoopModule extends BaseModule { if (!socketClient) { console.error("Socket client module not found"); - return; + return Promise.resolve(false); } // Connect UI controller to socket client for command handling @@ -181,12 +183,13 @@ class GameLoopModule extends BaseModule { }); // Connect to the socket server - socketClient.connect().then(success => { + return socketClient.connect().then(success => { if (success) { console.log("GameLoop: Socket connection established successfully."); } else { console.error("GameLoop: Failed to connect to socket server"); } + return success; }); } diff --git a/public/js/loader.js b/public/js/loader.js index c0512be..398358e 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260610-book-timeline-j'; +const MODULE_CACHE_BUSTER = '20260610-book-timeline-l'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** @@ -830,17 +830,17 @@ const ModuleLoader = (function() { async function completeFinalization() { isLoadingComplete = true; - // Call the start method on the game loop module directly - // Ensure the game loop module was found during initialization + // Call the start method on the game loop module directly. + // Starting before hiding the overlay lets socket connection and + // save/resume state settle as part of the loader handoff. if (gameLoopModule && typeof gameLoopModule.start === 'function') { - // Hide the overlay first, then start the game loop - await hideOverlay(); - console.log("Loader: Overlay hidden, starting Game Loop."); try { - gameLoopModule.start(); + console.log("Loader: Starting Game Loop before hiding overlay."); + await gameLoopModule.start(); } catch (error) { console.error("Error starting Game Loop:", error); } + await hideOverlay(); } else { console.error("Loader: Game Loop module not found or start method missing."); // Hide overlay anyway, but log error diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index b4987fd..36c9f4d 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js'; import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js'; import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js'; -import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-j'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-l'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -430,6 +430,12 @@ materials.flipPageEdge.map = paperTextures.edge; materials.flipPageEdge.normalMap = paperTextures.normal; materials.flipPageEdge.roughnessMap = paperTextures.roughness; materials.flipPageEdge.side = THREE.DoubleSide; +materials.flipPageSurface.userData.bookPageReveal = { + side: 'flipFront' +}; +materials.flipPageBackSurface.userData.bookPageReveal = { + side: 'flipBack' +}; materials.leftPage.userData.bookPageReveal = { side: 'left' }; @@ -441,6 +447,8 @@ materials.headband.userData.isHeadband = true; configureHardcoverPaperMaterial(materials.pageBlock); configureHardcoverPaperMaterial(materials.pageEdge, { useEdgeMap: true }); configureHardcoverPaperMaterial(materials.pageSurface); +configureHardcoverPaperMaterial(materials.flipPageSurface); +configureHardcoverPaperMaterial(materials.flipPageBackSurface); configureHardcoverPaperMaterial(materials.leftPage); configureHardcoverPaperMaterial(materials.rightPage); @@ -2072,30 +2080,36 @@ function syncBottomNavigation() { function handlePageTextureRecords(event) { const detail = normalizePageTextureRecordDetail(event.detail || {}); - if (detail.pageMeta) { - currentPageMeta = normalizePageMetaPair(detail.pageMeta, currentPageMeta); + const incomingPageMeta = detail.pageMeta + ? normalizePageMetaPair(detail.pageMeta, currentPageMeta) + : currentPageMeta; + const effectivePageMeta = detail.phase === 'prepare' + ? incomingPageMeta + : incomingPageMeta; + if (detail.phase !== 'prepare' && detail.pageMeta) { + currentPageMeta = incomingPageMeta; } markPageTextureTiming('handlePageTextureRecords:start', { hasLeft: Boolean(detail.left), hasRight: Boolean(detail.right), revealSides: Object.keys(detail.reveal || {}), phase: detail.phase || 'activate', - pageMeta: currentPageMeta + pageMeta: effectivePageMeta }); - const leftReveal = attachRevealPageMeta(detail.reveal?.left, currentPageMeta.left || null); - const rightReveal = attachRevealPageMeta(detail.reveal?.right, currentPageMeta.right || null); + const leftReveal = attachRevealPageMeta(detail.reveal?.left, effectivePageMeta.left || null); + const rightReveal = attachRevealPageMeta(detail.reveal?.right, effectivePageMeta.right || null); if (detail.phase === 'prepare') { if (detail.left) { - const texture = preloadPageTexture('left', detail.left, leftReveal, currentPageMeta.left); - pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, texture, detail.left, true); - } else if (currentPageMeta.left?.kind === 'blank') { - pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, getBlankPageTexture(), null, false); + const texture = preloadPageTexture('left', detail.left, leftReveal, effectivePageMeta.left); + pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.left, texture, detail.left, true); + } else if (effectivePageMeta.left?.kind === 'blank') { + pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.left, getBlankPageTexture(), null, false); } if (detail.right) { - const texture = preloadPageTexture('right', detail.right, rightReveal, currentPageMeta.right); - pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, texture, detail.right, true); - } else if (currentPageMeta.right?.kind === 'blank') { - pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, getBlankPageTexture(), null, false); + const texture = preloadPageTexture('right', detail.right, rightReveal, effectivePageMeta.right); + pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.right, texture, detail.right, true); + } else if (effectivePageMeta.right?.kind === 'blank') { + pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.right, getBlankPageTexture(), null, false); } markPageTextureTiming('handlePageTextureRecords:prepare:end'); return; @@ -2104,21 +2118,21 @@ function handlePageTextureRecords(event) { if (leftReveal) { beginPageReveal('left', detail.left, leftReveal); } else { - uploadPageTextureDirect('left', detail.left, currentPageMeta.left); + uploadPageTextureDirect('left', detail.left, effectivePageMeta.left); } } if (detail.right) { if (rightReveal) { beginPageReveal('right', detail.right, rightReveal); } else { - uploadPageTextureDirect('right', detail.right, currentPageMeta.right); + uploadPageTextureDirect('right', detail.right, effectivePageMeta.right); } } - if (!detail.left && currentPageMeta.left?.kind === 'blank') { - applyExplicitBlankPageTexture('left', currentPageMeta.left, 'page-texture-records'); + if (!detail.left && effectivePageMeta.left?.kind === 'blank') { + applyExplicitBlankPageTexture('left', effectivePageMeta.left, 'page-texture-records'); } - if (!detail.right && currentPageMeta.right?.kind === 'blank') { - applyExplicitBlankPageTexture('right', currentPageMeta.right, 'page-texture-records'); + if (!detail.right && effectivePageMeta.right?.kind === 'blank') { + applyExplicitBlankPageTexture('right', effectivePageMeta.right, 'page-texture-records'); } markStaticSceneBuffersDirty(); document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({ @@ -2427,6 +2441,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) { }); if (prepared?.texture) { material.map = prepared.texture; + material.needsUpdate = true; } else { if (material.map !== texture) { material.map = texture; @@ -2549,10 +2564,44 @@ function applyPageRevealRegions(shader, regions = []) { } function getPageRevealShader(side) { - const material = side === 'left' ? materials.leftPage : materials.rightPage; + const material = side === 'left' + ? materials.leftPage + : side === 'right' + ? materials.rightPage + : side === 'flipFront' + ? materials.flipPageSurface + : side === 'flipBack' + ? materials.flipPageBackSurface + : null; return material?.userData?.bookRevealShader || null; } +function syncFlipRevealShaderFromSource(sourceSide, targetMaterial = materials.flipPageSurface) { + if (!sourceSide || !targetMaterial?.userData) return false; + const sourceState = pageRevealState[sourceSide]; + const sourceShader = getPageRevealShader(sourceSide); + const targetShader = targetMaterial.userData.bookRevealShader || null; + if (!sourceState || !sourceShader?.uniforms || !targetShader?.uniforms) return false; + const sourceUniforms = sourceShader.uniforms; + const targetUniforms = targetShader.uniforms; + targetUniforms.bookRevealActive.value = sourceUniforms.bookRevealActive?.value || 0; + targetUniforms.bookRevealElapsedMs.value = sourceUniforms.bookRevealElapsedMs?.value || sourceState.visualElapsedMs || 0; + targetUniforms.bookRevealRegionCount.value = sourceUniforms.bookRevealRegionCount?.value || 0; + if (targetUniforms.bookRevealBaseMap) targetUniforms.bookRevealBaseMap.value = sourceUniforms.bookRevealBaseMap?.value || sourceState.baseTexture || targetMaterial.map; + if (targetUniforms.bookRevealUseBaseMap) targetUniforms.bookRevealUseBaseMap.value = sourceUniforms.bookRevealUseBaseMap?.value || 0; + const sourceRects = sourceUniforms.bookRevealRegionRects?.value || []; + const targetRects = targetUniforms.bookRevealRegionRects?.value || []; + const sourceTimings = sourceUniforms.bookRevealRegionTimings?.value || []; + const targetTimings = targetUniforms.bookRevealRegionTimings?.value || []; + for (let index = 0; index < Math.min(sourceRects.length, targetRects.length); index += 1) { + targetRects[index].copy(sourceRects[index]); + } + for (let index = 0; index < Math.min(sourceTimings.length, targetTimings.length); index += 1) { + targetTimings[index].copy(sourceTimings[index]); + } + return true; +} + function getRevealDebugState() { return ['left', 'right'].reduce((state, side) => { const shader = getPageRevealShader(side); @@ -2699,6 +2748,12 @@ function updatePageRevealAnimations(now) { } const progress = THREE.MathUtils.clamp(state.visualElapsedMs / state.durationMs, 0, 1); shader.uniforms.bookRevealElapsedMs.value = state.visualElapsedMs; + if (materials.flipPageSurface.userData.sourceRevealSide === side) { + syncFlipRevealShaderFromSource(side, materials.flipPageSurface); + } + if (materials.flipPageBackSurface.userData.sourceRevealSide === side) { + syncFlipRevealShaderFromSource(side, materials.flipPageBackSurface); + } if (progress < 1) return; clearPageReveal(side, 'duration-complete'); @@ -2913,8 +2968,8 @@ function startFastPageFlipPrepared(direction, options = {}) { function createPageFlip(direction, startTime, duration) { const sourceSide = direction > 0 ? 1 : -1; const sourcePageSide = direction > 0 ? 'right' : 'left'; - const sourceLine = topVisibleLine(sourceSide); - const destinationLine = topVisibleLine(-sourceSide); + const sourceLine = normalizeFlipLineToVisiblePage(topVisibleLine(sourceSide), sourceSide); + const destinationLine = normalizeFlipLineToVisiblePage(topVisibleLine(-sourceSide), -sourceSide); if (!sourceLine || !destinationLine) return null; return { direction, @@ -2930,6 +2985,29 @@ function createPageFlip(direction, startTime, duration) { }; } +function normalizeFlipLineToVisiblePage(line, side) { + if (!line || !currentProceduralBookModel) return line; + const points = Array.isArray(line.points) ? line.points : []; + if (points.length < 2) return line; + const pageStartX = side * Math.max(0, Number(currentProceduralBookModel.spineHalf || 0)); + const endpoint = points[points.length - 1]; + const sourceStart = points[0]; + const sourceSpan = Math.max(0.0001, side * (endpoint.x - sourceStart.x)); + const normalizedPoints = points.map((point) => { + const u = THREE.MathUtils.clamp(side * (point.x - sourceStart.x) / sourceSpan, 0, 1); + return { + x: THREE.MathUtils.lerp(pageStartX, endpoint.x, u), + y: point.y + }; + }); + return { + ...line, + anchor: normalizedPoints[0], + points: normalizedPoints, + endpoint: normalizedPoints[normalizedPoints.length - 1] + }; +} + function prepareStaticPageForFlip(flip, prewarm = null) { if (!flip) return false; const sourceSide = flip.direction > 0 ? 'right' : 'left'; @@ -2962,6 +3040,8 @@ function prepareStaticPageForFlip(flip, prewarm = null) { } materials.flipPageSurface.map = sourceTexture; materials.flipPageBackSurface.map = backTexture || getBlankPageTexture(); + materials.flipPageSurface.userData.sourceRevealSide = pageRevealState[sourceSide] ? sourceSide : null; + materials.flipPageBackSurface.userData.sourceRevealSide = null; materials.flipPageSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap; materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap; @@ -3006,15 +3086,17 @@ function prepareStaticPageForFlip(flip, prewarm = null) { ...lastFlipTexturePreflight, usedResidentBackTexture: Boolean(backTexture && backTexture !== getBlankPageTexture()) }); + syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface); return true; } function resolveCurrentFlipSourceTexture(side) { const pageMeta = currentPageMeta?.[side] || null; if (pageMeta?.kind === 'blank') return getBlankPageTexture(); + const material = side === 'left' ? materials.leftPage : materials.rightPage; + if (pageRevealState[side]) return material?.map || null; const resident = pageTextureStore?.getResidentTextureForMeta?.(pageMeta); if (resident) return resident; - const material = side === 'left' ? materials.leftPage : materials.rightPage; return material?.map || null; } @@ -3366,8 +3448,10 @@ function createFlippingPageGeometry(surface, direction = 1) { } function pageUvForSide(side, u, v) { + const inset = THREE.MathUtils.clamp(Number(PROCEDURAL_BOOK.PAGE_TEXTURE_FORE_EDGE_INSET_RATIO || 0), 0, 0.35); + const pageU = THREE.MathUtils.clamp(u / Math.max(0.0001, 1 - inset), 0, 1); return { - x: side < 0 ? 1 - u : u, + x: side < 0 ? 1 - pageU : pageU, y: v }; } @@ -3462,6 +3546,8 @@ function removeFlipMesh(flip) { book.remove(flip.mesh); flip.mesh.geometry.dispose(); flip.mesh = null; + materials.flipPageSurface.userData.sourceRevealSide = null; + materials.flipPageBackSurface.userData.sourceRevealSide = null; } function easeInOutCubic(t) { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 091f0f0..15647eb 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -150,9 +150,9 @@ const checks = [ ['webgl texture store is non-optional with db memory cache prepared textures and vram cache', /maxCacheSizeBytes = 5 \* 1024 \* 1024 \* 1024/.test(webglPageCacheSource) && /maxMemoryCanvasCount = 256/.test(webglPageCacheSource) && /residentTextures = new Map/.test(webglPageCacheSource) && /preparedTextures = \{/.test(webglPageCacheSource) && /persistent page caching is in a problem state/.test(webglPageCacheSource) && !/if \(this\.memoryCanvasCache\.has\(key\)\) return true/.test(webglPageCacheSource)], ['webgl lab prewarms navigation texture window through single store before flips', /const maxResidentPageTextures = 192/.test(source) && /configureTextureRuntime/.test(source) && /prewarmNavigationTextureWindow/.test(source) && /await prewarmFlipTextures\(direction, targetSpread\)/.test(source) && /resolveFlipBackTexture\(targetBackPageMeta, prewarmedBackTexture\)/.test(source) && !/const residentPageTextures = new Map/.test(source)], ['webgl texture store records cache misses as problem states', /problemLog/.test(webglPageCacheSource) && /recordProblem/.test(webglPageCacheSource) && /db-cache-miss/.test(webglPageCacheSource) && /webglPageCacheProblems/.test(webglPageCacheSource)], - ['webgl lab makes preload-only page canvases resident by explicit page metadata through store', /pageTextureStore\?\.preparePageTexture/.test(source) && /attachRevealPageMeta/.test(source) && source.includes('pageTextureStore?.rememberResidentTexture?.(currentPageMeta.left, texture, detail.left, true)') && source.includes('pageTextureStore?.rememberResidentTexture?.(currentPageMeta.right, texture, detail.right, true)')], + ['webgl lab makes preload-only page canvases resident by explicit page metadata through store', /pageTextureStore\?\.preparePageTexture/.test(source) && /attachRevealPageMeta/.test(source) && source.includes('pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.left, texture, detail.left, true)') && source.includes('pageTextureStore?.rememberResidentTexture?.(effectivePageMeta.right, texture, detail.right, true)')], ['webgl texture store keeps current visible page textures resident without disposing shared maps', /rememberResidentTexture\(pageMeta = \{\}, texture = null, sourceCanvas = null, ownsTexture = true\)/.test(webglPageCacheSource) && /ownsTexture/.test(webglPageCacheSource) && /if \(oldest\?\.ownsTexture\) oldest\.texture\?\.dispose\?\.\(\)/.test(webglPageCacheSource)], - ['webgl lab reuses current-enough resident cached page textures via single store for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && source.includes('pageTextureStore?.getResidentTextureForMeta?.(pageMeta)') && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, currentPageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, currentPageMeta\.right\)/.test(source) && !/function getResidentPageTextureForMeta/.test(source)], + ['webgl lab reuses current-enough resident cached page textures via single store for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && source.includes('pageTextureStore?.getResidentTextureForMeta?.(pageMeta)') && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, effectivePageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, effectivePageMeta\.right\)/.test(source) && !/function getResidentPageTextureForMeta/.test(source)], ['webgl page cache preserves explicit cache keys across writes and reads', /cacheKey: pageMeta\.cacheKey/.test(webglPageCacheSource) && /makePageKey\(pageMeta\)/.test(webglPageCacheSource)], ['webgl page cache rejects older page versions for the same page key', /isOlderPageEntry/.test(webglPageCacheSource) && /contentVersion/.test(webglPageCacheSource) && /completenessScore/.test(webglPageCacheSource) && /if \(this\.isOlderPageEntry\(pageMeta, oldEntry\)\) return true/.test(webglPageCacheSource)], ['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.test(source)], @@ -193,7 +193,9 @@ const checks = [ ['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.test(source)], ['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture \|\| getBlankPageTexture\(\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)], ['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)], - ['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - u : u/.test(source) && /y: v/.test(source)], + ['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)], + ['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)], + ['webgl flip geometry samples the same visible page plane as the static stack', /normalizeFlipLineToVisiblePage/.test(source) && /currentProceduralBookModel\.spineHalf/.test(source) && /const pageStartX = side \* Math\.max/.test(source) && /normalizeFlipLineToVisiblePage\(topVisibleLine\(sourceSide\), sourceSide\)/.test(source)], ['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)], ['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))], ['webgl 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)], @@ -213,6 +215,10 @@ const checks = [ ['webgl line reveal timing uses TTS word spans instead of stretching split page fragments', /lineWordCount/.test(bookPaginationSource) && /blockWordStart/.test(textureRendererSource) && /blockWordCount/.test(textureRendererSource) && /getLineTimingFromWords/.test(textureRendererSource) && /const canUseLineWordSpans/.test(textureRendererSource)], ['webgl flip completion defers book rebuild out of the final animation frame', /scheduledBookRebuildFrame/.test(source) && /function scheduleBookRebuild/.test(source) && /syncReadingProgressToCurrentPage\(\{[\s\S]*rebuild: 'defer'[\s\S]*reason: 'page-flip-finished'/.test(source)], ['webgl ordinary flip near-end uses resident target textures instead of renderer redraw', /applyResidentSpreadTextures\(targetSpread, 'page-flip-near-end'\)/.test(source) && /function applyResidentSpreadTextures/.test(source) && /residentSpreadTextures:applied/.test(source) && /spreadUpdate:skip-during-flip/.test(textureRendererSource)], + ['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(pageRevealState\[side\]\) return material\?\.map \|\| null/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide = pageRevealState\[sourceSide\] \? sourceSide : null/.test(source)], + ['webgl flipping page materials mirror active reveal shader uniforms', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source)], + ['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)], + ['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.pendingRevealBlockIds\.delete\(id\)/.test(textureRendererSource)], ['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)], ['webgl playback coordinator rejects placeholder zero-duration reveal timings', /timingDuration/.test(playbackCoordinatorSource) && /ttsDuration/.test(playbackCoordinatorSource) && /timingDuration <= 0 && ttsDuration > 0/.test(playbackCoordinatorSource) && /sentence\.animation = \{[\s\S]*wordTimings,[\s\S]*totalDuration: calculated\.totalDuration/.test(playbackCoordinatorSource)], ['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /this\.pagination\.spreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)], @@ -229,6 +235,7 @@ const checks = [ ['webgl lab delegates right-page reveal commits to timeline owner', /BookPlaybackTimeline\?\.ownsPageFlipCommit === true/.test(source) && /handleRevealCommittedForPageFlip/.test(source)], ['webgl reveal clock explicitly freezes during physical flips', /pageRevealFreezeAt/.test(source) && /state\.startedAt \+= frozenMs/.test(source) && /activeRevealBlockStarts\.set\(blockId, Number\(value\) \+ frozenMs\)/.test(source)], ['book playback timeline waits for right reveal only when current block is on right page', /getBlockRevealSides/.test(bookPlaybackTimelineSource) && /revealSides\.includes\('right'\) && this\.requiresRightPageFlipAfterReveal/.test(bookPlaybackTimelineSource) && /visual-completion:no-right-flip-wait/.test(bookPlaybackTimelineSource)], + ['book playback timeline flips at planned right-page fragment time instead of full TTS completion', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /Promise\.race\(\[[\s\S]*this\.waitForRevealCommit\(segment\)/.test(bookPlaybackTimelineSource)], ['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)], ['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)], ['webgl navigation buttons use visited page limit instead of future prepared pages', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /const navigableLimit = Math\.min\(maxVisitedPagePosition, writableLimit\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)],