Checkpoint reconstructed book shape solver
This commit is contained in:
+435
-123
@@ -4,6 +4,8 @@ import { OrbitControls } from 'https://esm.sh/three@0.165.0/examples/jsm/control
|
|||||||
const canvas = document.getElementById('scene');
|
const canvas = document.getElementById('scene');
|
||||||
const progressInput = document.getElementById('progress');
|
const progressInput = document.getElementById('progress');
|
||||||
const progressValue = document.getElementById('progress_value');
|
const progressValue = document.getElementById('progress_value');
|
||||||
|
const pageCountInput = document.getElementById('page_count');
|
||||||
|
const pageCountValue = document.getElementById('page_count_value');
|
||||||
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 });
|
||||||
@@ -48,12 +50,18 @@ const BOOK_PROFILE = {
|
|||||||
tableY: 0,
|
tableY: 0,
|
||||||
coverThickness: 0.03,
|
coverThickness: 0.03,
|
||||||
raisedHingeY: 0.056,
|
raisedHingeY: 0.056,
|
||||||
paperContactOffset: 0.0012
|
paperContactOffset: 0.0012,
|
||||||
|
bundleSpacing: 0.014
|
||||||
};
|
};
|
||||||
|
|
||||||
let readingProgress = readInitialProgress();
|
let readingProgress = readInitialProgress();
|
||||||
|
let pageCount = readInitialPageCount();
|
||||||
|
let lastLengthError = 0;
|
||||||
|
let lastSpacingError = 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);
|
||||||
|
pageCountValue.value = String(pageCount);
|
||||||
rebuildBook();
|
rebuildBook();
|
||||||
resize();
|
resize();
|
||||||
animate();
|
animate();
|
||||||
@@ -62,15 +70,32 @@ progressInput.addEventListener('input', () => {
|
|||||||
setReadingProgress(progressInput.value);
|
setReadingProgress(progressInput.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
pageCountInput.addEventListener('input', () => {
|
||||||
|
setPageCount(pageCountInput.value);
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener('resize', resize);
|
||||||
|
|
||||||
window.BookShapeLab = {
|
window.BookShapeLab = {
|
||||||
get progress() {
|
get progress() {
|
||||||
return readingProgress;
|
return readingProgress;
|
||||||
},
|
},
|
||||||
|
get pageCount() {
|
||||||
|
return pageCount;
|
||||||
|
},
|
||||||
|
get lastLengthError() {
|
||||||
|
return lastLengthError;
|
||||||
|
},
|
||||||
|
get lastSpacingError() {
|
||||||
|
return lastSpacingError;
|
||||||
|
},
|
||||||
setReadingProgress(value) {
|
setReadingProgress(value) {
|
||||||
setReadingProgress(value);
|
setReadingProgress(value);
|
||||||
return readingProgress;
|
return readingProgress;
|
||||||
|
},
|
||||||
|
setPageCount(value) {
|
||||||
|
setPageCount(value);
|
||||||
|
return pageCount;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,6 +104,16 @@ function readInitialProgress() {
|
|||||||
return Number.isFinite(parsed) ? THREE.MathUtils.clamp(parsed, 0, 1) : 0.25;
|
return Number.isFinite(parsed) ? THREE.MathUtils.clamp(parsed, 0, 1) : 0.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readInitialPageCount() {
|
||||||
|
const parsed = Number.parseInt(urlParams.get('pages') ?? '240', 10);
|
||||||
|
if (!Number.isFinite(parsed)) return 240;
|
||||||
|
return snapPageCount(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapPageCount(value) {
|
||||||
|
return THREE.MathUtils.clamp(Math.round(value / 10) * 10, 40, 600);
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -88,6 +123,15 @@ function setReadingProgress(value) {
|
|||||||
rebuildBook();
|
rebuildBook();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setPageCount(value) {
|
||||||
|
const next = snapPageCount(Number.parseFloat(value));
|
||||||
|
if (!Number.isFinite(next)) return;
|
||||||
|
pageCount = next;
|
||||||
|
pageCountInput.value = String(pageCount);
|
||||||
|
pageCountValue.value = String(pageCount);
|
||||||
|
rebuildBook();
|
||||||
|
}
|
||||||
|
|
||||||
function rebuildBook() {
|
function rebuildBook() {
|
||||||
clearGroup(book);
|
clearGroup(book);
|
||||||
|
|
||||||
@@ -95,20 +139,16 @@ function rebuildBook() {
|
|||||||
const coverThickness = BOOK_PROFILE.coverThickness;
|
const coverThickness = BOOK_PROFILE.coverThickness;
|
||||||
const pageWidth = 1.62;
|
const pageWidth = 1.62;
|
||||||
const pageDepth = 2.24;
|
const pageDepth = 2.24;
|
||||||
const gutter = 0.12;
|
const bundleCount = Math.max(4, Math.round(pageCount / 10));
|
||||||
const sheetTick = 0.0045;
|
const spineWidth = Math.max(0.16, bundleCount * BOOK_PROFILE.bundleSpacing);
|
||||||
const fullBlock = 0.41;
|
const lines = simulatePageLines(bundleCount, pageWidth, spineWidth);
|
||||||
const leftThickness = THREE.MathUtils.lerp(sheetTick, fullBlock, readingProgress);
|
lastLengthError = measureLineLengthError(lines, pageWidth);
|
||||||
const rightThickness = THREE.MathUtils.lerp(fullBlock, sheetTick, readingProgress);
|
lastSpacingError = measureStackSpacingError(lines);
|
||||||
const spineWidth = fullBlock;
|
|
||||||
const fold = spineCurvePoint(readingProgress, spineWidth);
|
|
||||||
|
|
||||||
addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth);
|
addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth);
|
||||||
addSplinePageBlock(-1, pageWidth, pageDepth, leftThickness, fold, spineWidth);
|
|
||||||
addSplinePageBlock(1, pageWidth, pageDepth, rightThickness, fold, spineWidth);
|
|
||||||
addPageLayerLines(-1, pageWidth, pageDepth, leftThickness, spineWidth, fold);
|
|
||||||
addPageLayerLines(1, pageWidth, pageDepth, rightThickness, spineWidth, fold);
|
|
||||||
addClothSpine(pageDepth, spineWidth);
|
addClothSpine(pageDepth, spineWidth);
|
||||||
|
addSimulatedStackBodies(lines, pageDepth);
|
||||||
|
addSimulatedPageLines(lines, pageDepth);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearGroup(group) {
|
function clearGroup(group) {
|
||||||
@@ -185,44 +225,41 @@ function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth) {
|
|||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addSplinePageBlock(side, width, depth, thickness, fold, spineWidth) {
|
|
||||||
const block = new THREE.Mesh(createSplinePageBlockGeometry(side, width, depth, thickness, fold, spineWidth), side < 0 ? materials.pagesLeft : materials.pagesRight);
|
|
||||||
book.add(block);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addPageLayerLines(side, width, depth, thickness, spineWidth, fold) {
|
|
||||||
const material = new THREE.LineBasicMaterial({ color: 0x8f7750, transparent: true, opacity: 0.55 });
|
|
||||||
const z = depth * 0.5 + 0.006;
|
|
||||||
const lineCount = Math.max(1, Math.round(thickness / 0.018));
|
|
||||||
for (let layer = 1; layer < lineCount; layer += 1) {
|
|
||||||
const t = layer / lineCount;
|
|
||||||
const curveT = side < 0
|
|
||||||
? THREE.MathUtils.lerp(0, readingProgress, t)
|
|
||||||
: THREE.MathUtils.lerp(1, readingProgress, t);
|
|
||||||
const lineFold = spineCurvePoint(curveT, spineWidth);
|
|
||||||
const points = [];
|
|
||||||
for (let i = 0; i <= 40; i += 1) {
|
|
||||||
const u = i / 40;
|
|
||||||
const top = pageBlockTopY(side, thickness, u, 0.5, lineFold, spineWidth);
|
|
||||||
const bottom = pageBlockBottomY(side, thickness, u, 0.5, lineFold, spineWidth);
|
|
||||||
points.push(new THREE.Vector3(pageX(side, lineFold, spineWidth, width, u, 0.5), bottom + (top - bottom) * t, z));
|
|
||||||
}
|
|
||||||
book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addClothSpine(depth, spineWidth) {
|
function addClothSpine(depth, spineWidth) {
|
||||||
const material = new THREE.LineBasicMaterial({ color: 0xb51f1f });
|
const spine = new THREE.Mesh(createClothSpineGeometry(depth, spineWidth), materials.spine);
|
||||||
|
book.add(spine);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createClothSpineGeometry(depth, spineWidth) {
|
||||||
const profile = [];
|
const profile = [];
|
||||||
for (let i = 0; i <= 32; i += 1) {
|
for (let i = 0; i <= 32; i += 1) {
|
||||||
const u = i / 32;
|
const u = i / 32;
|
||||||
profile.push(spineCurvePoint(u, spineWidth));
|
profile.push(spineCurvePoint(u, spineWidth));
|
||||||
}
|
}
|
||||||
|
const positions = [];
|
||||||
|
const indices = [];
|
||||||
|
const front = [];
|
||||||
|
const back = [];
|
||||||
|
const push = (point, z) => {
|
||||||
|
const index = positions.length / 3;
|
||||||
|
positions.push(point.x, point.y, z);
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
[depth * 0.5 + 0.008, -depth * 0.5 - 0.008].forEach((z) => {
|
profile.forEach((point) => {
|
||||||
const points = profile.map((point) => new THREE.Vector3(point.x, point.y, z));
|
front.push(push(point, depth * 0.5 + 0.012));
|
||||||
book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material));
|
back.push(push(point, -depth * 0.5 - 0.012));
|
||||||
});
|
});
|
||||||
|
for (let i = 0; i < profile.length - 1; i += 1) {
|
||||||
|
indices.push(front[i], back[i], front[i + 1]);
|
||||||
|
indices.push(front[i + 1], back[i], back[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
function spineCurvePoint(t, spineWidth) {
|
function spineCurvePoint(t, spineWidth) {
|
||||||
@@ -237,110 +274,385 @@ function spineCurvePoint(t, spineWidth) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageBlockTopY(side, thickness, u, v, fold, spineWidth) {
|
function simulatePageLines(bundleCount, pageWidth, spineWidth) {
|
||||||
const hingeWidth = 0.105;
|
const lines = [];
|
||||||
const hinge = THREE.MathUtils.clamp(u / hingeWidth, 0, 1);
|
const segments = 16;
|
||||||
const t = easeOutCubic(hinge);
|
const stepLength = pageWidth / segments;
|
||||||
const sewnY = fold.y + 0.002;
|
const entries = [];
|
||||||
const stackY = pageBlockBottomY(side, thickness, u, v, fold, spineWidth) + thickness;
|
const spineSamples = sampleSpineByArc(bundleCount, spineWidth);
|
||||||
const flatCrown = 0.006 * Math.sin(Math.PI * v);
|
const leftLimit = Math.floor((bundleCount - 1) * readingProgress);
|
||||||
const foreCurl = 0.006 * smoothstep(THREE.MathUtils.clamp((u - 0.88) / 0.12, 0, 1));
|
for (let index = 0; index < bundleCount; index += 1) {
|
||||||
return sewnY * (1 - t) + (stackY + flatCrown + foreCurl) * t;
|
const t = spineSamples[index].t;
|
||||||
}
|
const side = index <= leftLimit ? -1 : 1;
|
||||||
|
entries.push({ index, t, side });
|
||||||
function pageBlockBottomY(side, thickness, u, v, fold, spineWidth) {
|
|
||||||
const curveEnd = 0.34;
|
|
||||||
if (u <= curveEnd) {
|
|
||||||
return pageCurvePoint(side, fold, spineWidth, u, curveEnd).y;
|
|
||||||
}
|
}
|
||||||
const flatY = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset;
|
[-1, 1].forEach((side) => {
|
||||||
return flatY;
|
const sideEntries = entries.filter((entry) => entry.side === side);
|
||||||
|
sideEntries.forEach((entry, rank) => {
|
||||||
|
entry.rank = rank;
|
||||||
|
entry.sideCount = sideEntries.length;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
[-1, 1].forEach((side) => {
|
||||||
|
const sideEntries = entries
|
||||||
|
.filter((entry) => entry.side === side)
|
||||||
|
.sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t);
|
||||||
|
if (!sideEntries.length) return;
|
||||||
|
|
||||||
|
const bottomEntry = sideEntries[0];
|
||||||
|
const bottomAnchor = spineCurvePoint(bottomEntry.t, spineWidth);
|
||||||
|
const bottomTarget = restingTarget(side, pageWidth, 0, sideEntries.length);
|
||||||
|
const bottomPoints = initialPageLine(bottomAnchor, bottomTarget, segments);
|
||||||
|
relaxPageLine(bottomPoints, bottomAnchor, stepLength, side, 0, bundleCount);
|
||||||
|
keepPageAboveCover(bottomPoints, side, bundleCount);
|
||||||
|
|
||||||
|
sideEntries.forEach((entry, rank) => {
|
||||||
|
const anchor = spineCurvePoint(entry.t, spineWidth);
|
||||||
|
const points = rank === 0
|
||||||
|
? bottomPoints.map((point) => ({ ...point }))
|
||||||
|
: offsetPageLine(bottomPoints, anchor, rank * BOOK_PROFILE.bundleSpacing);
|
||||||
|
keepPageAboveCover(points, side, bundleCount);
|
||||||
|
lines.push({ index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1] });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
enforceStackConstraints(lines, stepLength, bundleCount);
|
||||||
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pageCurvePoint(side, fold, spineWidth, u, curveEnd) {
|
function measureLineLengthError(lines, pageWidth) {
|
||||||
const along = THREE.MathUtils.clamp(u / curveEnd, 0, 1);
|
return lines.reduce((maxError, line) => {
|
||||||
const targetT = side < 0 ? 0 : 1;
|
let length = 0;
|
||||||
return spineCurvePoint(THREE.MathUtils.lerp(fold.t, targetT, along), spineWidth);
|
for (let i = 0; i < line.points.length - 1; i += 1) {
|
||||||
}
|
length += Math.hypot(line.points[i + 1].x - line.points[i].x, line.points[i + 1].y - line.points[i].y);
|
||||||
|
|
||||||
function smoothstep(value) {
|
|
||||||
return value * value * (3 - 2 * value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function easeOutCubic(value) {
|
|
||||||
return 1 - Math.pow(1 - value, 3);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageWidthAtDepth(width, v) {
|
|
||||||
return width;
|
|
||||||
}
|
|
||||||
|
|
||||||
function pageX(side, fold, spineWidth, width, u, v = 0.5) {
|
|
||||||
const curveEnd = 0.34;
|
|
||||||
if (u <= curveEnd) {
|
|
||||||
return pageCurvePoint(side, fold, spineWidth, u, curveEnd).x;
|
|
||||||
}
|
}
|
||||||
const outerT = THREE.MathUtils.clamp((u - curveEnd) / (1 - curveEnd), 0, 1);
|
return Math.max(maxError, Math.abs(length - pageWidth));
|
||||||
const curveEndX = spineCurvePoint(side < 0 ? 0 : 1, spineWidth).x;
|
}, 0);
|
||||||
const foreX = side * pageWidthAtDepth(width, v);
|
|
||||||
return curveEndX * (1 - outerT) + foreX * outerT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSplinePageBlockGeometry(side, width, depth, thickness, fold, spineWidth) {
|
function measureStackSpacingError(lines) {
|
||||||
const columns = 36;
|
let maxError = 0;
|
||||||
const rows = 36;
|
[-1, 1].forEach((side) => {
|
||||||
|
const sideLines = lines
|
||||||
|
.filter((line) => line.side === side)
|
||||||
|
.sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t);
|
||||||
|
for (let row = 1; row < sideLines.length; row += 1) {
|
||||||
|
const lower = sideLines[row - 1];
|
||||||
|
const upper = sideLines[row];
|
||||||
|
for (let col = 0; col < upper.points.length; col += 1) {
|
||||||
|
const distance = Math.hypot(upper.points[col].x - lower.points[col].x, upper.points[col].y - lower.points[col].y);
|
||||||
|
maxError = Math.max(maxError, Math.abs(distance - BOOK_PROFILE.bundleSpacing));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return maxError;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleSpineByArc(count, spineWidth) {
|
||||||
|
const samples = [];
|
||||||
|
const steps = 240;
|
||||||
|
let length = 0;
|
||||||
|
let previous = spineCurvePoint(0, spineWidth);
|
||||||
|
samples.push({ point: previous, length });
|
||||||
|
for (let i = 1; i <= steps; i += 1) {
|
||||||
|
const t = i / steps;
|
||||||
|
const point = spineCurvePoint(t, spineWidth);
|
||||||
|
length += Math.hypot(point.x - previous.x, point.y - previous.y);
|
||||||
|
samples.push({ point, length });
|
||||||
|
previous = point;
|
||||||
|
}
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
const target = count === 1 ? length * 0.5 : length * (i / (count - 1));
|
||||||
|
const found = samples.findIndex((sample) => sample.length >= target);
|
||||||
|
if (found <= 0) {
|
||||||
|
points.push(samples[0].point);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const before = samples[found - 1];
|
||||||
|
const after = samples[found];
|
||||||
|
const span = after.length - before.length || 1;
|
||||||
|
const t = THREE.MathUtils.lerp(before.point.t, after.point.t, (target - before.length) / span);
|
||||||
|
points.push(spineCurvePoint(t, spineWidth));
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialPageLine(anchor, target, segments) {
|
||||||
|
const points = [];
|
||||||
|
for (let i = 0; i <= segments; i += 1) {
|
||||||
|
const u = i / segments;
|
||||||
|
const sag = 0.04 * Math.sin(Math.PI * u);
|
||||||
|
points.push({
|
||||||
|
x: THREE.MathUtils.lerp(anchor.x, target.x, u),
|
||||||
|
y: THREE.MathUtils.lerp(anchor.y, target.y, u) - sag * u
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
function restingTarget(side, pageWidth, rank, sideCount) {
|
||||||
|
const local = sideCount <= 1 ? 0 : rank / (sideCount - 1);
|
||||||
|
const foreCurve = 0.11 * Math.sin(Math.PI * local);
|
||||||
|
const x = side * (pageWidth - foreCurve);
|
||||||
|
const y = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset + rank * BOOK_PROFILE.bundleSpacing + 0.002 * Math.sin(Math.PI * local);
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function relaxPageLine(points, anchor, stepLength, side, local, bundleCount) {
|
||||||
|
const gravity = 0.00072;
|
||||||
|
const stackPressure = 0.0011 * (1 - local);
|
||||||
|
const bendStrength = 0.52;
|
||||||
|
const iterations = 72;
|
||||||
|
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
||||||
|
points[0].x = anchor.x;
|
||||||
|
points[0].y = anchor.y;
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const u = i / (points.length - 1);
|
||||||
|
points[i].y -= gravity * u + stackPressure * u * u;
|
||||||
|
}
|
||||||
|
applyBendingResistance(points, bendStrength);
|
||||||
|
for (let pass = 0; pass < 3; pass += 1) {
|
||||||
|
points[0].x = anchor.x;
|
||||||
|
points[0].y = anchor.y;
|
||||||
|
enforceLineLength(points, anchor, stepLength, 3);
|
||||||
|
keepPageAboveCover(points, side, bundleCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBendingResistance(points, strength) {
|
||||||
|
const updates = points.map((point) => ({ x: point.x, y: point.y }));
|
||||||
|
for (let i = 1; i < points.length - 1; i += 1) {
|
||||||
|
const previous = points[i - 1];
|
||||||
|
const current = points[i];
|
||||||
|
const next = points[i + 1];
|
||||||
|
updates[i].x += (previous.x + next.x - current.x * 2) * strength;
|
||||||
|
updates[i].y += (previous.y + next.y - current.y * 2) * strength;
|
||||||
|
}
|
||||||
|
for (let i = 1; i < points.length - 1; i += 1) {
|
||||||
|
points[i].x = updates[i].x;
|
||||||
|
points[i].y = updates[i].y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceLineLength(points, anchor, stepLength, passes) {
|
||||||
|
for (let pass = 0; pass < passes; pass += 1) {
|
||||||
|
points[0].x = anchor.x;
|
||||||
|
points[0].y = anchor.y;
|
||||||
|
for (let i = 0; i < points.length - 1; i += 1) {
|
||||||
|
constrainSegment(points[i], points[i + 1], stepLength, i === 0);
|
||||||
|
}
|
||||||
|
for (let i = points.length - 2; i >= 0; i -= 1) {
|
||||||
|
constrainSegment(points[i], points[i + 1], stepLength, i === 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainSegment(a, b, length, anchorA) {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const distance = Math.hypot(dx, dy) || 0.0001;
|
||||||
|
const correction = (distance - length) / distance;
|
||||||
|
if (anchorA) {
|
||||||
|
b.x -= dx * correction;
|
||||||
|
b.y -= dy * correction;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
a.x += dx * correction * 0.5;
|
||||||
|
a.y += dy * correction * 0.5;
|
||||||
|
b.x -= dx * correction * 0.5;
|
||||||
|
b.y -= dy * correction * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepPageAboveCover(points, side, bundleCount) {
|
||||||
|
for (let i = 1; i < points.length; i += 1) {
|
||||||
|
const clearance = BOOK_PROFILE.paperContactOffset + 0.0002 * bundleCount;
|
||||||
|
points[i].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + clearance);
|
||||||
|
points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function enforceStackConstraints(lines, stepLength, bundleCount) {
|
||||||
|
const iterations = 44;
|
||||||
|
[-1, 1].forEach((side) => {
|
||||||
|
const sideLines = lines
|
||||||
|
.filter((line) => line.side === side)
|
||||||
|
.sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t);
|
||||||
|
for (let iteration = 0; iteration < iterations; iteration += 1) {
|
||||||
|
sideLines.forEach((line) => {
|
||||||
|
line.points[0].x = line.anchor.x;
|
||||||
|
line.points[0].y = line.anchor.y;
|
||||||
|
applyBendingResistance(line.points, 0.22);
|
||||||
|
enforceLineLength(line.points, line.anchor, stepLength, 3);
|
||||||
|
keepPageAboveCover(line.points, side, bundleCount);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let row = 1; row < sideLines.length; row += 1) {
|
||||||
|
const lower = sideLines[row - 1];
|
||||||
|
const upper = sideLines[row];
|
||||||
|
for (let col = 1; col < upper.points.length; col += 1) {
|
||||||
|
const normal = upwardNormalAt(lower.points, col);
|
||||||
|
const targetX = lower.points[col].x + normal.x * BOOK_PROFILE.bundleSpacing;
|
||||||
|
const targetY = lower.points[col].y + normal.y * BOOK_PROFILE.bundleSpacing;
|
||||||
|
upper.points[col].x = THREE.MathUtils.lerp(upper.points[col].x, targetX, 0.28);
|
||||||
|
upper.points[col].y = Math.max(upper.points[col].y, THREE.MathUtils.lerp(upper.points[col].y, targetY, 0.42));
|
||||||
|
}
|
||||||
|
upper.points[0].x = upper.anchor.x;
|
||||||
|
upper.points[0].y = upper.anchor.y;
|
||||||
|
applyBendingResistance(upper.points, 0.2);
|
||||||
|
enforceLineLength(upper.points, upper.anchor, stepLength, 3);
|
||||||
|
keepPageAboveCover(upper.points, side, bundleCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sideLines.forEach((line) => {
|
||||||
|
applyBendingResistance(line.points, 0.32);
|
||||||
|
enforceLineLength(line.points, line.anchor, stepLength, 10);
|
||||||
|
keepPageAboveCover(line.points, side, bundleCount);
|
||||||
|
enforceLineLength(line.points, line.anchor, stepLength, 6);
|
||||||
|
});
|
||||||
|
sideLines.forEach((line) => {
|
||||||
|
line.endpoint = line.points[line.points.length - 1];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetPageLine(basePoints, anchor, distance) {
|
||||||
|
return basePoints.map((point, index) => {
|
||||||
|
if (index === 0) return { x: anchor.x, y: anchor.y };
|
||||||
|
const normal = upwardNormalAt(basePoints, index);
|
||||||
|
return {
|
||||||
|
x: point.x + normal.x * distance,
|
||||||
|
y: point.y + normal.y * distance
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function upwardNormalAt(points, index) {
|
||||||
|
const previous = points[Math.max(0, index - 1)];
|
||||||
|
const next = points[Math.min(points.length - 1, index + 1)];
|
||||||
|
const dx = next.x - previous.x;
|
||||||
|
const dy = next.y - previous.y;
|
||||||
|
const length = Math.hypot(dx, dy) || 0.0001;
|
||||||
|
let nx = -dy / length;
|
||||||
|
let ny = dx / length;
|
||||||
|
if (ny < 0) {
|
||||||
|
nx = -nx;
|
||||||
|
ny = -ny;
|
||||||
|
}
|
||||||
|
return { x: nx, y: ny };
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverTopYAtX(x) {
|
||||||
|
const ax = Math.abs(x);
|
||||||
|
const spineHalf = currentSpineHalf();
|
||||||
|
const hingeX = spineHalf + 0.07;
|
||||||
|
const outerX = 1.62 + 0.055;
|
||||||
|
if (ax <= spineHalf) return BOOK_PROFILE.coverThickness;
|
||||||
|
if (ax <= hingeX) {
|
||||||
|
const t = (ax - spineHalf) / (hingeX - spineHalf);
|
||||||
|
return THREE.MathUtils.lerp(BOOK_PROFILE.coverThickness, BOOK_PROFILE.raisedHingeY, t);
|
||||||
|
}
|
||||||
|
const t = THREE.MathUtils.clamp((ax - hingeX) / (outerX - hingeX), 0, 1);
|
||||||
|
return THREE.MathUtils.lerp(BOOK_PROFILE.raisedHingeY, BOOK_PROFILE.coverThickness, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentSpineHalf() {
|
||||||
|
return Math.max(0.16, Math.round(pageCount / 10) * BOOK_PROFILE.bundleSpacing) * 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSimulatedPageLines(lines, depth) {
|
||||||
|
const leftMaterial = new THREE.LineBasicMaterial({ color: 0x8f7750, transparent: true, opacity: 0.72 });
|
||||||
|
const rightMaterial = new THREE.LineBasicMaterial({ color: 0x9a8058, transparent: true, opacity: 0.72 });
|
||||||
|
const z = depth * 0.5 + 0.11;
|
||||||
|
lines.forEach((line) => {
|
||||||
|
const points = smoothLinePoints(line.points, 4).map((point) => new THREE.Vector3(point.x, point.y, z));
|
||||||
|
book.add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), line.side < 0 ? leftMaterial : rightMaterial));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSimulatedStackBodies(lines, depth) {
|
||||||
|
[-1, 1].forEach((side) => {
|
||||||
|
const sideLines = lines.filter((line) => line.side === side);
|
||||||
|
if (sideLines.length < 2) return;
|
||||||
|
const material = side < 0 ? materials.pagesLeft : materials.pagesRight;
|
||||||
|
book.add(new THREE.Mesh(createLoftedLineBody(sideLines, depth), material));
|
||||||
|
book.add(new THREE.Line(createEndpointPolyline(sideLines, depth), new THREE.LineBasicMaterial({ color: 0xb99a68, transparent: true, opacity: 0.62 })));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createLoftedLineBody(lines, depth) {
|
||||||
const positions = [];
|
const positions = [];
|
||||||
const uvs = [];
|
|
||||||
const indices = [];
|
const indices = [];
|
||||||
const top = [];
|
const smoothLines = lines.map((line) => smoothLinePoints(line.points, 4));
|
||||||
const bottom = [];
|
const push = (point, z) => {
|
||||||
|
|
||||||
const push = (x, y, z, u, v) => {
|
|
||||||
const index = positions.length / 3;
|
const index = positions.length / 3;
|
||||||
positions.push(x, y, z);
|
positions.push(point.x, point.y, z);
|
||||||
uvs.push(u, v);
|
|
||||||
return index;
|
return index;
|
||||||
};
|
};
|
||||||
|
const front = smoothLines.map((points) => points.map((point) => push(point, depth * 0.5 + 0.09)));
|
||||||
for (let row = 0; row <= rows; row += 1) {
|
const back = smoothLines.map((points) => points.map((point) => push(point, -depth * 0.5 + 0.09)));
|
||||||
const v = row / rows;
|
for (let row = 0; row < smoothLines.length - 1; row += 1) {
|
||||||
top[row] = [];
|
for (let col = 0; col < smoothLines[row].length - 1; col += 1) {
|
||||||
bottom[row] = [];
|
indices.push(front[row][col], front[row + 1][col], front[row][col + 1]);
|
||||||
for (let column = 0; column <= columns; column += 1) {
|
indices.push(front[row][col + 1], front[row + 1][col], front[row + 1][col + 1]);
|
||||||
const u = column / columns;
|
indices.push(back[row][col], back[row][col + 1], back[row + 1][col]);
|
||||||
const z = (v - 0.5) * depth;
|
indices.push(back[row][col + 1], back[row + 1][col + 1], back[row + 1][col]);
|
||||||
top[row][column] = push(pageX(side, fold, spineWidth, width, u, v), pageBlockTopY(side, thickness, u, v, fold, spineWidth), z, u, v);
|
|
||||||
bottom[row][column] = push(pageX(side, fold, spineWidth, width, u, v), pageBlockBottomY(side, thickness, u, v, fold, spineWidth), z, u, v);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (let row = 0; row < smoothLines.length - 1; row += 1) {
|
||||||
for (let row = 0; row < rows; row += 1) {
|
const last = smoothLines[row].length - 1;
|
||||||
for (let column = 0; column < columns; column += 1) {
|
indices.push(front[row][last], front[row + 1][last], back[row][last]);
|
||||||
indices.push(top[row][column], top[row + 1][column], top[row][column + 1]);
|
indices.push(front[row + 1][last], back[row + 1][last], back[row][last]);
|
||||||
indices.push(top[row][column + 1], top[row + 1][column], top[row + 1][column + 1]);
|
|
||||||
indices.push(bottom[row][column], bottom[row][column + 1], bottom[row + 1][column]);
|
|
||||||
indices.push(bottom[row][column + 1], bottom[row + 1][column + 1], bottom[row + 1][column]);
|
|
||||||
}
|
}
|
||||||
|
for (let col = 0; col < smoothLines[0].length - 1; col += 1) {
|
||||||
|
const bottomRow = 0;
|
||||||
|
const topRow = smoothLines.length - 1;
|
||||||
|
indices.push(front[bottomRow][col], front[bottomRow][col + 1], back[bottomRow][col]);
|
||||||
|
indices.push(front[bottomRow][col + 1], back[bottomRow][col + 1], back[bottomRow][col]);
|
||||||
|
indices.push(front[topRow][col], back[topRow][col], front[topRow][col + 1]);
|
||||||
|
indices.push(front[topRow][col + 1], back[topRow][col], back[topRow][col + 1]);
|
||||||
}
|
}
|
||||||
|
for (let row = 0; row < smoothLines.length - 1; row += 1) {
|
||||||
for (let row = 0; row < rows; row += 1) {
|
indices.push(front[row][0], back[row][0], front[row + 1][0]);
|
||||||
indices.push(top[row][0], bottom[row][0], top[row + 1][0], top[row + 1][0], bottom[row][0], bottom[row + 1][0]);
|
indices.push(front[row + 1][0], back[row][0], back[row + 1][0]);
|
||||||
indices.push(top[row][columns], top[row + 1][columns], bottom[row][columns], top[row + 1][columns], bottom[row + 1][columns], bottom[row][columns]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let column = 0; column < columns; column += 1) {
|
|
||||||
indices.push(top[0][column], top[0][column + 1], bottom[0][column], top[0][column + 1], bottom[0][column + 1], bottom[0][column]);
|
|
||||||
indices.push(top[rows][column], bottom[rows][column], top[rows][column + 1], top[rows][column + 1], bottom[rows][column], bottom[rows][column + 1]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setIndex(indices);
|
geometry.setIndex(indices);
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
|
||||||
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
|
|
||||||
geometry.computeVertexNormals();
|
geometry.computeVertexNormals();
|
||||||
return geometry;
|
return geometry;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createEndpointPolyline(lines, depth) {
|
||||||
|
const points = lines.map((line) => new THREE.Vector3(line.endpoint.x, line.endpoint.y, depth * 0.5 + 0.112));
|
||||||
|
return new THREE.BufferGeometry().setFromPoints(points);
|
||||||
|
}
|
||||||
|
|
||||||
|
function smoothLinePoints(points, subdivisions) {
|
||||||
|
const result = [];
|
||||||
|
for (let i = 0; i < points.length - 1; i += 1) {
|
||||||
|
const p0 = points[Math.max(0, i - 1)];
|
||||||
|
const p1 = points[i];
|
||||||
|
const p2 = points[i + 1];
|
||||||
|
const p3 = points[Math.min(points.length - 1, i + 2)];
|
||||||
|
for (let step = 0; step < subdivisions; step += 1) {
|
||||||
|
const t = step / subdivisions;
|
||||||
|
result.push(catmullRomPoint(p0, p1, p2, p3, t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.push(points[points.length - 1]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function catmullRomPoint(p0, p1, p2, p3, t) {
|
||||||
|
const tt = t * t;
|
||||||
|
const ttt = tt * t;
|
||||||
|
return {
|
||||||
|
x: 0.5 * ((2 * p1.x) + (-p0.x + p2.x) * t + (2 * p0.x - 5 * p1.x + 4 * p2.x - p3.x) * tt + (-p0.x + 3 * p1.x - 3 * p2.x + p3.x) * ttt),
|
||||||
|
y: 0.5 * ((2 * p1.y) + (-p0.y + p2.y) * t + (2 * p0.y - 5 * p1.y + 4 * p2.y - p3.y) * tt + (-p0.y + 3 * p1.y - 3 * p2.y + p3.y) * ttt)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function resize() {
|
function resize() {
|
||||||
const width = window.innerWidth;
|
const width = window.innerWidth;
|
||||||
const height = window.innerHeight;
|
const height = window.innerHeight;
|
||||||
|
|||||||
@@ -38,7 +38,8 @@
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#progress {
|
#progress,
|
||||||
|
#page_count {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -49,6 +50,9 @@
|
|||||||
<label for="progress">Reading progress</label>
|
<label for="progress">Reading progress</label>
|
||||||
<input id="progress" type="range" min="0" max="1" step="0.001" value="0.25">
|
<input id="progress" type="range" min="0" max="1" step="0.001" value="0.25">
|
||||||
<output id="progress_value" for="progress">0.25</output>
|
<output id="progress_value" for="progress">0.25</output>
|
||||||
|
<label for="page_count">Book pages</label>
|
||||||
|
<input id="page_count" type="range" min="40" max="600" step="10" value="240">
|
||||||
|
<output id="page_count_value" for="page_count">240</output>
|
||||||
</div>
|
</div>
|
||||||
<script type="module" src="/js/webgl-book-shape-lab.js"></script>
|
<script type="module" src="/js/webgl-book-shape-lab.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user