From 5283f0007e66a2e5cc5f6e093accb18879b2db08 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Fri, 5 Jun 2026 01:03:27 +0200 Subject: [PATCH] Checkpoint optimized book shape spline --- public/js/webgl-book-shape-lab.js | 198 ++++++++++++++++++++++++++---- 1 file changed, 175 insertions(+), 23 deletions(-) diff --git a/public/js/webgl-book-shape-lab.js b/public/js/webgl-book-shape-lab.js index f32780f..90123b1 100644 --- a/public/js/webgl-book-shape-lab.js +++ b/public/js/webgl-book-shape-lab.js @@ -135,7 +135,7 @@ function setPageCount(value) { function rebuildBook() { clearGroup(book); - const coverDepth = 2.34; + const coverDepth = 2.30; const coverThickness = BOOK_PROFILE.coverThickness; const pageWidth = 1.62; const pageDepth = 2.24; @@ -167,7 +167,7 @@ function addCoverAssembly(pageWidth, depth, thickness, spineWidth) { } function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth) { - const overhang = 0.055; + const overhang = 0.13; const spineHalf = spineWidth * 0.5; const hingeInset = 0.07; const outerX = pageWidth + overhang; @@ -276,7 +276,7 @@ function spineCurvePoint(t, spineWidth) { function simulatePageLines(bundleCount, pageWidth, spineWidth) { const lines = []; - const segments = 16; + const segments = 24; const stepLength = pageWidth / segments; const entries = []; const spineSamples = sampleSpineByArc(bundleCount, spineWidth); @@ -299,23 +299,16 @@ function simulatePageLines(bundleCount, pageWidth, spineWidth) { .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); - + let lowerLine = null; 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] }); + const target = restingTarget(side, pageWidth, rank, sideEntries.length); + const points = buildOptimizedSplineLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount); + const line = { index: entry.index, t: entry.t, side, anchor, points, endpoint: points[points.length - 1] }; + lines.push(line); + lowerLine = line; }); }); - enforceStackConstraints(lines, stepLength, bundleCount); return lines; } @@ -338,7 +331,7 @@ function measureStackSpacingError(lines) { 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) { + for (let col = 1; 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)); } @@ -398,6 +391,148 @@ function restingTarget(side, pageWidth, rank, sideCount) { return { x, y }; } +function buildOptimizedSplineLine(anchor, target, lowerLine, side, segments, stepLength, bundleCount) { + const points = initialSplineGuess(anchor, target, lowerLine, side, segments, bundleCount); + const iterations = lowerLine ? 120 : 90; + for (let iteration = 0; iteration < iterations; iteration += 1) { + smoothSplineCurvature(points, iteration < iterations * 0.65 ? 0.34 : 0.14); + attractSplineToSupport(points, lowerLine, side, bundleCount, lowerLine ? 0.46 : 0.18); + attractEndpoint(points, target, 0.08); + enforceOutwardProgress(points, side); + enforceLineLength(points, anchor, stepLength, 5); + keepSplineOnSupport(points, lowerLine, side, bundleCount); + enforceLineLength(points, anchor, stepLength, 3); + } + smoothSplineCurvature(points, 0.08); + enforceLineLength(points, anchor, stepLength, 12); + keepSplineOnSupport(points, lowerLine, side, bundleCount); + enforceLineLength(points, anchor, stepLength, 12); + enforceForwardLineLength(points, anchor, stepLength); + return points; +} + +function initialSplineGuess(anchor, target, lowerLine, side, segments, bundleCount) { + const points = []; + for (let i = 0; i <= segments; i += 1) { + const u = i / segments; + if (lowerLine) { + const lowerPoint = lowerLine.points[i]; + const normal = upwardNormalAt(lowerLine.points, i); + const anchorBlend = Math.pow(1 - u, 2.2); + points.push({ + x: lowerPoint.x + normal.x * BOOK_PROFILE.bundleSpacing + (anchor.x - (lowerLine.anchor.x + normal.x * BOOK_PROFILE.bundleSpacing)) * anchorBlend, + y: lowerPoint.y + normal.y * BOOK_PROFILE.bundleSpacing + (anchor.y - (lowerLine.anchor.y + normal.y * BOOK_PROFILE.bundleSpacing)) * anchorBlend + }); + } else { + const crown = 0.026 * Math.sin(Math.PI * u) * Math.pow(u, 0.7); + points.push({ + x: THREE.MathUtils.lerp(anchor.x, target.x, u), + y: THREE.MathUtils.lerp(anchor.y, target.y, u) + crown + }); + } + points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); + points[i].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + coverClearance(bundleCount)); + } + points[0].x = anchor.x; + points[0].y = anchor.y; + return points; +} + +function smoothSplineCurvature(points, strength) { + const nextPoints = points.map((point) => ({ x: point.x, y: point.y })); + for (let i = 1; i < points.length - 1; i += 1) { + const midpointX = (points[i - 1].x + points[i + 1].x) * 0.5; + const midpointY = (points[i - 1].y + points[i + 1].y) * 0.5; + nextPoints[i].x = THREE.MathUtils.lerp(points[i].x, midpointX, strength); + nextPoints[i].y = THREE.MathUtils.lerp(points[i].y, midpointY, strength); + } + for (let i = 1; i < points.length - 1; i += 1) { + points[i].x = nextPoints[i].x; + points[i].y = nextPoints[i].y; + } +} + +function attractSplineToSupport(points, lowerLine, side, bundleCount, strength) { + for (let i = 1; i < points.length; i += 1) { + if (lowerLine) { + const support = lowerLine.points[i]; + const normal = upwardNormalAt(lowerLine.points, i); + const targetX = support.x + normal.x * BOOK_PROFILE.bundleSpacing; + const targetY = support.y + normal.y * BOOK_PROFILE.bundleSpacing; + points[i].x = THREE.MathUtils.lerp(points[i].x, targetX, strength); + points[i].y = THREE.MathUtils.lerp(points[i].y, targetY, strength); + } else { + const floor = coverTopYAtX(points[i].x) + coverClearance(bundleCount); + points[i].y = THREE.MathUtils.lerp(points[i].y, floor, strength * 0.12); + } + points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); + } +} + +function attractEndpoint(points, target, strength) { + const end = points[points.length - 1]; + end.x = THREE.MathUtils.lerp(end.x, target.x, strength); + end.y = THREE.MathUtils.lerp(end.y, target.y, strength); +} + +function enforceOutwardProgress(points, side) { + for (let i = 1; i < points.length; i += 1) { + if (side < 0) { + points[i].x = Math.min(points[i].x, points[i - 1].x - 0.0005); + } else { + points[i].x = Math.max(points[i].x, points[i - 1].x + 0.0005); + } + } +} + +function keepSplineOnSupport(points, lowerLine, side, bundleCount) { + for (let i = 1; i < points.length; i += 1) { + points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); + points[i].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + coverClearance(bundleCount)); + if (lowerLine) { + const support = lowerLine.points[i]; + const normal = upwardNormalAt(lowerLine.points, i); + const dx = points[i].x - support.x; + const dy = points[i].y - support.y; + const normalDistance = dx * normal.x + dy * normal.y; + if (normalDistance < BOOK_PROFILE.bundleSpacing) { + points[i].x += normal.x * (BOOK_PROFILE.bundleSpacing - normalDistance); + points[i].y += normal.y * (BOOK_PROFILE.bundleSpacing - normalDistance); + } + const nearest = closestPointOnPolyline(points[i], lowerLine.points); + const nearestDistance = Math.hypot(points[i].x - nearest.x, points[i].y - nearest.y); + if (nearestDistance < BOOK_PROFILE.bundleSpacing * 0.72) { + points[i].y += BOOK_PROFILE.bundleSpacing * 0.72 - nearestDistance; + } + } + } +} + +function closestPointOnPolyline(point, polyline) { + let best = polyline[0]; + let bestDistance = Number.POSITIVE_INFINITY; + for (let i = 0; i < polyline.length - 1; i += 1) { + const candidate = closestPointOnSegment(point, polyline[i], polyline[i + 1]); + const distance = Math.hypot(point.x - candidate.x, point.y - candidate.y); + if (distance < bestDistance) { + best = candidate; + bestDistance = distance; + } + } + return best; +} + +function closestPointOnSegment(point, a, b) { + const dx = b.x - a.x; + const dy = b.y - a.y; + const lengthSquared = dx * dx + dy * dy || 0.0001; + const t = THREE.MathUtils.clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / lengthSquared, 0, 1); + return { + x: a.x + dx * t, + y: a.y + dy * t + }; +} + function relaxPageLine(points, anchor, stepLength, side, local, bundleCount) { const gravity = 0.00072; const stackPressure = 0.0011 * (1 - local); @@ -464,14 +599,31 @@ function constrainSegment(a, b, length, anchorA) { b.y -= dy * correction * 0.5; } +function enforceForwardLineLength(points, anchor, stepLength) { + points[0].x = anchor.x; + points[0].y = anchor.y; + for (let i = 1; i < points.length; i += 1) { + const previous = points[i - 1]; + const current = points[i]; + const dx = current.x - previous.x; + const dy = current.y - previous.y; + const distance = Math.hypot(dx, dy) || 0.0001; + current.x = previous.x + dx / distance * stepLength; + current.y = previous.y + dy / distance * stepLength; + } +} + 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].y = Math.max(points[i].y, coverTopYAtX(points[i].x) + coverClearance(bundleCount)); points[i].x = side < 0 ? Math.min(points[i].x, -0.01) : Math.max(points[i].x, 0.01); } } +function coverClearance(bundleCount) { + return BOOK_PROFILE.paperContactOffset + 0.0002 * bundleCount; +} + function enforceStackConstraints(lines, stepLength, bundleCount) { const iterations = 44; [-1, 1].forEach((side) => { @@ -563,7 +715,7 @@ function currentSpineHalf() { 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; + const z = depth * 0.5 + 0.006; 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)); @@ -589,8 +741,8 @@ function createLoftedLineBody(lines, depth) { positions.push(point.x, point.y, z); return index; }; - const front = smoothLines.map((points) => points.map((point) => push(point, depth * 0.5 + 0.09))); - const back = smoothLines.map((points) => points.map((point) => push(point, -depth * 0.5 + 0.09))); + const front = smoothLines.map((points) => points.map((point) => push(point, depth * 0.5))); + const back = smoothLines.map((points) => points.map((point) => push(point, -depth * 0.5))); for (let row = 0; row < smoothLines.length - 1; row += 1) { for (let col = 0; col < smoothLines[row].length - 1; col += 1) { indices.push(front[row][col], front[row + 1][col], front[row][col + 1]); @@ -624,7 +776,7 @@ function createLoftedLineBody(lines, depth) { } function createEndpointPolyline(lines, depth) { - const points = lines.map((line) => new THREE.Vector3(line.endpoint.x, line.endpoint.y, depth * 0.5 + 0.112)); + const points = lines.map((line) => new THREE.Vector3(line.endpoint.x, line.endpoint.y, depth * 0.5 + 0.008)); return new THREE.BufferGeometry().setFromPoints(points); }