Checkpoint packed spine spacing

This commit is contained in:
2026-06-05 11:32:32 +02:00
parent ee641d2b91
commit b5c2f9fa42
2 changed files with 150 additions and 51 deletions
+148 -49
View File
@@ -64,12 +64,23 @@ const NORMAL_FLIP_DURATION = 1800;
const FAST_FLIP_DURATION = 900; const FAST_FLIP_DURATION = 900;
const FAST_FLIP_COUNT = 10; const FAST_FLIP_COUNT = 10;
const FAST_FLIP_OVERLAP = 5; const FAST_FLIP_OVERLAP = 5;
const OPEN_SEAM_GAP = 0.003;
const SUPPORT_ANGLE_STEPS = 720;
const SUPPORT_ANGLE_CANDIDATES = Array.from({ length: SUPPORT_ANGLE_STEPS }, (_, sample) => {
const angle = sample / SUPPORT_ANGLE_STEPS * Math.PI * 2;
return {
angle,
cos: Math.cos(angle),
sin: Math.sin(angle)
};
});
let readingProgress = readInitialProgress(); 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 lastBookModel = null;
let activeSpineHalf = 0.08;
let activeFlips = []; let activeFlips = [];
let pendingPageFlips = 0; let pendingPageFlips = 0;
progressInput.value = readingProgress.toFixed(3); progressInput.value = readingProgress.toFixed(3);
@@ -188,11 +199,14 @@ function rebuildBook() {
const pageWidth = 1.62; const pageWidth = 1.62;
const pageDepth = 2.24; const pageDepth = 2.24;
const bundleCount = Math.max(4, Math.round(pageCount / 10)); const bundleCount = Math.max(4, Math.round(pageCount / 10));
const spineWidth = Math.max(0.16, bundleCount * BOOK_PROFILE.bundleSpacing); const spineWidth = calculateSpineWidth(bundleCount);
const lines = simulatePageLines(bundleCount, pageWidth, spineWidth); const leftCount = calculateLeftBundleCount(bundleCount);
const bundleSpacing = calculateBundleSpacing(bundleCount, spineWidth, leftCount);
activeSpineHalf = spineWidth * 0.5;
const lines = simulatePageLines(bundleCount, pageWidth, spineWidth, bundleSpacing, leftCount);
lastLengthError = measureLineLengthError(lines, pageWidth); lastLengthError = measureLineLengthError(lines, pageWidth);
lastSpacingError = measureStackSpacingError(lines); lastSpacingError = measureStackSpacingError(lines, bundleSpacing);
lastBookModel = { coverDepth, pageWidth, pageDepth, bundleCount, spineWidth, lines }; lastBookModel = { coverDepth, pageWidth, pageDepth, bundleCount, spineWidth, bundleSpacing, lines };
addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth); addCoverAssembly(pageWidth, coverDepth, coverThickness, spineWidth);
addClothSpine(pageDepth, spineWidth); addClothSpine(pageDepth, spineWidth);
@@ -323,17 +337,76 @@ function spineCurvePoint(t, spineWidth) {
}; };
} }
function simulatePageLines(bundleCount, pageWidth, spineWidth) { function calculateSpineWidth(bundleCount) {
const minimumWidth = 0.16;
if (bundleCount <= 1) return minimumWidth;
const targetArcLength = (bundleCount - 1) * BOOK_PROFILE.bundleSpacing + OPEN_SEAM_GAP;
let low = minimumWidth;
let high = Math.max(minimumWidth, bundleCount * BOOK_PROFILE.bundleSpacing * 1.4);
while (measureSpineArcLength(high) < targetArcLength) {
high *= 1.25;
}
for (let i = 0; i < 24; i += 1) {
const mid = (low + high) * 0.5;
if (measureSpineArcLength(mid) < targetArcLength) {
low = mid;
} else {
high = mid;
}
}
return high;
}
function calculateBundleSpacing(bundleCount, spineWidth, leftCount) {
const rightCount = bundleCount - leftCount;
const stackIntervals = Math.max(0, leftCount - 1) + Math.max(0, rightCount - 1);
if (stackIntervals <= 0) return BOOK_PROFILE.bundleSpacing;
return Math.max(0.001, (measureSpineArcLength(spineWidth) - OPEN_SEAM_GAP) / stackIntervals);
}
function measureSpineArcLength(spineWidth) {
const steps = 240;
let length = 0;
let previous = spineCurvePoint(0, spineWidth);
for (let i = 1; i <= steps; i += 1) {
const point = spineCurvePoint(i / steps, spineWidth);
length += Math.hypot(point.x - previous.x, point.y - previous.y);
previous = point;
}
return length;
}
function calculateLeftBundleCount(bundleCount) {
return THREE.MathUtils.clamp(Math.round(bundleCount * readingProgress), 0, bundleCount);
}
function simulatePageLines(bundleCount, pageWidth, spineWidth, bundleSpacing, leftCount) {
const lines = []; const lines = [];
const segments = 24; const segments = 24;
const stepLength = pageWidth / segments; const stepLength = pageWidth / segments;
const entries = []; const entries = [];
const spineSamples = sampleSpineByArc(bundleCount, spineWidth); const spineArc = buildSpineArcSamples(spineWidth);
const leftLimit = Math.min(bundleCount - 2, Math.floor((bundleCount - 1) * readingProgress)); const rightCount = bundleCount - leftCount;
const leftSpan = Math.max(0, leftCount - 1) * bundleSpacing;
const rightSpan = Math.max(0, rightCount - 1) * bundleSpacing;
const seamLeftLength = leftSpan;
const seamRightLength = seamLeftLength + OPEN_SEAM_GAP;
for (let index = 0; index < bundleCount; index += 1) { for (let index = 0; index < bundleCount; index += 1) {
const t = spineSamples[index].t; const side = index < leftCount ? -1 : 1;
const side = index <= leftLimit ? -1 : 1; const sideRank = side < 0 ? index : index - leftCount;
entries.push({ index, t, side }); const arcLength = side < 0
? seamLeftLength - (leftCount - 1 - sideRank) * bundleSpacing
: seamRightLength + sideRank * bundleSpacing;
const point = pointAtSpineArcLength(spineArc, arcLength);
entries.push({ index, t: point.t, side });
}
if (leftCount === 0) {
const point = pointAtSpineArcLength(spineArc, seamLeftLength);
entries.push({ index: -1, t: point.t, side: -1, isHairPage: true });
}
if (rightCount === 0) {
const point = pointAtSpineArcLength(spineArc, seamRightLength);
entries.push({ index: bundleCount, t: point.t, side: 1, isHairPage: true });
} }
[-1, 1].forEach((side) => { [-1, 1].forEach((side) => {
const sideEntries = entries.filter((entry) => entry.side === side); const sideEntries = entries.filter((entry) => entry.side === side);
@@ -351,9 +424,9 @@ function simulatePageLines(bundleCount, pageWidth, spineWidth) {
let lowerLine = null; let lowerLine = null;
sideEntries.forEach((entry, rank) => { sideEntries.forEach((entry, rank) => {
const anchor = spineCurvePoint(entry.t, spineWidth); const anchor = spineCurvePoint(entry.t, spineWidth);
const target = restingTarget(side, pageWidth, rank, sideEntries.length); const target = restingTarget(side, pageWidth, rank, sideEntries.length, bundleSpacing);
const points = buildSupportSolvedLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount); const points = buildSupportSolvedLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount, bundleSpacing);
const line = { index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1] }; const line = { index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1], isHairPage: entry.isHairPage === true };
lines.push(line); lines.push(line);
lowerLine = line; lowerLine = line;
}); });
@@ -371,7 +444,7 @@ function measureLineLengthError(lines, pageWidth) {
}, 0); }, 0);
} }
function measureStackSpacingError(lines) { function measureStackSpacingError(lines, bundleSpacing) {
let maxViolation = 0; let maxViolation = 0;
[-1, 1].forEach((side) => { [-1, 1].forEach((side) => {
const sideLines = lines const sideLines = lines
@@ -383,14 +456,14 @@ function measureStackSpacingError(lines) {
for (let col = 1; col < upper.points.length; col += 1) { for (let col = 1; col < upper.points.length; col += 1) {
const closest = closestPointOnPolyline(upper.points[col], lower.points); const closest = closestPointOnPolyline(upper.points[col], lower.points);
const distance = Math.hypot(upper.points[col].x - closest.x, upper.points[col].y - closest.y); const distance = Math.hypot(upper.points[col].x - closest.x, upper.points[col].y - closest.y);
maxViolation = Math.max(maxViolation, Math.max(0, BOOK_PROFILE.bundleSpacing - distance)); maxViolation = Math.max(maxViolation, Math.max(0, bundleSpacing - distance));
} }
} }
}); });
return maxViolation; return maxViolation;
} }
function sampleSpineByArc(count, spineWidth) { function buildSpineArcSamples(spineWidth) {
const samples = []; const samples = [];
const steps = 240; const steps = 240;
let length = 0; let length = 0;
@@ -403,21 +476,27 @@ function sampleSpineByArc(count, spineWidth) {
samples.push({ point, length }); samples.push({ point, length });
previous = point; previous = point;
} }
const points = []; return { samples, length, spineWidth };
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); function pointAtSpineArcLength(spineArc, targetLength) {
if (found <= 0) { const target = THREE.MathUtils.clamp(targetLength, 0, spineArc.length);
points.push(samples[0].point); let low = 0;
continue; let high = spineArc.samples.length - 1;
while (low < high) {
const mid = Math.floor((low + high) * 0.5);
if (spineArc.samples[mid].length < target) {
low = mid + 1;
} else {
high = mid;
} }
const before = samples[found - 1]; }
const after = samples[found]; if (low <= 0) return spineArc.samples[0].point;
const before = spineArc.samples[low - 1];
const after = spineArc.samples[low];
const span = after.length - before.length || 1; const span = after.length - before.length || 1;
const t = THREE.MathUtils.lerp(before.point.t, after.point.t, (target - before.length) / span); const t = THREE.MathUtils.lerp(before.point.t, after.point.t, (target - before.length) / span);
points.push(spineCurvePoint(t, spineWidth)); return spineCurvePoint(t, spineArc.spineWidth);
}
return points;
} }
function initialPageLine(anchor, target, segments) { function initialPageLine(anchor, target, segments) {
@@ -433,47 +512,54 @@ function initialPageLine(anchor, target, segments) {
return points; return points;
} }
function restingTarget(side, pageWidth, rank, sideCount) { function restingTarget(side, pageWidth, rank, sideCount, bundleSpacing) {
const local = sideCount <= 1 ? 0 : rank / (sideCount - 1); const local = sideCount <= 1 ? 0 : rank / (sideCount - 1);
const foreCurve = 0.11 * Math.sin(Math.PI * local); const foreCurve = 0.11 * Math.sin(Math.PI * local);
const x = side * (pageWidth - foreCurve); const x = side * (pageWidth - foreCurve);
const y = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset + rank * BOOK_PROFILE.bundleSpacing + 0.002 * Math.sin(Math.PI * local); const y = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset + rank * bundleSpacing + 0.002 * Math.sin(Math.PI * local);
return { x, y }; return { x, y };
} }
function buildSupportSolvedLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount) { function buildSupportSolvedLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount, bundleSpacing) {
const points = [{ x: anchor.x, y: anchor.y }]; const points = [{ x: anchor.x, y: anchor.y }];
let tangent = coverTangentAtX(anchor.x, side); let tangent = coverTangentAtX(anchor.x, side);
for (let index = 1; index <= segments; index += 1) { for (let index = 1; index <= segments; index += 1) {
const u = index / segments; const u = index / segments;
const supportTangent = lowerLine ? lineTangentAt(lowerLine.points, index) : coverTangentAtX(points[index - 1].x, side); const supportTangent = lowerLine ? lineTangentAt(lowerLine.points, index) : coverTangentAtX(points[index - 1].x, side);
const point = chooseClosestSupportedPoint(points[index - 1], tangent, supportTangent, target, lowerLine, index, side, stepLength, bundleCount, u); const point = chooseClosestSupportedPoint(points[index - 1], tangent, supportTangent, target, lowerLine, index, side, stepLength, bundleCount, bundleSpacing, u);
points.push(point); points.push(point);
tangent = normalizedVector(point.x - points[index - 1].x, point.y - points[index - 1].y); tangent = normalizedVector(point.x - points[index - 1].x, point.y - points[index - 1].y);
} }
return points; return points;
} }
function chooseClosestSupportedPoint(previous, tangent, supportTangent, target, lowerLine, index, side, stepLength, bundleCount, u) { function chooseClosestSupportedPoint(previous, tangent, supportTangent, target, lowerLine, index, side, stepLength, bundleCount, bundleSpacing, u) {
const blendTangent = normalizedVector(tangent.x + supportTangent.x * 2, tangent.y + supportTangent.y * 2); const blendTangent = normalizedVector(tangent.x + supportTangent.x * 2, tangent.y + supportTangent.y * 2);
const angleHint = Math.atan2(blendTangent.y, blendTangent.x); const angleHint = Math.atan2(blendTangent.y, blendTangent.x);
let best = null; let best = null;
let fallback = null; for (const sample of SUPPORT_ANGLE_CANDIDATES) {
for (let sample = 0; sample < 720; sample += 1) {
const angle = sample / 720 * Math.PI * 2;
const candidate = { const candidate = {
x: previous.x + Math.cos(angle) * stepLength, x: previous.x + sample.cos * stepLength,
y: previous.y + Math.sin(angle) * stepLength y: previous.y + sample.sin * stepLength
}; };
const score = scoreSupportedPoint(candidate, previous, tangent, supportTangent, angle, angleHint, target, lowerLine, index, side, bundleCount, u); const score = scoreSupportedPoint(candidate, previous, tangent, supportTangent, sample.angle, angleHint, target, lowerLine, index, side, bundleCount, bundleSpacing, u);
if (best === null || score < best.score) best = { point: candidate, score }; if (best === null || score < best.score) best = { point: candidate, score };
const fallbackScore = scoreSupportedPoint(candidate, previous, tangent, supportTangent, angle, angleHint, target, lowerLine, index, side, bundleCount, u, true); }
if (Number.isFinite(best?.score)) return best.point;
let fallback = null;
for (const sample of SUPPORT_ANGLE_CANDIDATES) {
const candidate = {
x: previous.x + sample.cos * stepLength,
y: previous.y + sample.sin * stepLength
};
const fallbackScore = scoreSupportedPoint(candidate, previous, tangent, supportTangent, sample.angle, angleHint, target, lowerLine, index, side, bundleCount, bundleSpacing, u, true);
if (fallback === null || fallbackScore < fallback.score) fallback = { point: candidate, score: fallbackScore }; if (fallback === null || fallbackScore < fallback.score) fallback = { point: candidate, score: fallbackScore };
} }
return Number.isFinite(best?.score) ? best.point : fallback.point; return fallback.point;
} }
function scoreSupportedPoint(candidate, previous, tangent, supportTangent, angle, angleHint, target, lowerLine, index, side, bundleCount, u, allowViolation = false) { function scoreSupportedPoint(candidate, previous, tangent, supportTangent, angle, angleHint, target, lowerLine, index, side, bundleCount, bundleSpacing, u, allowViolation = false) {
const backward = Math.max(0, side * (previous.x - candidate.x)); const backward = Math.max(0, side * (previous.x - candidate.x));
if (!allowViolation && backward > 0.00001) return Number.POSITIVE_INFINITY; if (!allowViolation && backward > 0.00001) return Number.POSITIVE_INFINITY;
@@ -482,9 +568,9 @@ function scoreSupportedPoint(candidate, previous, tangent, supportTangent, angle
if (lowerLine) { if (lowerLine) {
const closest = closestPointOnPolyline(candidate, lowerLine.points); const closest = closestPointOnPolyline(candidate, lowerLine.points);
const closestDistance = Math.hypot(candidate.x - closest.x, candidate.y - closest.y); const closestDistance = Math.hypot(candidate.x - closest.x, candidate.y - closest.y);
supportViolation = Math.max(0, BOOK_PROFILE.bundleSpacing - closestDistance) + Math.max(0, closest.y - candidate.y); supportViolation = Math.max(0, bundleSpacing - closestDistance) + Math.max(0, closest.y - candidate.y);
if (!allowViolation && supportViolation > 0.00001) return Number.POSITIVE_INFINITY; if (!allowViolation && supportViolation > 0.00001) return Number.POSITIVE_INFINITY;
supportError = closestDistance - BOOK_PROFILE.bundleSpacing; supportError = closestDistance - bundleSpacing;
} else { } else {
const floor = coverTopYAtX(candidate.x) + coverClearance(bundleCount); const floor = coverTopYAtX(candidate.x) + coverClearance(bundleCount);
supportViolation = Math.max(0, floor - candidate.y); supportViolation = Math.max(0, floor - candidate.y);
@@ -636,6 +722,7 @@ function coverClearance(bundleCount) {
} }
function enforceStackConstraints(lines, stepLength, bundleCount) { function enforceStackConstraints(lines, stepLength, bundleCount) {
const bundleSpacing = lastBookModel?.bundleSpacing ?? BOOK_PROFILE.bundleSpacing;
const iterations = 44; const iterations = 44;
[-1, 1].forEach((side) => { [-1, 1].forEach((side) => {
const sideLines = lines const sideLines = lines
@@ -655,8 +742,8 @@ function enforceStackConstraints(lines, stepLength, bundleCount) {
const upper = sideLines[row]; const upper = sideLines[row];
for (let col = 1; col < upper.points.length; col += 1) { for (let col = 1; col < upper.points.length; col += 1) {
const normal = upwardNormalAt(lower.points, col); const normal = upwardNormalAt(lower.points, col);
const targetX = lower.points[col].x + normal.x * BOOK_PROFILE.bundleSpacing; const targetX = lower.points[col].x + normal.x * bundleSpacing;
const targetY = lower.points[col].y + normal.y * BOOK_PROFILE.bundleSpacing; const targetY = lower.points[col].y + normal.y * bundleSpacing;
upper.points[col].x = THREE.MathUtils.lerp(upper.points[col].x, targetX, 0.28); 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[col].y = Math.max(upper.points[col].y, THREE.MathUtils.lerp(upper.points[col].y, targetY, 0.42));
} }
@@ -720,7 +807,7 @@ function coverTopYAtX(x) {
} }
function currentSpineHalf() { function currentSpineHalf() {
return Math.max(0.16, Math.round(pageCount / 10) * BOOK_PROFILE.bundleSpacing) * 0.5; return activeSpineHalf;
} }
function addSimulatedStackBodies(lines, depth) { function addSimulatedStackBodies(lines, depth) {
@@ -735,9 +822,10 @@ function addSimulatedStackBodies(lines, depth) {
function createSinglePageBodyLines(line) { function createSinglePageBodyLines(line) {
const bundleCount = Math.max(4, Math.round(pageCount / 10)); const bundleCount = Math.max(4, Math.round(pageCount / 10));
const bundleSpacing = lastBookModel?.bundleSpacing ?? BOOK_PROFILE.bundleSpacing;
const supportPoints = line.points.map((point) => ({ const supportPoints = line.points.map((point) => ({
x: point.x, x: point.x,
y: Math.max(coverTopYAtX(point.x) + coverClearance(bundleCount) + BOOK_PROFILE.singlePageCoverGap, point.y - BOOK_PROFILE.bundleSpacing) y: Math.max(coverTopYAtX(point.x) + coverClearance(bundleCount) + BOOK_PROFILE.singlePageCoverGap, point.y - bundleSpacing)
})); }));
return [ return [
{ ...line, points: supportPoints, endpoint: supportPoints[supportPoints.length - 1] }, { ...line, points: supportPoints, endpoint: supportPoints[supportPoints.length - 1] },
@@ -951,10 +1039,11 @@ function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pag
const baseAngle = startAngle + direction * Math.PI * t; const baseAngle = startAngle + direction * Math.PI * t;
const lift = Math.sin(Math.PI * t); const lift = Math.sin(Math.PI * t);
const curlStrength = direction * 0.48 * lift; const curlStrength = direction * 0.48 * lift;
const sourceLengths = cumulativeLineLengths(sourceLine.points);
const surface = []; const surface = [];
for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) { for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) {
const u = widthIndex / widthSegments; const u = widthIndex / widthSegments;
const radius = lastBookModel.pageWidth * u; const radius = sourceLengths[widthIndex];
const row = []; const row = [];
for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) { for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) {
const v = depthIndex / depthSegments; const v = depthIndex / depthSegments;
@@ -977,6 +1066,16 @@ function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pag
return surface; return surface;
} }
function cumulativeLineLengths(points) {
const lengths = [0];
for (let index = 1; index < points.length; index += 1) {
const previous = points[index - 1];
const current = points[index];
lengths.push(lengths[index - 1] + Math.hypot(current.x - previous.x, current.y - previous.y));
}
return lengths;
}
function createRestingPageSurface(points, depthSegments, zFront, zBack) { function createRestingPageSurface(points, depthSegments, zFront, zBack) {
return points.map((point) => { return points.map((point) => {
const row = []; const row = [];
+1 -1
View File
@@ -74,6 +74,6 @@
<button id="fast_forward" type="button">Fast Forward</button> <button id="fast_forward" type="button">Fast Forward</button>
<output id="flip_count">0 / 10</output> <output id="flip_count">0 / 10</output>
</div> </div>
<script type="module" src="/js/webgl-book-shape-lab.js?v=stack-texture-lines-5"></script> <script type="module" src="/js/webgl-book-shape-lab.js?v=packed-spine-seam-2"></script>
</body> </body>
</html> </html>