Rework WebGL book playback to single ownership and fix flip/reveal pipeline
Establish book-playback-timeline as the sole playback owner driving the scene through formal webgl-book:* events (not the BookLabDebug surface), with a single reveal clock in the scene render loop and webgl-page-cache as the only texture cache. Remove the legacy dual playback path and the ownsPageFlipCommit gating. Fixes: - Flip page detached/folded at the spine: restore the raw page-cap line for flip geometry (matches the prototype/pre-regression), removing normalizeFlipLineToVisiblePage which moved the pivot off the spine arc. - Flip textures: distance-based UVs (no horizontal compression), direction-aware face material (source on the camera-facing side), source meta derived from the visible spread (manual flips), prewarm shape fix. - Reveal: flash removed on the static page and the flip back surface; spanning blocks rebuild the reveal plan at activate and continue the reveal on the next spread after the fill flip. - Cache staleness is contentVersion-primary; nav clamps to spreadCount. Docs updated to describe the intended single-owner architecture. Regression checks updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+118
-115
@@ -275,8 +275,6 @@ let currentPageMeta = {
|
||||
left: null,
|
||||
right: null
|
||||
};
|
||||
let pendingRightPageFlip = false;
|
||||
let pendingRightPageFlipAutoplay = false;
|
||||
const pageRevealState = {
|
||||
left: null,
|
||||
right: null
|
||||
@@ -655,6 +653,24 @@ window.BookLabDebug = {
|
||||
}
|
||||
};
|
||||
|
||||
// Publish the visible spread as a production accessor on the scene module so the
|
||||
// playback owner can read it without touching the debug surface (window.BookLabDebug).
|
||||
const webglBookSceneModule = window.moduleRegistry?.getModule?.('webgl-book-scene') || null;
|
||||
if (webglBookSceneModule) {
|
||||
webglBookSceneModule.getVisibleSpreadIndex = () => Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||
// Production control surface for the scene host (webgl-book-scene) and save/restore.
|
||||
// window.BookLabDebug remains a debug/inspection-only alias; production code uses this.
|
||||
webglBookSceneModule.sceneControl = {
|
||||
getBookState: () => window.BookLabDebug.getBookState(),
|
||||
setReadingProgress: (value) => setReadingProgress(value),
|
||||
setBookPageCount: (value) => setBookPageCount(value),
|
||||
setPageReserve: (value) => setPageReserve(value),
|
||||
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
|
||||
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
|
||||
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY)
|
||||
};
|
||||
}
|
||||
|
||||
window.addEventListener('resize', resize);
|
||||
document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
|
||||
document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
||||
@@ -663,27 +679,38 @@ document.addEventListener('webgl-book:page-reveal-start', (event) => {
|
||||
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
||||
fastForwardPageReveals(event.detail?.blockIds || []);
|
||||
});
|
||||
document.addEventListener('webgl-book:reveal-committed', (event) => {
|
||||
handleRevealCommittedForPageFlip(event.detail || {});
|
||||
document.addEventListener('webgl-book:request-page-flip', (event) => {
|
||||
const detail = event.detail || {};
|
||||
const direction = Number(detail.direction) || (detail.targetSpread > bookPaginationState.spreadIndex ? 1 : -1);
|
||||
// Let the scene own flip prewarming via prewarmFlipTextures (which draws and
|
||||
// makes resident the current + target spreads). The owner's cache-warming plan
|
||||
// is a different shape and must not be passed through as the flip prewarm.
|
||||
startPageFlip(direction, {
|
||||
force: detail.force === true,
|
||||
reason: detail.reason,
|
||||
targetSpread: detail.targetSpread,
|
||||
deferRevealSides: Array.isArray(detail.revealSides) ? detail.revealSides : null
|
||||
});
|
||||
});
|
||||
document.addEventListener('webgl-book:page-cache-problem', (event) => {
|
||||
pageTextureStore?.recordProblem?.(event.detail || {});
|
||||
});
|
||||
// Pagination spread updates only carry state. The playback owner decides when the
|
||||
// visible spread changes (via flips). The scene jumps directly only for non-playback
|
||||
// commits such as history restore. See docs/webgl-3d-ui-spec.md "Single ownership".
|
||||
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 (
|
||||
window.BookPlaybackTimeline?.ownsPageFlipCommit === true
|
||||
&& detail.visibility !== 'future-ready'
|
||||
&& latestBlockId > 0
|
||||
) {
|
||||
markPageTextureTiming('spreadUpdate:timeline-owned-state-only', {
|
||||
const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||
const stateOnly = playbackActive
|
||||
|| activeFlips.length > 0
|
||||
|| detail.visibility === 'future-ready';
|
||||
if (stateOnly) {
|
||||
markPageTextureTiming('spreadUpdate:state-only', {
|
||||
incomingSpreadIndex,
|
||||
visibleSpreadIndex: bookPaginationState.spreadIndex,
|
||||
latestBlockId,
|
||||
latestRenderedBlockId
|
||||
visibility: detail.visibility || 'current',
|
||||
playbackActive
|
||||
});
|
||||
bookPaginationState = {
|
||||
...bookPaginationState,
|
||||
@@ -695,23 +722,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
|
||||
};
|
||||
growBookIfWritableLimitReached();
|
||||
syncBookControls();
|
||||
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
|
||||
return;
|
||||
}
|
||||
if (
|
||||
latestBlockId > latestRenderedBlockId
|
||||
&& detail.visibility !== 'future-ready'
|
||||
&& activeFlips.length === 0
|
||||
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
|
||||
) {
|
||||
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
|
||||
incomingSpreadIndex,
|
||||
visibleSpreadIndex: bookPaginationState.spreadIndex,
|
||||
latestBlockId,
|
||||
latestRenderedBlockId
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Non-playback committed update (history restore, continuation reload): jump
|
||||
// directly to the committed spread and paint it.
|
||||
const previousPageCount = bookPageCount;
|
||||
bookPaginationState = {
|
||||
spreadIndex: incomingSpreadIndex,
|
||||
@@ -724,8 +738,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
|
||||
notifyBookPageCountChanged();
|
||||
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
|
||||
}
|
||||
const spread = detail.spread || getPaginationSpread(incomingSpreadIndex);
|
||||
if (spread) window.BookTextureRenderer?.drawSpread?.(spread, ['left', 'right'], { force: true });
|
||||
syncBookControls();
|
||||
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
|
||||
markPageTextureTiming('spreadUpdate:jump', { incomingSpreadIndex });
|
||||
});
|
||||
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
||||
const detail = event.detail || {};
|
||||
@@ -736,11 +752,6 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
||||
: Math.round(value);
|
||||
setPageReserve(nextReserve);
|
||||
});
|
||||
document.addEventListener('ui:command', (event) => {
|
||||
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
|
||||
tryStartPendingRightPageFlip('continue', { force: true });
|
||||
}
|
||||
});
|
||||
installBookControls();
|
||||
installCameraControls();
|
||||
resize();
|
||||
@@ -1881,6 +1892,17 @@ 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);
|
||||
}
|
||||
|
||||
function scheduleBookRebuild(reason = 'scheduled') {
|
||||
if (scheduledBookRebuildFrame !== null) return;
|
||||
const scheduler = typeof window.requestIdleCallback === 'function'
|
||||
@@ -2090,7 +2112,7 @@ function syncBottomNavigation() {
|
||||
if (!bottomNavigation) return;
|
||||
const currentPage = getCurrentPagePosition();
|
||||
const writableLimit = getWritablePageLimit();
|
||||
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit);
|
||||
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));
|
||||
@@ -2938,12 +2960,10 @@ function startPageFlipPrepared(direction, options = {}) {
|
||||
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
||||
if (!flip) return false;
|
||||
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
|
||||
flip.deferRevealSides = Array.isArray(options.deferRevealSides) ? options.deferRevealSides : null;
|
||||
if (!prepareStaticPageForFlip(flip, options.prewarm || null)) {
|
||||
return false;
|
||||
}
|
||||
pendingRightPageFlip = false;
|
||||
pendingRightPageFlipAutoplay = false;
|
||||
delete document.documentElement.dataset.webglPendingPageFlip;
|
||||
activeFlips.push(flip);
|
||||
setPageFlipActiveFlag();
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
|
||||
@@ -3007,8 +3027,12 @@ function startFastPageFlipPrepared(direction, options = {}) {
|
||||
function createPageFlip(direction, startTime, duration) {
|
||||
const sourceSide = direction > 0 ? 1 : -1;
|
||||
const sourcePageSide = direction > 0 ? 'right' : 'left';
|
||||
const sourceLine = normalizeFlipLineToVisiblePage(topVisibleLine(sourceSide), sourceSide);
|
||||
const destinationLine = normalizeFlipLineToVisiblePage(topVisibleLine(-sourceSide), -sourceSide);
|
||||
// Use the raw page-cap line (as the working prototype / pre-ef358c5 lab did). Each
|
||||
// line's points[0] === its spine-arc anchor (spineCurvePoint(t)), so the flip sheet
|
||||
// hinges at the spine. Rewriting the line to the "visible page width" moved the pivot
|
||||
// off the spine arc and folded the inner spine-wall climb into a crease at the spine.
|
||||
const sourceLine = topVisibleLine(sourceSide);
|
||||
const destinationLine = topVisibleLine(-sourceSide);
|
||||
if (!sourceLine || !destinationLine) return null;
|
||||
return {
|
||||
direction,
|
||||
@@ -3024,34 +3048,11 @@ 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';
|
||||
const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
|
||||
const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || null;
|
||||
const sourcePageMeta = getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || currentPageMeta?.[sourceSide] || null;
|
||||
const targetSpread = Number.isFinite(Number(flip.targetSpread))
|
||||
? Math.max(0, Math.round(Number(flip.targetSpread)))
|
||||
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
|
||||
@@ -3077,10 +3078,15 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
||||
});
|
||||
return false;
|
||||
}
|
||||
// If the page the flip lands on will be revealed right after (a block reveals on
|
||||
// that side), do not show its full text on the turning page's back face — that
|
||||
// flashes the not-yet-revealed content. Show blank during the turn; the masked
|
||||
// reveal lands on the static page once the flip finishes.
|
||||
const backDeferred = Array.isArray(flip.deferRevealSides) && flip.deferRevealSides.includes(targetBackSide);
|
||||
materials.flipPageSurface.map = sourceTexture;
|
||||
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
|
||||
materials.flipPageBackSurface.map = backDeferred ? getBlankPageTexture() : (backTexture || getBlankPageTexture());
|
||||
materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null;
|
||||
materials.flipPageBackSurface.userData.sourceRevealSide = revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null;
|
||||
materials.flipPageBackSurface.userData.sourceRevealSide = backDeferred ? null : (revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null);
|
||||
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
||||
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
|
||||
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||
@@ -3131,7 +3137,11 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
||||
}
|
||||
|
||||
function resolveCurrentFlipSourceTexture(side) {
|
||||
const pageMeta = currentPageMeta?.[side] || null;
|
||||
// Derive the source page meta from the actually-visible spread. currentPageMeta is
|
||||
// only refreshed by the activate pipeline, so it is stale after manual navigation —
|
||||
// using it here resolved the wrong source texture for the next flip.
|
||||
const visiblePageIndex = spreadPageIndices(bookPaginationState.spreadIndex)[side];
|
||||
const pageMeta = getPaginationPageMeta(visiblePageIndex) || currentPageMeta?.[side] || null;
|
||||
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
|
||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||
if (revealStateMatchesPage(side, pageMeta)) return material?.map || null;
|
||||
@@ -3149,53 +3159,16 @@ function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
|
||||
function canPageFlip(direction) {
|
||||
if (!currentProceduralBookModel) return false;
|
||||
const currentPage = getCurrentPagePosition();
|
||||
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit());
|
||||
if (direction > 0) return currentPage < maxNavigablePage;
|
||||
if (direction > 0) return currentPage < getNavigablePageLimit();
|
||||
return currentPage > 0;
|
||||
}
|
||||
|
||||
function handleRevealCommittedForPageFlip(detail = {}) {
|
||||
if (window.BookPlaybackTimeline?.ownsPageFlipCommit === true) return;
|
||||
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
|
||||
if (activeFlips.length > 0 || pendingRightPageFlip) return;
|
||||
if (isChoiceAwaitingPlayer()) return;
|
||||
const autoplayFlip = isTtsPlaybackActive();
|
||||
pendingRightPageFlip = true;
|
||||
pendingRightPageFlipAutoplay = autoplayFlip;
|
||||
document.documentElement.dataset.webglPendingPageFlip = 'right';
|
||||
if (autoplayFlip) {
|
||||
tryStartPendingRightPageFlip('tts-active');
|
||||
}
|
||||
}
|
||||
|
||||
async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
|
||||
if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false;
|
||||
if (!options.force && !pendingRightPageFlipAutoplay) return false;
|
||||
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
|
||||
const flipped = await startPageFlip(1, {
|
||||
force: options.force === true || pendingRightPageFlipAutoplay,
|
||||
reason,
|
||||
targetSpread
|
||||
});
|
||||
if (flipped) {
|
||||
pendingRightPageFlip = false;
|
||||
pendingRightPageFlipAutoplay = false;
|
||||
delete document.documentElement.dataset.webglPendingPageFlip;
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
function isChoiceAwaitingPlayer() {
|
||||
return document.documentElement.dataset.choiceAwaiting === 'true'
|
||||
|| document.body?.dataset?.choiceAwaiting === 'true'
|
||||
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
|
||||
}
|
||||
|
||||
function isTtsPlaybackActive() {
|
||||
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
|
||||
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
|
||||
}
|
||||
|
||||
function topVisibleLine(side) {
|
||||
const sideLines = currentProceduralBookModel.lines
|
||||
.filter((line) => line.side === side)
|
||||
@@ -3205,9 +3178,15 @@ function topVisibleLine(side) {
|
||||
|
||||
function updateActiveFlips(now) {
|
||||
if (!activeFlips.length || !currentProceduralBookModel) return;
|
||||
// Debug/isolation hook: when window.__debugFlipFreezeT is a finite number in [0,1],
|
||||
// hold every active flip at that progress so a single frame can be inspected.
|
||||
const freezeT = Number(window.__debugFlipFreezeT);
|
||||
const frozen = Number.isFinite(freezeT);
|
||||
const completed = [];
|
||||
activeFlips.forEach((flip) => {
|
||||
const elapsed = (now - flip.startTime) / flip.duration;
|
||||
const elapsed = frozen
|
||||
? THREE.MathUtils.clamp(freezeT, 0, 1)
|
||||
: (now - flip.startTime) / flip.duration;
|
||||
if (elapsed < 0) return;
|
||||
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
||||
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
|
||||
@@ -3218,7 +3197,10 @@ function updateActiveFlips(now) {
|
||||
? Math.max(0, Math.round(Number(flip.targetSpread)))
|
||||
: null;
|
||||
if (targetSpread !== null && !hasActivePageReveal()) {
|
||||
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end');
|
||||
// Skip the revealing side(s): the timeline's activate lands the masked
|
||||
// reveal for them right after the flip. Showing the full resident texture
|
||||
// here would flash the not-yet-revealed block.
|
||||
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end', { skipSides: flip.deferRevealSides });
|
||||
}
|
||||
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
|
||||
detail: {
|
||||
@@ -3242,9 +3224,11 @@ function hasActivePageReveal() {
|
||||
});
|
||||
}
|
||||
|
||||
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread') {
|
||||
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
|
||||
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
|
||||
const pageIndices = spreadPageIndices(spreadIndex);
|
||||
['left', 'right'].forEach((side) => {
|
||||
if (skipSides.includes(side)) return;
|
||||
const pageIndex = pageIndices[side];
|
||||
const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex);
|
||||
const texture = pageMeta.kind === 'blank'
|
||||
@@ -3389,8 +3373,9 @@ function lineYAtX(points, x) {
|
||||
}
|
||||
|
||||
function setActivePageGeometry(flip, surface) {
|
||||
const widthRatios = flipWidthRatios(flip.sourceLine?.points);
|
||||
if (!flip.mesh) {
|
||||
const geometry = createFlippingPageGeometry(surface, flip.direction);
|
||||
const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
|
||||
flip.mesh = new THREE.Mesh(geometry, [
|
||||
materials.flipPageSurface,
|
||||
materials.flipPageBackSurface,
|
||||
@@ -3404,13 +3389,25 @@ function setActivePageGeometry(flip, surface) {
|
||||
return;
|
||||
}
|
||||
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
|
||||
const geometry = createFlippingPageGeometry(surface, flip.direction);
|
||||
const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
|
||||
flip.mesh.geometry.dispose();
|
||||
flip.mesh.geometry = geometry;
|
||||
}
|
||||
}
|
||||
|
||||
function createFlippingPageGeometry(surface, direction = 1) {
|
||||
// Texture U coordinates must follow physical page width (the spline uses short
|
||||
// segments near the spine and long segments near the fore-edge), not the uniform
|
||||
// vertex index, otherwise the flip texture is horizontally compressed relative to
|
||||
// the static stack cap.
|
||||
function flipWidthRatios(points) {
|
||||
if (!Array.isArray(points) || points.length < 2) return null;
|
||||
const lengths = cumulativeLineLengths(points);
|
||||
const total = lengths[lengths.length - 1];
|
||||
if (!(total > 0)) return null;
|
||||
return lengths.map(length => length / total);
|
||||
}
|
||||
|
||||
function createFlippingPageGeometry(surface, direction = 1, widthRatios = null) {
|
||||
const positions = [];
|
||||
const uvs = [];
|
||||
const indices = [];
|
||||
@@ -3426,8 +3423,12 @@ function createFlippingPageGeometry(surface, direction = 1) {
|
||||
const targetSide = -sourceSide;
|
||||
const topPageSide = direction > 0 ? targetSide : sourceSide;
|
||||
const bottomPageSide = direction > 0 ? sourceSide : targetSide;
|
||||
const topMaterialIndex = 0;
|
||||
const bottomMaterialIndex = 1;
|
||||
// The page's width index runs spine->right for forward flips and spine->left for
|
||||
// backward flips, which inverts the computed face normals. Assign the source
|
||||
// texture to the face that actually points at the camera at the start of the turn
|
||||
// so the lifting page shows the page it came from (not the page it lands on).
|
||||
const topMaterialIndex = direction > 0 ? 1 : 0;
|
||||
const bottomMaterialIndex = direction > 0 ? 0 : 1;
|
||||
const push = (point, yOffset, uv) => {
|
||||
const index = positions.length / 3;
|
||||
positions.push(point.x, point.y + yOffset, point.z);
|
||||
@@ -3438,7 +3439,9 @@ function createFlippingPageGeometry(surface, direction = 1) {
|
||||
surface.forEach((rowPoints, widthIndex) => {
|
||||
const topRow = [];
|
||||
const bottomRow = [];
|
||||
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
|
||||
const u = Array.isArray(widthRatios) && widthRatios.length === surface.length
|
||||
? widthRatios[widthIndex]
|
||||
: (widthSegments <= 0 ? 0 : widthIndex / widthSegments);
|
||||
rowPoints.forEach((point, depthIndex) => {
|
||||
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
||||
topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v)));
|
||||
|
||||
Reference in New Issue
Block a user