Checkpoint page flip surface
This commit is contained in:
@@ -6,6 +6,9 @@ const progressInput = document.getElementById('progress');
|
|||||||
const progressValue = document.getElementById('progress_value');
|
const progressValue = document.getElementById('progress_value');
|
||||||
const pageCountInput = document.getElementById('page_count');
|
const pageCountInput = document.getElementById('page_count');
|
||||||
const pageCountValue = document.getElementById('page_count_value');
|
const pageCountValue = document.getElementById('page_count_value');
|
||||||
|
const flipBackwardButton = document.getElementById('flip_backward');
|
||||||
|
const flipForwardButton = document.getElementById('flip_forward');
|
||||||
|
const flipCountValue = document.getElementById('flip_count');
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
|
||||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
@@ -42,6 +45,7 @@ const materials = {
|
|||||||
pagesLeft: new THREE.MeshBasicMaterial({ color: 0xd8c7a4, side: THREE.DoubleSide }),
|
pagesLeft: new THREE.MeshBasicMaterial({ color: 0xd8c7a4, side: THREE.DoubleSide }),
|
||||||
pagesRight: new THREE.MeshBasicMaterial({ color: 0xe7d6b4, side: THREE.DoubleSide }),
|
pagesRight: new THREE.MeshBasicMaterial({ color: 0xe7d6b4, side: THREE.DoubleSide }),
|
||||||
topPage: new THREE.MeshBasicMaterial({ color: 0xf1dfba, side: THREE.DoubleSide }),
|
topPage: new THREE.MeshBasicMaterial({ color: 0xf1dfba, side: THREE.DoubleSide }),
|
||||||
|
flippingPage: new THREE.MeshBasicMaterial({ color: 0xf3dfb6, side: THREE.DoubleSide }),
|
||||||
edge: new THREE.MeshBasicMaterial({ color: 0xb99a68, side: THREE.DoubleSide }),
|
edge: new THREE.MeshBasicMaterial({ color: 0xb99a68, side: THREE.DoubleSide }),
|
||||||
hinge: new THREE.MeshBasicMaterial({ color: 0x2b0808 })
|
hinge: new THREE.MeshBasicMaterial({ color: 0x2b0808 })
|
||||||
};
|
};
|
||||||
@@ -59,10 +63,15 @@ let readingProgress = readInitialProgress();
|
|||||||
let pageCount = readInitialPageCount();
|
let pageCount = readInitialPageCount();
|
||||||
let lastLengthError = 0;
|
let lastLengthError = 0;
|
||||||
let lastSpacingError = 0;
|
let lastSpacingError = 0;
|
||||||
|
let lastBookModel = null;
|
||||||
|
let activeFlip = null;
|
||||||
|
let activeFlipMesh = null;
|
||||||
|
let pendingPageFlips = 0;
|
||||||
progressInput.value = readingProgress.toFixed(3);
|
progressInput.value = readingProgress.toFixed(3);
|
||||||
progressValue.value = readingProgress.toFixed(2);
|
progressValue.value = readingProgress.toFixed(2);
|
||||||
pageCountInput.value = String(pageCount);
|
pageCountInput.value = String(pageCount);
|
||||||
pageCountValue.value = String(pageCount);
|
pageCountValue.value = String(pageCount);
|
||||||
|
updateFlipControls();
|
||||||
rebuildBook();
|
rebuildBook();
|
||||||
resize();
|
resize();
|
||||||
animate();
|
animate();
|
||||||
@@ -75,6 +84,14 @@ pageCountInput.addEventListener('input', () => {
|
|||||||
setPageCount(pageCountInput.value);
|
setPageCount(pageCountInput.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
flipBackwardButton.addEventListener('click', () => {
|
||||||
|
startPageFlip(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
flipForwardButton.addEventListener('click', () => {
|
||||||
|
startPageFlip(1);
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
window.BookShapeLab = {
|
window.BookShapeLab = {
|
||||||
@@ -97,6 +114,12 @@ window.BookShapeLab = {
|
|||||||
setPageCount(value) {
|
setPageCount(value) {
|
||||||
setPageCount(value);
|
setPageCount(value);
|
||||||
return pageCount;
|
return pageCount;
|
||||||
|
},
|
||||||
|
flipForward() {
|
||||||
|
return startPageFlip(1);
|
||||||
|
},
|
||||||
|
flipBackward() {
|
||||||
|
return startPageFlip(-1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,19 +141,24 @@ function snapPageCount(value) {
|
|||||||
function setReadingProgress(value) {
|
function setReadingProgress(value) {
|
||||||
const next = THREE.MathUtils.clamp(Number.parseFloat(value), 0, 1);
|
const next = THREE.MathUtils.clamp(Number.parseFloat(value), 0, 1);
|
||||||
if (!Number.isFinite(next)) return;
|
if (!Number.isFinite(next)) return;
|
||||||
|
clearActiveFlip();
|
||||||
readingProgress = next;
|
readingProgress = next;
|
||||||
progressInput.value = readingProgress.toFixed(3);
|
progressInput.value = readingProgress.toFixed(3);
|
||||||
progressValue.value = readingProgress.toFixed(2);
|
progressValue.value = readingProgress.toFixed(2);
|
||||||
rebuildBook();
|
rebuildBook();
|
||||||
|
updateFlipControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPageCount(value) {
|
function setPageCount(value) {
|
||||||
const next = snapPageCount(Number.parseFloat(value));
|
const next = snapPageCount(Number.parseFloat(value));
|
||||||
if (!Number.isFinite(next)) return;
|
if (!Number.isFinite(next)) return;
|
||||||
|
clearActiveFlip();
|
||||||
|
pendingPageFlips = 0;
|
||||||
pageCount = next;
|
pageCount = next;
|
||||||
pageCountInput.value = String(pageCount);
|
pageCountInput.value = String(pageCount);
|
||||||
pageCountValue.value = String(pageCount);
|
pageCountValue.value = String(pageCount);
|
||||||
rebuildBook();
|
rebuildBook();
|
||||||
|
updateFlipControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildBook() {
|
function rebuildBook() {
|
||||||
@@ -145,11 +173,13 @@ function rebuildBook() {
|
|||||||
const lines = simulatePageLines(bundleCount, pageWidth, spineWidth);
|
const lines = simulatePageLines(bundleCount, pageWidth, spineWidth);
|
||||||
lastLengthError = measureLineLengthError(lines, pageWidth);
|
lastLengthError = measureLineLengthError(lines, pageWidth);
|
||||||
lastSpacingError = measureStackSpacingError(lines);
|
lastSpacingError = measureStackSpacingError(lines);
|
||||||
|
lastBookModel = { coverDepth, pageWidth, pageDepth, bundleCount, spineWidth, lines };
|
||||||
|
|
||||||
addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth);
|
addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth);
|
||||||
addClothSpine(pageDepth, spineWidth);
|
addClothSpine(pageDepth, spineWidth);
|
||||||
addSimulatedStackBodies(lines, pageDepth);
|
addSimulatedStackBodies(lines, pageDepth);
|
||||||
addSimulatedPageLines(lines, pageDepth);
|
addSimulatedPageLines(lines, pageDepth);
|
||||||
|
updateFlipControls();
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearGroup(group) {
|
function clearGroup(group) {
|
||||||
@@ -749,6 +779,261 @@ function createEndpointPolyline(lines, depth) {
|
|||||||
return new THREE.BufferGeometry().setFromPoints(points);
|
return new THREE.BufferGeometry().setFromPoints(points);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startPageFlip(direction) {
|
||||||
|
if (activeFlip || !lastBookModel || !canPageFlip(direction)) return false;
|
||||||
|
const sourceSide = direction > 0 ? 1 : -1;
|
||||||
|
const sourceLine = topVisibleLine(sourceSide);
|
||||||
|
const destinationLine = topVisibleLine(-sourceSide);
|
||||||
|
if (!sourceLine || !destinationLine) return false;
|
||||||
|
|
||||||
|
activeFlip = {
|
||||||
|
direction,
|
||||||
|
sourceLine,
|
||||||
|
destinationLine,
|
||||||
|
startTime: performance.now(),
|
||||||
|
duration: 1800
|
||||||
|
};
|
||||||
|
updateFlipControls();
|
||||||
|
updateActiveFlip(activeFlip.startTime);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPageFlip(direction) {
|
||||||
|
if (!lastBookModel) return false;
|
||||||
|
if (direction > 0) return readingProgress < 1;
|
||||||
|
return readingProgress > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function topVisibleLine(side) {
|
||||||
|
const sideLines = lastBookModel.lines
|
||||||
|
.filter((line) => line.side === side)
|
||||||
|
.sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t);
|
||||||
|
return sideLines[sideLines.length - 1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateActiveFlip(now) {
|
||||||
|
if (!activeFlip || !lastBookModel) return;
|
||||||
|
const elapsed = (now - activeFlip.startTime) / activeFlip.duration;
|
||||||
|
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
|
||||||
|
const surface = buildFlippingPageSurface(activeFlip.sourceLine, activeFlip.destinationLine, activeFlip.direction, easeInOutCubic(t));
|
||||||
|
setActivePageGeometry(surface);
|
||||||
|
if (t < 1) return;
|
||||||
|
|
||||||
|
finishActiveFlip();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFlippingPageSurface(sourceLine, destinationLine, direction, t) {
|
||||||
|
const widthSegments = sourceLine.points.length - 1;
|
||||||
|
const depthSegments = 18;
|
||||||
|
const zFront = lastBookModel.pageDepth * 0.5 + 0.018;
|
||||||
|
const zBack = -lastBookModel.pageDepth * 0.5 - 0.018;
|
||||||
|
if (t <= 0) return createRestingPageSurface(sourceLine.points, depthSegments, zFront, zBack);
|
||||||
|
if (t >= 1) return createRestingPageSurface(destinationLine.points, depthSegments, zFront, zBack);
|
||||||
|
|
||||||
|
const anchorT = THREE.MathUtils.lerp(sourceLine.t, destinationLine.t, t);
|
||||||
|
const anchor = spineCurvePoint(anchorT, lastBookModel.spineWidth);
|
||||||
|
const sourceSide = direction > 0 ? 1 : -1;
|
||||||
|
const startAngle = sourceSide > 0 ? 0 : Math.PI;
|
||||||
|
const baseAngle = startAngle + direction * Math.PI * t;
|
||||||
|
const lift = Math.sin(Math.PI * t);
|
||||||
|
const curlStrength = direction * 0.48 * lift;
|
||||||
|
const surface = [];
|
||||||
|
for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) {
|
||||||
|
const u = widthIndex / widthSegments;
|
||||||
|
const radius = lastBookModel.pageWidth * u;
|
||||||
|
const row = [];
|
||||||
|
for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) {
|
||||||
|
const v = depthIndex / depthSegments;
|
||||||
|
const z = THREE.MathUtils.lerp(zFront, zBack, v);
|
||||||
|
const depthWave = (v - 0.5) * 0.22 * lift * (0.15 + u * 0.85);
|
||||||
|
const curl = curlStrength * Math.sin(Math.PI * u) + direction * depthWave;
|
||||||
|
const angle = baseAngle + curl;
|
||||||
|
const stackPoint = interpolatePagePoint(sourceLine.points, destinationLine.points, widthIndex, t);
|
||||||
|
const relaxedY = THREE.MathUtils.lerp(stackPoint.y, anchor.y + Math.sin(angle) * radius, lift);
|
||||||
|
const point = {
|
||||||
|
x: anchor.x + Math.cos(angle) * radius,
|
||||||
|
y: relaxedY + 0.055 * lift * Math.sin(Math.PI * u),
|
||||||
|
z
|
||||||
|
};
|
||||||
|
keepFlippingSurfacePointAboveStacks(point, lift);
|
||||||
|
row.push(point);
|
||||||
|
}
|
||||||
|
surface.push(row);
|
||||||
|
}
|
||||||
|
return surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRestingPageSurface(points, depthSegments, zFront, zBack) {
|
||||||
|
return points.map((point) => {
|
||||||
|
const row = [];
|
||||||
|
for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) {
|
||||||
|
row.push({
|
||||||
|
x: point.x,
|
||||||
|
y: point.y,
|
||||||
|
z: THREE.MathUtils.lerp(zFront, zBack, depthIndex / depthSegments)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function interpolatePagePoint(sourcePoints, destinationPoints, index, t) {
|
||||||
|
const source = sourcePoints[index];
|
||||||
|
const destination = destinationPoints[index];
|
||||||
|
return {
|
||||||
|
x: THREE.MathUtils.lerp(source.x, destination.x, t),
|
||||||
|
y: THREE.MathUtils.lerp(source.y, destination.y, t)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepFlippingSurfacePointAboveStacks(point, lift) {
|
||||||
|
const envelopeY = stackEnvelopeYAtX(point.x);
|
||||||
|
if (envelopeY === null) return;
|
||||||
|
const clearance = 0.016 + lift * 0.045;
|
||||||
|
point.y = Math.max(point.y, envelopeY + clearance);
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepFlippingPageAboveStacks(points, lift) {
|
||||||
|
for (let index = 1; index < points.length; index += 1) {
|
||||||
|
const u = index / (points.length - 1);
|
||||||
|
const envelopeY = stackEnvelopeYAtX(points[index].x);
|
||||||
|
if (envelopeY === null) continue;
|
||||||
|
const clearance = 0.018 + lift * (0.05 + 0.05 * Math.sin(Math.PI * u));
|
||||||
|
points[index].y = Math.max(points[index].y, envelopeY + clearance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackEnvelopeYAtX(x) {
|
||||||
|
let envelope = null;
|
||||||
|
lastBookModel.lines.forEach((line) => {
|
||||||
|
const y = lineYAtX(line.points, x);
|
||||||
|
if (y === null) return;
|
||||||
|
envelope = envelope === null ? y : Math.max(envelope, y);
|
||||||
|
});
|
||||||
|
return envelope;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lineYAtX(points, x) {
|
||||||
|
let y = null;
|
||||||
|
for (let index = 0; index < points.length - 1; index += 1) {
|
||||||
|
const a = points[index];
|
||||||
|
const b = points[index + 1];
|
||||||
|
const minX = Math.min(a.x, b.x) - 0.00001;
|
||||||
|
const maxX = Math.max(a.x, b.x) + 0.00001;
|
||||||
|
if (x < minX || x > maxX) continue;
|
||||||
|
const span = b.x - a.x;
|
||||||
|
const segmentY = Math.abs(span) < 0.00001
|
||||||
|
? Math.max(a.y, b.y)
|
||||||
|
: THREE.MathUtils.lerp(a.y, b.y, (x - a.x) / span);
|
||||||
|
y = y === null ? segmentY : Math.max(y, segmentY);
|
||||||
|
}
|
||||||
|
return y;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivePageGeometry(surface) {
|
||||||
|
const geometry = createFlippingPageGeometry(surface);
|
||||||
|
if (!activeFlipMesh) {
|
||||||
|
activeFlipMesh = new THREE.Mesh(geometry, materials.flippingPage);
|
||||||
|
book.add(activeFlipMesh);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeFlipMesh.geometry.dispose();
|
||||||
|
activeFlipMesh.geometry = geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createFlippingPageGeometry(surface) {
|
||||||
|
const positions = [];
|
||||||
|
const indices = [];
|
||||||
|
const topGrid = [];
|
||||||
|
const bottomGrid = [];
|
||||||
|
const pageThickness = 0.006;
|
||||||
|
const widthSegments = surface.length - 1;
|
||||||
|
const depthSegments = surface[0].length - 1;
|
||||||
|
const push = (point, yOffset) => {
|
||||||
|
const index = positions.length / 3;
|
||||||
|
positions.push(point.x, point.y + yOffset, point.z);
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
surface.forEach((rowPoints) => {
|
||||||
|
const topRow = [];
|
||||||
|
const bottomRow = [];
|
||||||
|
rowPoints.forEach((point) => {
|
||||||
|
topRow.push(push(point, pageThickness));
|
||||||
|
bottomRow.push(push(point, 0));
|
||||||
|
});
|
||||||
|
topGrid.push(topRow);
|
||||||
|
bottomGrid.push(bottomRow);
|
||||||
|
});
|
||||||
|
for (let index = 0; index < widthSegments; index += 1) {
|
||||||
|
for (let zIndex = 0; zIndex < depthSegments; zIndex += 1) {
|
||||||
|
const a = topGrid[index][zIndex];
|
||||||
|
const b = topGrid[index + 1][zIndex];
|
||||||
|
const c = topGrid[index][zIndex + 1];
|
||||||
|
const d = topGrid[index + 1][zIndex + 1];
|
||||||
|
const bottomA = bottomGrid[index][zIndex];
|
||||||
|
const bottomB = bottomGrid[index + 1][zIndex];
|
||||||
|
const bottomC = bottomGrid[index][zIndex + 1];
|
||||||
|
const bottomD = bottomGrid[index + 1][zIndex + 1];
|
||||||
|
indices.push(a, c, b);
|
||||||
|
indices.push(b, c, d);
|
||||||
|
indices.push(bottomA, bottomB, bottomC);
|
||||||
|
indices.push(bottomB, bottomD, bottomC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let index = 0; index < widthSegments; index += 1) {
|
||||||
|
addWall(topGrid[index][0], topGrid[index + 1][0], bottomGrid[index][0], bottomGrid[index + 1][0]);
|
||||||
|
addWall(topGrid[index][depthSegments], topGrid[index + 1][depthSegments], bottomGrid[index][depthSegments], bottomGrid[index + 1][depthSegments]);
|
||||||
|
}
|
||||||
|
for (let zIndex = 0; zIndex < depthSegments; zIndex += 1) {
|
||||||
|
addWall(topGrid[0][zIndex], topGrid[0][zIndex + 1], bottomGrid[0][zIndex], bottomGrid[0][zIndex + 1]);
|
||||||
|
addWall(topGrid[widthSegments][zIndex], topGrid[widthSegments][zIndex + 1], bottomGrid[widthSegments][zIndex], bottomGrid[widthSegments][zIndex + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
return geometry;
|
||||||
|
|
||||||
|
function addWall(topA, topB, bottomA, bottomB) {
|
||||||
|
indices.push(topA, bottomA, topB);
|
||||||
|
indices.push(topB, bottomA, bottomB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishActiveFlip() {
|
||||||
|
const direction = activeFlip.direction;
|
||||||
|
clearActiveFlip();
|
||||||
|
pendingPageFlips += direction;
|
||||||
|
if (Math.abs(pendingPageFlips) >= 10) {
|
||||||
|
const commitDirection = Math.sign(pendingPageFlips);
|
||||||
|
pendingPageFlips -= commitDirection * 10;
|
||||||
|
const step = 1 / (lastBookModel.bundleCount - 1);
|
||||||
|
setReadingProgress(readingProgress + commitDirection * step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateFlipControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearActiveFlip() {
|
||||||
|
activeFlip = null;
|
||||||
|
if (!activeFlipMesh) return;
|
||||||
|
book.remove(activeFlipMesh);
|
||||||
|
activeFlipMesh.geometry.dispose();
|
||||||
|
activeFlipMesh = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFlipControls() {
|
||||||
|
flipBackwardButton.disabled = Boolean(activeFlip) || !canPageFlip(-1);
|
||||||
|
flipForwardButton.disabled = Boolean(activeFlip) || !canPageFlip(1);
|
||||||
|
flipCountValue.textContent = `${Math.abs(pendingPageFlips)} / 10`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function easeInOutCubic(t) {
|
||||||
|
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
const width = window.innerWidth;
|
const width = window.innerWidth;
|
||||||
const height = window.innerHeight;
|
const height = window.innerHeight;
|
||||||
@@ -763,6 +1048,7 @@ function animate() {
|
|||||||
const t = performance.now() * 0.00035;
|
const t = performance.now() * 0.00035;
|
||||||
setReadingProgress(0.5 + Math.sin(t) * 0.48);
|
setReadingProgress(0.5 + Math.sin(t) * 0.48);
|
||||||
}
|
}
|
||||||
|
updateActiveFlip(performance.now());
|
||||||
controls.update();
|
controls.update();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,21 @@
|
|||||||
#page_count {
|
#page_count {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #2b2d30;
|
||||||
|
color: #f0f0f0;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -53,7 +68,10 @@
|
|||||||
<label for="page_count">Book pages</label>
|
<label for="page_count">Book pages</label>
|
||||||
<input id="page_count" type="range" min="40" max="600" step="10" value="240">
|
<input id="page_count" type="range" min="40" max="600" step="10" value="240">
|
||||||
<output id="page_count_value" for="page_count">240</output>
|
<output id="page_count_value" for="page_count">240</output>
|
||||||
|
<button id="flip_backward" type="button">Backward</button>
|
||||||
|
<button id="flip_forward" type="button">Forward</button>
|
||||||
|
<output id="flip_count">0 / 10</output>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/js/webgl-book-shape-lab.js"></script>
|
<script type="module" src="/js/webgl-book-shape-lab.js?v=flip-3d-surface-3"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user