diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 7074792..efbc676 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -1892,15 +1892,21 @@ function getCurrentPagePosition() { return spreadIndexToPagePosition(bookPaginationState.spreadIndex); } -// Manual navigation must not run past the spreads that actually exist (so a stale -// restored maxVisitedPagePosition cannot enable a flip into empty pages), but it must -// still reach the last existing spread. spreadCount is the real spread count; the last -// navigable spread is spreadCount - 1. (writtenPageLimit deliberately under-counts by a -// spread, so it must not be used for this.) -function getNavigablePageLimit() { - const lastSpreadIndex = Math.max(0, Math.round(Number(bookPaginationState.spreadCount || 1)) - 1); - const contentNavigable = spreadIndexToPagePosition(lastSpreadIndex); - return Math.min(maxVisitedPagePosition, getWritablePageLimit(), contentNavigable); +// Navigation is spread-based. The highest spread the reader may reach is the lesser of +// the spreads they have already visited and the spreads that actually exist (spreadCount +// is the real count). This prevents a stale restored position from flipping into empty +// pages while still allowing reaching the last existing spread. +function getMaxNavigableSpread() { + const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1))); + const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition); + return Math.max(0, Math.min(visitedSpread, spreadCount - 1)); +} + +// The page-number readout shows the odd (right) page of the visible pair, or 0 at the +// title spread. +function spreadPageLabel(spreadIndex) { + const spread = Math.max(0, Math.round(Number(spreadIndex || 0))); + return spread <= 0 ? '0' : String(spread * 2 + 1); } function scheduleBookRebuild(reason = 'scheduled') { @@ -2045,17 +2051,17 @@ function ensureBottomNavigation() { const forwardButton = makeButton('webgl_book_nav_forward', appInitialState.t?.('webgl.forward') || 'Forward', '▶'); const endButton = makeButton('webgl_book_nav_end', appInitialState.t?.('webgl.goToEnd') || 'Go to end', '⏭'); - startButton.addEventListener('click', () => navigateToPagePosition(0)); - backButton.addEventListener('click', () => navigateByPageDelta(-1)); - forwardButton.addEventListener('click', () => navigateByPageDelta(1)); - endButton.addEventListener('click', () => navigateToPagePosition(maxVisitedPagePosition)); + startButton.addEventListener('click', () => navigateToSpread(0)); + backButton.addEventListener('click', () => navigateBySpreadDelta(-1)); + forwardButton.addEventListener('click', () => navigateBySpreadDelta(1)); + endButton.addEventListener('click', () => navigateToSpread(getMaxNavigableSpread())); slider.addEventListener('input', () => { const requested = Number(slider.value); - const clamped = Math.min(requested, maxVisitedPagePosition, getWritablePageLimit()); + const clamped = Math.min(requested, getMaxNavigableSpread()); if (requested !== clamped) slider.value = String(clamped); - pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${clamped}`; + pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${spreadPageLabel(clamped)}`; }); - slider.addEventListener('change', () => navigateToPagePosition(Number(slider.value))); + slider.addEventListener('change', () => navigateToSpread(Number(slider.value))); document.body.appendChild(root); bottomNavigation = { @@ -2072,27 +2078,33 @@ function ensureBottomNavigation() { return bottomNavigation; } -function navigateByPageDelta(delta) { - const current = getCurrentPagePosition(); - const next = Math.max(0, current + Math.sign(Number(delta || 0))); - return navigateToPagePosition(next); -} - -function navigateToPagePosition(pagePosition) { - const writableLimit = getWritablePageLimit(); - const targetPage = THREE.MathUtils.clamp(Math.round(Number(pagePosition || 0)), 0, Math.min(writableLimit, maxVisitedPagePosition)); - const currentPage = getCurrentPagePosition(); - if (targetPage === currentPage) { +function navigateToSpread(targetSpread) { + const maxSpread = getMaxNavigableSpread(); + const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread); + const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); + const spreadDelta = target - currentSpread; + if (spreadDelta === 0) { syncBookControls(); return false; } - const targetSpread = pageToSpreadIndex(targetPage); - const currentSpread = bookPaginationState.spreadIndex; - const spreadDelta = targetSpread - currentSpread; if (Math.abs(spreadDelta) === 1) { - return startPageFlip(Math.sign(spreadDelta), { targetSpread }); + return startPageFlip(Math.sign(spreadDelta), { targetSpread: target }); } - return startFastPageFlip(Math.sign(spreadDelta), { targetSpread, skippedSpreads: Math.abs(spreadDelta) }); + return startFastPageFlip(Math.sign(spreadDelta), { targetSpread: target, skippedSpreads: Math.abs(spreadDelta) }); +} + +function navigateBySpreadDelta(delta) { + const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); + return navigateToSpread(currentSpread + Math.sign(Number(delta || 0))); +} + +// Compatibility wrappers for the page-position-based external API (save/restore, debug). +function navigateToPagePosition(pagePosition) { + return navigateToSpread(pageToSpreadIndex(Math.max(0, Math.round(Number(pagePosition || 0))))); +} + +function navigateByPageDelta(delta) { + return navigateBySpreadDelta(delta); } function syncBookControls() { @@ -2110,24 +2122,25 @@ function syncBookControls() { function syncBottomNavigation() { if (!bottomNavigation) return; - const currentPage = getCurrentPagePosition(); - const writableLimit = getWritablePageLimit(); - const navigableLimit = getNavigablePageLimit(); - const reservedStart = Math.max(0, writableLimit); - bottomNavigation.slider.max = String(Math.max(0, bookPageCount)); - bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit)); + const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); + const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1))); + const maxSpread = getMaxNavigableSpread(); + const lastSpread = Math.max(0, spreadCount - 1); + const denominator = Math.max(1, lastSpread); + bottomNavigation.slider.max = String(lastSpread); + bottomNavigation.slider.value = String(Math.min(currentSpread, maxSpread)); bottomNavigation.minLabel.textContent = '0'; - 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 ? maxVisitedPagePosition / bookPageCount : 0}`); - bottomNavigation.root.style.setProperty('--book-nav-reserve-start', `${bookPageCount > 0 ? reservedStart / bookPageCount : 1}`); + bottomNavigation.maxLabel.textContent = spreadPageLabel(lastSpread); + bottomNavigation.pageLabel.textContent = `${appInitialState.t?.('webgl.page') || 'Page'} ${spreadPageLabel(currentSpread)}`; + bottomNavigation.root.style.setProperty('--book-nav-position', `${currentSpread / denominator}`); + bottomNavigation.root.style.setProperty('--book-nav-written', `${maxSpread / denominator}`); + bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1'); bottomNavigation.root.dataset.bookSize = String(bookPageCount); bottomNavigation.root.dataset.pageReserve = String(pageReserve); - bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentPage <= 0; - bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentPage <= 0; - bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; - bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentPage >= navigableLimit; + bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0; + bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0; + bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; + bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; } function handlePageTextureRecords(event) { @@ -2634,7 +2647,16 @@ function syncFlipRevealShaderFromSource(sourceSide, targetMaterial = materials.f const sourceState = pageRevealState[sourceSide]; const sourceShader = getPageRevealShader(sourceSide); const targetShader = targetMaterial.userData.bookRevealShader || null; - if (!sourceState || !sourceShader?.uniforms || !targetShader?.uniforms) return false; + if (!targetShader?.uniforms) return false; + if (!sourceState || !sourceShader?.uniforms) { + // The source page has no active reveal (finished content). Clear any stale reveal + // mask left on the flip surface by a previous playback flip, so the full page — + // including its last word — shows during the turn. + targetShader.uniforms.bookRevealActive.value = 0; + targetShader.uniforms.bookRevealRegionCount.value = 0; + if (targetShader.uniforms.bookRevealUseBaseMap) targetShader.uniforms.bookRevealUseBaseMap.value = 0; + return true; + } const sourceUniforms = sourceShader.uniforms; const targetUniforms = targetShader.uniforms; targetUniforms.bookRevealActive.value = sourceUniforms.bookRevealActive?.value || 0; @@ -3159,9 +3181,9 @@ function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) { function canPageFlip(direction) { if (!currentProceduralBookModel) return false; - const currentPage = getCurrentPagePosition(); - if (direction > 0) return currentPage < getNavigablePageLimit(); - return currentPage > 0; + const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); + if (direction > 0) return currentSpread < getMaxNavigableSpread(); + return currentSpread > 0; } function isChoiceAwaitingPlayer() { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 119c72b..9683014 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -240,7 +240,7 @@ const checks = [ ['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 cap at visited page and written content limit', /maxVisitedPagePosition/.test(source) && /navigateToPagePosition\(maxVisitedPagePosition\)/.test(source) && /function getNavigablePageLimit\(\)/.test(source) && /const navigableLimit = getNavigablePageLimit\(\)/.test(source) && /Math\.min\(maxVisitedPagePosition, getWritablePageLimit\(\), contentNavigable\)/.test(source) && !/navigateToPagePosition\(bookPaginationState\.writtenPageLimit\)/.test(source)], + ['webgl navigation is spread-based and caps at visited/written spread', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, spreadCount - 1\)/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /spread <= 0 \? '0' : String\(spread \* 2 \+ 1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.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 nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)], ['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],