diff --git a/public/js/book-page-format-module.js b/public/js/book-page-format-module.js new file mode 100644 index 0000000..17cf11c --- /dev/null +++ b/public/js/book-page-format-module.js @@ -0,0 +1,90 @@ +/** + * Book Page Format Module + * Defines the canonical page geometry used by the WebGL book renderer. + */ +import { BaseModule } from './base-module.js'; + +class BookPageFormatModule extends BaseModule { + constructor() { + super('book-page-format', 'Book Page Format'); + this.dependencies = []; + this.format = Object.freeze({ + id: 'us-mass-market-hardcover', + trim: Object.freeze({ + widthIn: 4.25, + heightIn: 6.375 + }), + margins: Object.freeze({ + topIn: 0.46, + bottomIn: 0.58, + innerIn: 0.68, + outerIn: 0.46 + }), + typography: Object.freeze({ + fontFamily: '"EB Garamond", "EB Garamond 12", serif', + bodyFontSizePt: 10.8, + lineHeightPt: 14.9, + headingFontSizePt: 13.2, + dropCapLines: 2 + }) + }); + + this.bindMethods([ + 'getFormat', + 'getAspectRatio', + 'getTextureMetrics', + 'inchesToTexture' + ]); + } + + async initialize() { + this.reportProgress(100, 'Book page format ready'); + return true; + } + + getFormat() { + return this.format; + } + + getAspectRatio() { + return this.format.trim.widthIn / this.format.trim.heightIn; + } + + inchesToTexture(valueIn, textureHeight) { + return (Number(valueIn) / this.format.trim.heightIn) * textureHeight; + } + + getTextureMetrics(textureWidth = 1280) { + const width = Math.max(1, Math.round(Number(textureWidth) || 1280)); + const height = Math.round(width / this.getAspectRatio()); + const margins = { + top: this.inchesToTexture(this.format.margins.topIn, height), + bottom: this.inchesToTexture(this.format.margins.bottomIn, height), + inner: this.inchesToTexture(this.format.margins.innerIn, height), + outer: this.inchesToTexture(this.format.margins.outerIn, height) + }; + return { + width, + height, + aspectRatio: this.getAspectRatio(), + margins, + content: { + x: margins.outer, + y: margins.top, + width: Math.max(1, width - margins.outer - margins.inner), + height: Math.max(1, height - margins.top - margins.bottom) + }, + typography: this.format.typography + }; + } +} + +const bookPageFormat = new BookPageFormatModule(); + +export { bookPageFormat as BookPageFormat }; + +if (window.moduleRegistry) { + window.moduleRegistry.register(bookPageFormat); +} + +window.BookPageFormat = bookPageFormat; diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js new file mode 100644 index 0000000..f321fcb --- /dev/null +++ b/public/js/book-texture-renderer-module.js @@ -0,0 +1,139 @@ +/** + * Book Texture Renderer Module + * Draws the virtual book pages directly into texture-space canvases. + */ +import { BaseModule } from './base-module.js'; + +class BookTextureRendererModule extends BaseModule { + constructor() { + super('book-texture-renderer', 'Book Texture Renderer'); + this.dependencies = ['book-page-format', 'localization']; + this.pageFormat = null; + this.localization = null; + this.metrics = null; + this.canvases = { + left: null, + right: null + }; + this.contexts = { + left: null, + right: null + }; + this.hitMaps = { + left: [], + right: [] + }; + + this.bindMethods([ + 'initialize', + 'createPageCanvases', + 'drawEmptySpread', + 'drawPageBase', + 'drawDebugText', + 'publishSpread', + 'getPageCanvas', + 'getHitMap', + 'handleSceneReady' + ]); + } + + async initialize() { + this.pageFormat = this.getModule('book-page-format'); + this.localization = this.getModule('localization'); + this.reportProgress(20, 'Preparing page texture canvases'); + this.createPageCanvases(); + this.drawEmptySpread(); + this.addEventListener(document, 'webgl-book:scene-ready', this.handleSceneReady); + this.reportProgress(100, 'Book texture renderer ready'); + return true; + } + + createPageCanvases(textureWidth = 1280) { + this.metrics = this.pageFormat.getTextureMetrics(textureWidth); + ['left', 'right'].forEach((side) => { + const canvas = document.createElement('canvas'); + canvas.width = this.metrics.width; + canvas.height = this.metrics.height; + this.canvases[side] = canvas; + this.contexts[side] = canvas.getContext('2d'); + }); + } + + drawEmptySpread() { + this.drawPageBase('left'); + this.drawPageBase('right'); + this.drawDebugText('right', 'Book canvas renderer ready'); + this.publishSpread(); + } + + drawPageBase(side) { + const canvas = this.canvases[side]; + const ctx = this.contexts[side]; + if (!canvas || !ctx) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = '#fff7dc'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + const shade = ctx.createLinearGradient(0, 0, canvas.width, 0); + if (side === 'left') { + shade.addColorStop(0, 'rgba(255, 255, 255, 0.10)'); + shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)'); + shade.addColorStop(1, 'rgba(82, 42, 14, 0.16)'); + } else { + shade.addColorStop(0, 'rgba(82, 42, 14, 0.16)'); + shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)'); + shade.addColorStop(1, 'rgba(255, 255, 255, 0.10)'); + } + ctx.fillStyle = shade; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + this.hitMaps[side] = []; + } + + drawDebugText(side, text) { + const ctx = this.contexts[side]; + const metrics = this.metrics; + if (!ctx || !metrics) return; + + ctx.save(); + ctx.fillStyle = 'rgba(31, 19, 10, 0.82)'; + ctx.font = `${Math.round(metrics.typography.bodyFontSizePt * 1.55)}px ${metrics.typography.fontFamily}`; + ctx.textBaseline = 'alphabetic'; + ctx.fillText(String(text || ''), metrics.content.x, metrics.content.y + 44); + ctx.restore(); + } + + publishSpread() { + document.dispatchEvent(new CustomEvent('webgl-book:page-canvases', { + detail: { + left: this.canvases.left, + right: this.canvases.right, + metrics: this.metrics, + hitMaps: this.hitMaps + } + })); + } + + getPageCanvas(side) { + return this.canvases[side] || null; + } + + getHitMap(side) { + return this.hitMaps[side] || []; + } + + handleSceneReady() { + this.publishSpread(); + } +} + +const bookTextureRenderer = new BookTextureRendererModule(); + +export { bookTextureRenderer as BookTextureRenderer }; + +if (window.moduleRegistry) { + window.moduleRegistry.register(bookTextureRenderer); +} + +window.BookTextureRenderer = bookTextureRenderer; diff --git a/public/js/loader.js b/public/js/loader.js index 5fccb04..1b40822 100644 --- a/public/js/loader.js +++ b/public/js/loader.js @@ -24,7 +24,7 @@ const ModuleState = { ERROR: 'ERROR' }; -const MODULE_CACHE_BUSTER = '20260606-webgl-direct-page-crop-coords'; +const MODULE_CACHE_BUSTER = '20260606-webgl-texture-renderer-foundation'; window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER; /** @@ -113,6 +113,8 @@ const ModuleLoader = (function() { { id: 'paragraph-layout', script: '/js/paragraph-layout-module.js', weight: 17 }, { id: 'sentence-queue', script: '/js/sentence-queue-module.js', weight: 12 }, { id: 'layout-renderer', script: '/js/layout-renderer-module.js', weight: 13 }, // Add Layout Renderer module + { id: 'book-page-format', script: '/js/book-page-format-module.js', weight: 4 }, + { id: 'book-texture-renderer', script: '/js/book-texture-renderer-module.js', weight: 6 }, { id: 'webgl-book-scene', script: '/js/webgl-book-scene-module.js', weight: 13 }, { id: 'animation-queue', script: '/js/animation-queue-module.js', weight: 12 }, { id: 'playback-coordinator', script: '/js/playback-coordinator-module.js', weight: 8 }, // Synchronizes animation + TTS diff --git a/public/js/procedural-book-model.js b/public/js/procedural-book-model.js index 99a50c4..aaa6235 100644 --- a/public/js/procedural-book-model.js +++ b/public/js/procedural-book-model.js @@ -6,7 +6,7 @@ export const PROCEDURAL_BOOK = { PAGE_COUNT_STEP: 10, PAGE_LINE_SEGMENTS: 48, PAGE_DEPTH: 2.24, - PAGE_WIDTH: 2.24 * 0.806, + PAGE_WIDTH: 2.24 * 2 / 3, COVER_DEPTH: 2.30, OPEN_SEAM_GAP: 0.003, PROFILE: { diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 2b539d3..f075494 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=20260606-webgl-no-menu-offscreen-dom'; +import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260606-book-page-format-restore'; const canvas = document.getElementById('scene'); canvas.style.cursor = 'grab'; @@ -25,9 +25,6 @@ const appInitialState = window.WebGLBookInitialState || {}; const tableDebugName = urlParams.get('tableDebug') || 'none'; const tableDebugMode = tableDebugModes[tableDebugName] ?? tableDebugModes.none; const isAppIntegrationMode = appInitialState.appMode === true; -const html2CanvasPromise = isAppIntegrationMode - ? import('https://esm.sh/html2canvas@1.4.1') - : null; const labStatus = document.getElementById('lab_status'); if (labStatus && tableDebugMode !== tableDebugModes.none) { labStatus.textContent = tableDebugName === 'ao' ? 'scene debug: SSAO' : `table debug: ${tableDebugName}`; @@ -44,13 +41,9 @@ const generatedTextureCanvases = {}; const maxTextureAnisotropy = renderer.capabilities.getMaxAnisotropy(); const reflectionPixelRatio = Math.min(window.devicePixelRatio || 1, 2); const pageTextureWidth = isAppIntegrationMode ? 1280 : 3200; -const appPageTextureInset = 0; const reflectionTargetSize = new THREE.Vector2(); const pageRaycaster = new THREE.Raycaster(); const pointerNdc = new THREE.Vector2(); -let pageTextureRenderSerial = 0; -let pageTextureRenderInProgress = false; -let pageTextureRenderPending = false; let sceneComposerTarget = null; let composer = null; let sceneRenderPass = null; @@ -402,14 +395,13 @@ window.BookLabDebug = { return bookPageCount; }, redrawPageTextures() { - redrawPageTexturesFromDom(); + window.BookTextureRenderer?.publishSpread?.(); return true; }, getTextureInfo() { return { pageTextureWidth, pageTextureHeight: leftCanvas.height, - appPageTextureInset, debug: getPageTextureDebugState() }; }, @@ -424,10 +416,11 @@ window.BookLabDebug = { }; window.addEventListener('resize', resize); -document.addEventListener('webgl-book:redraw-pages', redrawPageTexturesFromDom); +document.addEventListener('webgl-book:page-canvases', handlePageCanvases); installBookControls(); installCameraControls(); resize(); +document.dispatchEvent(new CustomEvent('webgl-book:scene-ready')); animate(); function buildTable() { @@ -1444,35 +1437,24 @@ function syncBookControls() { if (fastForwardButton) fastForwardButton.disabled = busy || !canPageFlip(1); } -function redrawPageTexturesFromDom() { - if (pageTextureRenderInProgress) { - pageTextureRenderPending = true; - return; +function handlePageCanvases(event) { + const detail = event.detail || {}; + if (detail.left) { + drawCanvasPageTexture(leftCanvas, detail.left, 'left'); + leftTexture.needsUpdate = true; } - const leftSource = document.getElementById('page_left'); - const rightSource = document.getElementById('page_right'); - if (!leftSource && !rightSource) return; - pageTextureRenderInProgress = true; - const serial = ++pageTextureRenderSerial; - (async () => { - try { - if (leftSource && await drawDomPageTexture(leftCanvas, leftSource, 'left')) { - leftTexture.needsUpdate = true; - } - if (rightSource && await drawDomPageTexture(rightCanvas, rightSource, 'right')) { - rightTexture.needsUpdate = true; - } - } finally { - pageTextureRenderInProgress = false; - if (pageTextureRenderPending && serial === pageTextureRenderSerial) { - pageTextureRenderPending = false; - redrawPageTexturesFromDom(); - } - } - })(); + if (detail.right) { + drawCanvasPageTexture(rightCanvas, detail.right, 'right'); + rightTexture.needsUpdate = true; + } + document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({ + width: leftCanvas.width, + height: leftCanvas.height, + source: 'book-texture-renderer' + }); } -async function drawDomPageTexture(canvas, source, side) { +function drawCanvasPageTexture(canvas, sourceCanvas, side) { const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fffaf0'; ctx.fillRect(0, 0, canvas.width, canvas.height); @@ -1484,9 +1466,9 @@ async function drawDomPageTexture(canvas, source, side) { ctx.fillStyle = shade; ctx.fillRect(0, 0, canvas.width, canvas.height); - const painted = await paintRasterizedDomPage(ctx, canvas, source); - updatePageTextureDebugState(side, canvas, source, painted); - return painted; + ctx.drawImage(sourceCanvas, 0, 0, canvas.width, canvas.height); + updatePageTextureDebugState(side, canvas, sourceCanvas, true); + return true; } function getPageTextureDebugState() { @@ -1505,8 +1487,8 @@ function updatePageTextureDebugState(side, canvas, source, painted) { painted, width: canvas.width, height: canvas.height, - sourceId: source.id || '', - sourceTextLength: source.textContent?.trim().length || 0, + sourceId: source?.id || 'book-texture-renderer', + sourceTextLength: 0, darkPixels: countPageTextureDarkPixels(canvas) }; document.documentElement.dataset.webglPageTextures = JSON.stringify(state); @@ -1530,56 +1512,6 @@ function countPageTextureDarkPixels(canvas) { return darkPixels; } -async function paintRasterizedDomPage(ctx, canvas, source) { - const pageRect = source.getBoundingClientRect(); - if (pageRect.width <= 0 || pageRect.height <= 0) return false; - const captured = await captureDomPageWithHtml2Canvas(source, pageRect, canvas); - if (captured) { - drawCapturedPageCanvas(ctx, canvas, captured); - return true; - } - return false; -} - -async function captureDomPageWithHtml2Canvas(source, pageRect, targetCanvas) { - if (!html2CanvasPromise) return null; - try { - const module = await html2CanvasPromise; - const html2canvas = module.default || module; - return await html2canvas(source, { - backgroundColor: null, - logging: false, - useCORS: true, - allowTaint: false, - foreignObjectRendering: true, - x: pageRect.left, - y: pageRect.top, - width: pageRect.width, - height: pageRect.height, - scrollX: 0, - scrollY: 0, - windowWidth: Math.ceil(Math.max(window.innerWidth, pageRect.right)), - windowHeight: Math.ceil(Math.max(window.innerHeight, pageRect.bottom)), - scale: Math.max(1, targetCanvas.width / pageRect.width) - }); - } catch (error) { - document.documentElement.dataset.webglLastCaptureError = error?.message || String(error); - return null; - } -} - -function drawCapturedPageCanvas(ctx, canvas, captured) { - const insetX = canvas.width * appPageTextureInset; - const insetY = canvas.height * appPageTextureInset * 0.35; - ctx.drawImage( - captured, - insetX, - insetY, - canvas.width - insetX * 2, - canvas.height - insetY * 2 - ); -} - function projectPointerToPage(clientX, clientY) { const rect = canvas.getBoundingClientRect(); if (rect.width <= 0 || rect.height <= 0) return null; @@ -1592,10 +1524,8 @@ function projectPointerToPage(clientX, clientY) { for (const hit of intersections) { const pageSide = textureHitPageSide(hit); if (!pageSide || !hit.uv) continue; - const insetX = appPageTextureInset; - const insetY = appPageTextureInset * 0.35; - const mappedX = THREE.MathUtils.clamp((hit.uv.x - insetX) / Math.max(0.001, 1 - insetX * 2), 0, 1); - const mappedY = 1 - THREE.MathUtils.clamp((hit.uv.y - insetY) / Math.max(0.001, 1 - insetY * 2), 0, 1); + const mappedX = THREE.MathUtils.clamp(hit.uv.x, 0, 1); + const mappedY = 1 - THREE.MathUtils.clamp(hit.uv.y, 0, 1); return { pageId: pageSide === 'left' ? 'page_left' : 'page_right', x: mappedX, diff --git a/public/js/webgl-book-scene-module.js b/public/js/webgl-book-scene-module.js index 27c391f..01b9cef 100644 --- a/public/js/webgl-book-scene-module.js +++ b/public/js/webgl-book-scene-module.js @@ -439,7 +439,6 @@ class WebGLBookSceneModule extends BaseModule { triggerTextureRefresh() { clearTimeout(this.textureRefreshTimer); this.textureRefreshTimer = setTimeout(() => { - document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages')); window.BookLabDebug?.redrawPageTextures?.(); }, 60); } @@ -463,7 +462,6 @@ class WebGLBookSceneModule extends BaseModule { } if (now - this.lastAnimatedTextureRefresh > 100) { this.lastAnimatedTextureRefresh = now; - document.dispatchEvent(new CustomEvent('webgl-book:redraw-pages')); window.BookLabDebug?.redrawPageTextures?.(); } this.textureRefreshAnimationId = window.requestAnimationFrame(tick);