From b5c2f9fa422714e4fc3bd8e3401a04e0b5b57762 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Fri, 5 Jun 2026 11:32:32 +0200 Subject: [PATCH] Checkpoint packed spine spacing --- public/js/webgl-book-shape-lab.js | 199 ++++++++++++++++++++++-------- public/webgl-book-shape-lab.html | 2 +- 2 files changed, 150 insertions(+), 51 deletions(-) diff --git a/public/js/webgl-book-shape-lab.js b/public/js/webgl-book-shape-lab.js index c2b97e3..4bc25af 100644 --- a/public/js/webgl-book-shape-lab.js +++ b/public/js/webgl-book-shape-lab.js @@ -64,12 +64,23 @@ const NORMAL_FLIP_DURATION = 1800; const FAST_FLIP_DURATION = 900; const FAST_FLIP_COUNT = 10; 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 pageCount = readInitialPageCount(); let lastLengthError = 0; let lastSpacingError = 0; let lastBookModel = null; +let activeSpineHalf = 0.08; let activeFlips = []; let pendingPageFlips = 0; progressInput.value = readingProgress.toFixed(3); @@ -188,11 +199,14 @@ function rebuildBook() { const pageWidth = 1.62; const pageDepth = 2.24; const bundleCount = Math.max(4, Math.round(pageCount / 10)); - const spineWidth = Math.max(0.16, bundleCount * BOOK_PROFILE.bundleSpacing); - const lines = simulatePageLines(bundleCount, pageWidth, spineWidth); + const spineWidth = calculateSpineWidth(bundleCount); + 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); - lastSpacingError = measureStackSpacingError(lines); - lastBookModel = { coverDepth, pageWidth, pageDepth, bundleCount, spineWidth, lines }; + lastSpacingError = measureStackSpacingError(lines, bundleSpacing); + lastBookModel = { coverDepth, pageWidth, pageDepth, bundleCount, spineWidth, bundleSpacing, lines }; addCoverAssembly(pageWidth, coverDepth, coverThickness, 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 segments = 24; const stepLength = pageWidth / segments; const entries = []; - const spineSamples = sampleSpineByArc(bundleCount, spineWidth); - const leftLimit = Math.min(bundleCount - 2, Math.floor((bundleCount - 1) * readingProgress)); + const spineArc = buildSpineArcSamples(spineWidth); + 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) { - const t = spineSamples[index].t; - const side = index <= leftLimit ? -1 : 1; - entries.push({ index, t, side }); + const side = index < leftCount ? -1 : 1; + const sideRank = side < 0 ? index : index - leftCount; + 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) => { const sideEntries = entries.filter((entry) => entry.side === side); @@ -351,9 +424,9 @@ function simulatePageLines(bundleCount, pageWidth, spineWidth) { let lowerLine = null; sideEntries.forEach((entry, rank) => { const anchor = spineCurvePoint(entry.t, spineWidth); - const target = restingTarget(side, pageWidth, rank, sideEntries.length); - const points = buildSupportSolvedLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount); - const line = { index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1] }; + const target = restingTarget(side, pageWidth, rank, sideEntries.length, bundleSpacing); + 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], isHairPage: entry.isHairPage === true }; lines.push(line); lowerLine = line; }); @@ -371,7 +444,7 @@ function measureLineLengthError(lines, pageWidth) { }, 0); } -function measureStackSpacingError(lines) { +function measureStackSpacingError(lines, bundleSpacing) { let maxViolation = 0; [-1, 1].forEach((side) => { const sideLines = lines @@ -383,14 +456,14 @@ function measureStackSpacingError(lines) { for (let col = 1; col < upper.points.length; col += 1) { const closest = closestPointOnPolyline(upper.points[col], lower.points); 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; } -function sampleSpineByArc(count, spineWidth) { +function buildSpineArcSamples(spineWidth) { const samples = []; const steps = 240; let length = 0; @@ -403,21 +476,27 @@ function sampleSpineByArc(count, spineWidth) { 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; + return { samples, length, spineWidth }; +} + +function pointAtSpineArcLength(spineArc, targetLength) { + const target = THREE.MathUtils.clamp(targetLength, 0, spineArc.length); + let low = 0; + 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]; - 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; + 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 t = THREE.MathUtils.lerp(before.point.t, after.point.t, (target - before.length) / span); + return spineCurvePoint(t, spineArc.spineWidth); } function initialPageLine(anchor, target, segments) { @@ -433,47 +512,54 @@ function initialPageLine(anchor, target, segments) { return points; } -function restingTarget(side, pageWidth, rank, sideCount) { +function restingTarget(side, pageWidth, rank, sideCount, bundleSpacing) { 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); + const y = BOOK_PROFILE.coverThickness + BOOK_PROFILE.paperContactOffset + rank * bundleSpacing + 0.002 * Math.sin(Math.PI * local); 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 }]; let tangent = coverTangentAtX(anchor.x, side); for (let index = 1; index <= segments; index += 1) { const u = index / segments; 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); tangent = normalizedVector(point.x - points[index - 1].x, point.y - points[index - 1].y); } 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 angleHint = Math.atan2(blendTangent.y, blendTangent.x); let best = null; - let fallback = null; - for (let sample = 0; sample < 720; sample += 1) { - const angle = sample / 720 * Math.PI * 2; + for (const sample of SUPPORT_ANGLE_CANDIDATES) { const candidate = { - x: previous.x + Math.cos(angle) * stepLength, - y: previous.y + Math.sin(angle) * stepLength + x: previous.x + sample.cos * 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 }; - 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 }; } - 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)); if (!allowViolation && backward > 0.00001) return Number.POSITIVE_INFINITY; @@ -482,9 +568,9 @@ function scoreSupportedPoint(candidate, previous, tangent, supportTangent, angle if (lowerLine) { const closest = closestPointOnPolyline(candidate, lowerLine.points); 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; - supportError = closestDistance - BOOK_PROFILE.bundleSpacing; + supportError = closestDistance - bundleSpacing; } else { const floor = coverTopYAtX(candidate.x) + coverClearance(bundleCount); supportViolation = Math.max(0, floor - candidate.y); @@ -636,6 +722,7 @@ function coverClearance(bundleCount) { } function enforceStackConstraints(lines, stepLength, bundleCount) { + const bundleSpacing = lastBookModel?.bundleSpacing ?? BOOK_PROFILE.bundleSpacing; const iterations = 44; [-1, 1].forEach((side) => { const sideLines = lines @@ -655,8 +742,8 @@ function enforceStackConstraints(lines, stepLength, bundleCount) { 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; + const targetX = lower.points[col].x + normal.x * 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].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() { - return Math.max(0.16, Math.round(pageCount / 10) * BOOK_PROFILE.bundleSpacing) * 0.5; + return activeSpineHalf; } function addSimulatedStackBodies(lines, depth) { @@ -735,9 +822,10 @@ function addSimulatedStackBodies(lines, depth) { function createSinglePageBodyLines(line) { const bundleCount = Math.max(4, Math.round(pageCount / 10)); + const bundleSpacing = lastBookModel?.bundleSpacing ?? BOOK_PROFILE.bundleSpacing; const supportPoints = line.points.map((point) => ({ 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 [ { ...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 lift = Math.sin(Math.PI * t); const curlStrength = direction * 0.48 * lift; + const sourceLengths = cumulativeLineLengths(sourceLine.points); const surface = []; for (let widthIndex = 0; widthIndex <= widthSegments; widthIndex += 1) { const u = widthIndex / widthSegments; - const radius = lastBookModel.pageWidth * u; + const radius = sourceLengths[widthIndex]; const row = []; for (let depthIndex = 0; depthIndex <= depthSegments; depthIndex += 1) { const v = depthIndex / depthSegments; @@ -977,6 +1066,16 @@ function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pag 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) { return points.map((point) => { const row = []; diff --git a/public/webgl-book-shape-lab.html b/public/webgl-book-shape-lab.html index 1361a26..58e1a26 100644 --- a/public/webgl-book-shape-lab.html +++ b/public/webgl-book-shape-lab.html @@ -74,6 +74,6 @@ 0 / 10 - +