import * as THREE from 'https://esm.sh/three@0.165.0'; export const PROCEDURAL_BOOK = { PAGE_COUNT_MIN: 40, PAGE_COUNT_MAX: 500, PAGE_COUNT_STEP: 10, PAGE_LINE_SEGMENTS: 48, PAGE_DEPTH: 2.24, PAGE_WIDTH: 2.24 * 2 / 3, COVER_DEPTH: 2.30, OPEN_SEAM_GAP: 0.003, PROFILE: { tableY: 0, coverThickness: 0.03, raisedHingeY: 0.056, paperContactOffset: 0.0012, singlePageCoverGap: 0.006, bundleSpacing: 0.014 } }; PROCEDURAL_BOOK.PAGE_SPLINE_LENGTH = PROCEDURAL_BOOK.PAGE_WIDTH; PROCEDURAL_BOOK.COVER_OVERHANG = (PROCEDURAL_BOOK.COVER_DEPTH - PROCEDURAL_BOOK.PAGE_DEPTH) * 0.5; export function snapProceduralPageCount(value) { const parsed = Number.parseFloat(value); if (!Number.isFinite(parsed)) return 240; return THREE.MathUtils.clamp( Math.round(parsed / PROCEDURAL_BOOK.PAGE_COUNT_STEP) * PROCEDURAL_BOOK.PAGE_COUNT_STEP, PROCEDURAL_BOOK.PAGE_COUNT_MIN, PROCEDURAL_BOOK.PAGE_COUNT_MAX ); } export function createProceduralBookModel(options = {}) { const context = createBookContext(options); const group = new THREE.Group(); const model = calculateBookModel(context); group.userData.proceduralBookModel = model; addCoverAssembly(group, context, model); addClothSpine(group, context, model); addSimulatedStackBodies(group, context, model); tagBookMeshes(group, context); return { group, model }; } function createBookContext(options) { const configureMaterial = typeof options.configureMaterial === 'function' ? options.configureMaterial : () => {}; const maxAnisotropy = options.maxAnisotropy ?? 8; const materials = { cover: options.materials?.cover ?? new THREE.MeshStandardMaterial({ color: 0x25130b, roughness: 0.58, metalness: 0.02, envMapIntensity: 0.18, side: THREE.DoubleSide }), hinge: options.materials?.hinge ?? options.materials?.cover ?? null, coverSpineBase: options.materials?.coverSpineBase ?? options.materials?.cover ?? null, coverEdge: options.materials?.coverEdge ?? options.materials?.cover ?? null, spine: options.materials?.spine ?? new THREE.MeshStandardMaterial({ color: 0x9c1f1f, roughness: 0.78, metalness: 0, envMapIntensity: 0.08, side: THREE.DoubleSide }), pageTop: options.materials?.pageTop ?? new THREE.MeshStandardMaterial({ color: 0xf1dfba, roughness: 0.82, metalness: 0, envMapIntensity: 0.08, side: THREE.DoubleSide }), leftPage: options.materials?.leftPage ?? options.materials?.pageTop ?? null, rightPage: options.materials?.rightPage ?? options.materials?.pageTop ?? null }; return { readingProgress: THREE.MathUtils.clamp(Number.parseFloat(options.readingProgress ?? 0.28), 0, 1), pageCount: snapProceduralPageCount(options.pageCount ?? 240), castShadow: false, receiveShadow: false, maxAnisotropy, configureMaterial, materials, configuredMaterials: new WeakSet(), activeSpineHalf: 0.08, activeCoverOuterX: 0.08 + PROCEDURAL_BOOK.PAGE_WIDTH + PROCEDURAL_BOOK.COVER_OVERHANG }; } function calculateBookModel(context) { const pageWidth = PROCEDURAL_BOOK.PAGE_WIDTH; const pageDepth = PROCEDURAL_BOOK.PAGE_DEPTH; const coverDepth = PROCEDURAL_BOOK.COVER_DEPTH; const bundleCount = Math.max(4, Math.round(context.pageCount / 10)); const spineWidth = calculateSpineWidth(bundleCount); const leftCount = calculateLeftBundleCount(context, bundleCount); const spineHalf = spineArcHalf(spineWidth); const foreEdgeX = spineHalf + pageWidth; const coverOuterX = spineHalf + pageWidth + PROCEDURAL_BOOK.COVER_OVERHANG; const bundleSpacing = calculateBundleSpacing(bundleCount, spineWidth, leftCount); context.activeSpineHalf = spineHalf; context.activeCoverOuterX = coverOuterX; const lines = simulatePageLines(context, bundleCount, pageWidth, spineWidth, foreEdgeX, bundleSpacing, leftCount); return { pageWidth, pageDepth, coverDepth, bundleCount, spineWidth, spineHalf, foreEdgeX, coverOuterX, bundleSpacing, leftCount, lines }; } function addCoverAssembly(group, context, model) { const coverMaterials = [ context.materials.cover, context.materials.hinge ?? context.materials.cover, context.materials.coverSpineBase ?? context.materials.cover, context.materials.coverEdge ?? context.materials.cover ]; const mesh = new THREE.Mesh( createCoverAssemblyGeometry(model.pageWidth, model.coverDepth, PROCEDURAL_BOOK.PROFILE.coverThickness, model.spineWidth, model.coverOuterX), coverMaterials ); mesh.userData.bookPart = 'cover'; configurePartMaterial(context, coverMaterials[0], 'cover'); configurePartMaterial(context, coverMaterials[1], 'hinge'); configurePartMaterial(context, coverMaterials[2], 'coverSpineBase'); configurePartMaterial(context, coverMaterials[3], 'coverEdge'); group.add(mesh); } function createCoverAssemblyGeometry(pageWidth, depth, thickness, spineWidth, coverOuterX) { const section = coverProfilePoints(spineWidth, coverOuterX); const positions = []; const uvs = []; const indices = []; const groups = []; const vertexCache = new Map(); const halfDepth = depth * 0.5; const edgeRadius = Math.min(thickness * 0.5, PROCEDURAL_BOOK.COVER_OVERHANG * 0.5); const cornerSteps = 8; const sideSteps = 18; const edgeSteps = 18; const leftX = section[0].x; const rightX = section[section.length - 1].x; const push = (point) => { const key = [ point.x.toFixed(5), point.y.toFixed(5), point.z.toFixed(5), point.u.toFixed(5), point.v.toFixed(5) ].join('|'); const cached = vertexCache.get(key); if (cached !== undefined) return cached; const index = positions.length / 3; positions.push(point.x, point.y, point.z); uvs.push(point.u, point.v); vertexCache.set(key, index); return index; }; const pushGroup = (materialIndex, quadIndices) => { const start = indices.length; indices.push(...quadIndices); groups.push({ start, count: quadIndices.length, materialIndex }); }; const addQuad = (materialIndex, a, b, c, d) => { const aIndex = push(a); const bIndex = push(b); const cIndex = push(c); const dIndex = push(d); pushGroup(materialIndex, [aIndex, bIndex, cIndex, cIndex, bIndex, dIndex]); }; const coverYAtX = (x) => { if (x <= leftX) return section[0].y; for (let index = 0; index < section.length - 1; index += 1) { const from = section[index]; const to = section[index + 1]; if (x <= to.x) { const t = (x - from.x) / (to.x - from.x || 1); return THREE.MathUtils.lerp(from.y, to.y, t); } } return section[section.length - 1].y; }; const materialAtX = (x) => { for (let index = 0; index < section.length - 1; index += 1) { if (x <= section[index + 1].x) return coverSegmentMaterialIndex(index); } return coverSegmentMaterialIndex(section.length - 2); }; const uAt = (x) => (x - leftX) / (rightX - leftX || 1); const vAt = (z) => (z + halfDepth) / depth; const pointAt = (x, y, z) => ({ x, y, z, u: uAt(x), v: vAt(z) }); const edgeProfile = Array.from({ length: edgeSteps + 1 }, (_, index) => { const angle = Math.PI * 0.5 - (index / edgeSteps) * Math.PI; return { inset: edgeRadius - Math.cos(angle) * edgeRadius, yOffset: -edgeRadius + Math.sin(angle) * edgeRadius }; }); const roundedRectContour = (inset) => { const hx = rightX - inset; const hz = halfDepth - inset; const cornerRadius = Math.max(0.0001, edgeRadius - inset); const points = []; const pushLinear = (fromX, fromZ, toX, toZ, steps) => { for (let step = 0; step <= steps; step += 1) { if (points.length && step === 0) continue; const t = step / steps; points.push({ x: THREE.MathUtils.lerp(fromX, toX, t), z: THREE.MathUtils.lerp(fromZ, toZ, t) }); } }; const pushCorner = (centerX, centerZ, fromAngle, toAngle) => { for (let step = 1; step <= cornerSteps; step += 1) { const t = step / cornerSteps; const angle = THREE.MathUtils.lerp(fromAngle, toAngle, t); points.push({ x: centerX + Math.cos(angle) * cornerRadius, z: centerZ + Math.sin(angle) * cornerRadius }); } }; pushLinear(-hx + cornerRadius, hz, hx - cornerRadius, hz, sideSteps); pushCorner(hx - cornerRadius, hz - cornerRadius, Math.PI * 0.5, 0); pushLinear(hx, hz - cornerRadius, hx, -hz + cornerRadius, sideSteps); pushCorner(hx - cornerRadius, -hz + cornerRadius, 0, -Math.PI * 0.5); pushLinear(hx - cornerRadius, -hz, -hx + cornerRadius, -hz, sideSteps); pushCorner(-hx + cornerRadius, -hz + cornerRadius, -Math.PI * 0.5, -Math.PI); pushLinear(-hx, -hz + cornerRadius, -hx, hz - cornerRadius, sideSteps); pushCorner(-hx + cornerRadius, hz - cornerRadius, Math.PI, Math.PI * 0.5); return points; }; const profileContours = edgeProfile.map((profile) => roundedRectContour(profile.inset).map((point) => { const topY = coverYAtX(point.x); return pointAt(point.x, topY + profile.yOffset, point.z); })); const topXs = [ leftX + edgeRadius, ...section.slice(1, -1).map((point) => point.x), rightX - edgeRadius ].sort((a, b) => a - b); const topZs = [-halfDepth + edgeRadius, halfDepth - edgeRadius]; for (let index = 0; index < topXs.length - 1; index += 1) { const left = topXs[index]; const right = topXs[index + 1]; const materialIndex = materialAtX((left + right) * 0.5); addQuad( materialIndex, pointAt(left, coverYAtX(left), topZs[1]), pointAt(right, coverYAtX(right), topZs[1]), pointAt(left, coverYAtX(left), topZs[0]), pointAt(right, coverYAtX(right), topZs[0]) ); addQuad( 3, pointAt(left, coverYAtX(left) - thickness, topZs[0]), pointAt(right, coverYAtX(right) - thickness, topZs[0]), pointAt(left, coverYAtX(left) - thickness, topZs[1]), pointAt(right, coverYAtX(right) - thickness, topZs[1]) ); } for (let profileIndex = 0; profileIndex < profileContours.length - 1; profileIndex += 1) { const current = profileContours[profileIndex]; const next = profileContours[profileIndex + 1]; for (let pointIndex = 0; pointIndex < current.length; pointIndex += 1) { const nextPointIndex = (pointIndex + 1) % current.length; addQuad(3, current[pointIndex], current[nextPointIndex], next[pointIndex], next[nextPointIndex]); } } const geometry = new THREE.BufferGeometry(); geometry.setIndex(indices); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); geometry.clearGroups(); groups.forEach((group) => geometry.addGroup(group.start, group.count, group.materialIndex)); geometry.computeVertexNormals(); return geometry; } function coverSegmentMaterialIndex(segmentIndex) { if (segmentIndex === 1 || segmentIndex === 3) return 1; if (segmentIndex === 2) return 2; return 0; } function addClothSpine(group, context, model) { const mesh = new THREE.Mesh(createClothSpineGeometry(model.pageDepth, model.spineWidth), context.materials.spine); mesh.userData.bookPart = 'spine'; configurePartMaterial(context, mesh.material, 'spine'); group.add(mesh); } function createClothSpineGeometry(depth, spineWidth) { const profile = []; for (let i = 0; i <= 32; i += 1) { profile.push(spineCurvePoint(i / 32, 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; }; profile.forEach((point) => { front.push(push(point, depth * 0.5 + 0.024)); back.push(push(point, -depth * 0.5 - 0.024)); }); 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 addSimulatedStackBodies(group, context, model) { [-1, 1].forEach((side) => { const sideLines = model.lines.filter((line) => line.side === side); if (!sideLines.length) return; const isSinglePage = sideLines.length === 1; const bodyLines = isSinglePage ? createSinglePageBodyLines(context, model, sideLines[0]) : sideLines; const mesh = new THREE.Mesh(createLoftedLineBody(model, bodyLines, model.pageDepth), createStackBodyMaterials(context, model, side, isSinglePage)); mesh.userData.bookPart = side < 0 ? 'leftPages' : 'rightPages'; group.add(mesh); }); } function createStackBodyMaterials(context, model, side, isSinglePage = false) { const baseColor = side < 0 ? '#d8c7a4' : '#e7d6b4'; const lineColor = '#9a8058'; const layerTexture = createStackLayerTexture(context, model.bundleCount, baseColor, lineColor); const surface = new THREE.MeshStandardMaterial({ map: layerTexture, roughness: 0.84, metalness: 0, envMapIntensity: 0.08, side: THREE.DoubleSide }); const edge = surface.clone(); edge.map = layerTexture; const bottom = context.materials.pageTop.clone(); const top = side < 0 && context.materials.leftPage ? context.materials.leftPage : side > 0 && context.materials.rightPage ? context.materials.rightPage : context.materials.pageTop.clone(); if (isSinglePage) { const singleSurface = context.materials.pageTop.clone(); const singleEdge = context.materials.pageTop.clone(); const singleBottom = context.materials.pageTop.clone(); [singleSurface, singleEdge, singleBottom, top].forEach((material) => configurePartMaterial(context, material, 'pages')); configurePartMaterial(context, context.materials.spine, 'spine'); return [singleSurface, singleEdge, singleBottom, top, context.materials.spine]; } [surface, edge, bottom, top].forEach((material) => configurePartMaterial(context, material, 'pages')); configurePartMaterial(context, context.materials.spine, 'spine'); return [surface, edge, bottom, top, context.materials.spine]; } function createStackLayerTexture(context, bundleCount, baseColor, lineColor) { const canvas = document.createElement('canvas'); canvas.width = 2048; canvas.height = 1024; const context2d = canvas.getContext('2d'); context2d.fillStyle = baseColor; context2d.fillRect(0, 0, canvas.width, canvas.height); context2d.strokeStyle = lineColor; context2d.globalAlpha = 0.95; context2d.lineWidth = 4.2; context2d.lineCap = 'square'; for (let row = 0; row < bundleCount; row += 1) { const v = bundleCount <= 1 ? 0.5 : row / (bundleCount - 1); const y = (1 - v) * canvas.height; context2d.beginPath(); context2d.moveTo(-8, y); context2d.lineTo(canvas.width + 8, y); context2d.stroke(); } const texture = new THREE.CanvasTexture(canvas); texture.colorSpace = THREE.SRGBColorSpace; texture.anisotropy = context.maxAnisotropy; texture.minFilter = THREE.LinearMipmapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.generateMipmaps = true; texture.needsUpdate = true; return texture; } function createLoftedLineBody(model, lines, depth) { const positions = []; const uvs = []; const indices = []; const smoothLines = lines.map((line) => line.points); const bundleCount = model.bundleCount; const push = (point, z, uv) => { const index = positions.length / 3; positions.push(point.x, point.y, z); uvs.push(uv.u, uv.v); return index; }; const rowUv = (row) => { const line = lines[row]; const index = line.isHairPage ? (line.side < 0 ? 0 : bundleCount - 1) : line.index; return bundleCount <= 1 ? 0.5 : index / (bundleCount - 1); }; const lineUv = (row, col) => ({ u: smoothLines[row].length <= 1 ? 0.5 : col / (smoothLines[row].length - 1), v: rowUv(row) }); const sideUv = (row, z) => ({ u: (z + depth * 0.5) / depth, v: rowUv(row) }); const front = smoothLines.map((points, row) => points.map((point, col) => push(point, depth * 0.5, lineUv(row, col)))); const back = smoothLines.map((points, row) => points.map((point, col) => push(point, -depth * 0.5, lineUv(row, col)))); const capUv = (point, z, col, row) => ({ u: smoothLines[row].length <= 1 ? 0.5 : col / (smoothLines[row].length - 1), v: (z + depth * 0.5) / depth }); const topCapUv = (point, z, col, row) => { const side = lines[row]?.side ?? 1; const pageDistance = side > 0 ? point.x - model.spineHalf : -model.spineHalf - point.x; const pageU = THREE.MathUtils.clamp(pageDistance / model.pageWidth, 0, 1); return { u: side < 0 ? 1 - pageU : pageU, v: 1 - ((z + depth * 0.5) / depth) }; }; 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]); indices.push(front[row][col + 1], front[row + 1][col], front[row + 1][col + 1]); indices.push(back[row][col], back[row][col + 1], back[row + 1][col]); indices.push(back[row][col + 1], back[row + 1][col + 1], back[row + 1][col]); } } const sideStart = indices.length; for (let row = 0; row < smoothLines.length - 1; row += 1) { const last = smoothLines[row].length - 1; const a = smoothLines[row][last]; const b = smoothLines[row + 1][last]; const frontA = push(a, depth * 0.5, sideUv(row, depth * 0.5)); const frontB = push(b, depth * 0.5, sideUv(row + 1, depth * 0.5)); const backA = push(a, -depth * 0.5, sideUv(row, -depth * 0.5)); const backB = push(b, -depth * 0.5, sideUv(row + 1, -depth * 0.5)); indices.push(frontA, frontB, backA); indices.push(frontB, backB, backA); } for (let row = 0; row < smoothLines.length - 1; row += 1) { const a = smoothLines[row][0]; const b = smoothLines[row + 1][0]; const frontA = push(a, depth * 0.5, sideUv(row, depth * 0.5)); const frontB = push(b, depth * 0.5, sideUv(row + 1, depth * 0.5)); const backA = push(a, -depth * 0.5, sideUv(row, -depth * 0.5)); const backB = push(b, -depth * 0.5, sideUv(row + 1, -depth * 0.5)); indices.push(frontA, backA, frontB); indices.push(frontB, backA, backB); } const bottomRow = 0; const topRow = smoothLines.length - 1; const bottomStart = indices.length; const bottomFront = smoothLines[bottomRow].map((point, col) => push(point, depth * 0.5, capUv(point, depth * 0.5, col, bottomRow))); const bottomBack = smoothLines[bottomRow].map((point, col) => push(point, -depth * 0.5, capUv(point, -depth * 0.5, col, bottomRow))); for (let col = 0; col < smoothLines[bottomRow].length - 1; col += 1) { const frontA = bottomFront[col]; const frontB = bottomFront[col + 1]; const backA = bottomBack[col]; const backB = bottomBack[col + 1]; const bottomSide = lines[bottomRow]?.side ?? 1; if (bottomSide > 0) { indices.push(frontA, frontB, backA); indices.push(frontB, backB, backA); } else { indices.push(frontA, backA, frontB); indices.push(frontB, backA, backB); } } const topStart = indices.length; const topFront = smoothLines[topRow].map((point, col) => push(point, depth * 0.5, topCapUv(point, depth * 0.5, col, topRow))); const topBack = smoothLines[topRow].map((point, col) => push(point, -depth * 0.5, topCapUv(point, -depth * 0.5, col, topRow))); const topGroups = []; for (let col = 0; col < smoothLines[topRow].length - 1; col += 1) { const groupStart = indices.length; const frontA = topFront[col]; const frontB = topFront[col + 1]; const backA = topBack[col]; const backB = topBack[col + 1]; const topSide = lines[topRow]?.side ?? 1; if (topSide > 0) { indices.push(frontA, frontB, backA); indices.push(frontB, backB, backA); } else { indices.push(frontA, backA, frontB); indices.push(frontB, backA, backB); } topGroups.push({ start: groupStart, count: indices.length - groupStart, materialIndex: 3 }); } const geometry = new THREE.BufferGeometry(); geometry.setIndex(indices); geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)); geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2)); geometry.clearGroups(); geometry.addGroup(0, sideStart, 0); geometry.addGroup(sideStart, bottomStart - sideStart, 1); geometry.addGroup(bottomStart, topStart - bottomStart, 2); topGroups.forEach((group) => geometry.addGroup(group.start, group.count, group.materialIndex)); geometry.computeVertexNormals(); return geometry; } function createSinglePageBodyLines(context, model, line) { const topPoints = line.points.map((point) => ({ x: point.x, y: Math.max( coverTopYAtX(context, point.x) + coverClearance(model.bundleCount) + PROCEDURAL_BOOK.PROFILE.singlePageCoverGap + model.bundleSpacing, point.y ) })); const supportPoints = topPoints.map((point) => ({ x: point.x, y: Math.max(coverTopYAtX(context, point.x) + coverClearance(model.bundleCount) + PROCEDURAL_BOOK.PROFILE.singlePageCoverGap, point.y - model.bundleSpacing) })); return [ { ...line, points: supportPoints, endpoint: supportPoints[supportPoints.length - 1] }, { ...line, points: topPoints, endpoint: topPoints[topPoints.length - 1] } ]; } function simulatePageLines(context, bundleCount, pageWidth, spineWidth, foreEdgeX, bundleSpacing, leftCount) { const lines = []; const segments = PROCEDURAL_BOOK.PAGE_LINE_SEGMENTS; const segmentLengths = pageSegmentLengths(pageWidth, segments); const entries = []; const spineArc = buildSpineArcSamples(spineWidth); const rightCount = bundleCount - leftCount; const leftSpan = Math.max(0, leftCount - 1) * bundleSpacing; const seamLeftLength = leftSpan; const seamRightLength = seamLeftLength + PROCEDURAL_BOOK.OPEN_SEAM_GAP; for (let index = 0; index < bundleCount; index += 1) { 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) .sort((a, b) => side < 0 ? a.t - b.t : b.t - a.t); let lowerLine = null; sideEntries.forEach((entry, rank) => { const anchor = spineCurvePoint(entry.t, spineWidth); const target = restingTarget(side, foreEdgeX, rank, sideEntries.length, bundleSpacing); const points = buildSupportSolvedLine(context, anchor, target, lowerLine, side, segments, segmentLengths, 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; }); }); return lines; } function buildSupportSolvedLine(context, anchor, target, lowerLine, side, segments, segmentLengths, bundleCount, bundleSpacing) { const points = [{ x: anchor.x, y: anchor.y }]; const supportPath = createLineSupportPath(context, anchor, lowerLine, side, bundleCount, bundleSpacing); const support = createMeasuredPath(supportPath); let cursor = 0; for (let index = 1; index <= segments; index += 1) { const next = nextPointOnSupportPath(support, cursor, points[index - 1], segmentLengths[index - 1], side, target); points.push(next.point); cursor = next.cursor; } return points; } function createLineSupportPath(context, anchor, lowerLine, side, bundleCount, bundleSpacing) { const path = [{ x: anchor.x, y: anchor.y }]; const source = lowerLine ? offsetPaperSupportPath(lowerLine.points, bundleSpacing) : coverBaseSupportPath(context, anchor, side, bundleCount); source.forEach((point) => { if (side * (point.x - anchor.x) >= -0.0001) { path.push(point); } }); return compactPath(path); } function coverBaseSupportPath(context, anchor, side, bundleCount) { const path = []; const clearance = coverClearance(bundleCount); const steps = 16; for (let sample = 1; sample <= steps; sample += 1) { const u = sample / steps; const t = side > 0 ? THREE.MathUtils.lerp(anchor.t, 1, u) : THREE.MathUtils.lerp(anchor.t, 0, u); const point = spineCurvePoint(t, context.activeSpineHalf / 0.42); path.push({ x: point.x, y: point.y + clearance }); } const profile = coverProfilePointsFromFrame(context.activeSpineHalf, context.activeCoverOuterX) .filter((point) => side < 0 ? point.x <= -context.activeSpineHalf : point.x >= context.activeSpineHalf) .sort((a, b) => side < 0 ? b.x - a.x : a.x - b.x); profile.forEach((point) => path.push({ x: point.x, y: point.y + clearance })); return path; } function nextPointOnSupportPath(support, cursor, previous, segmentLength, side, target) { let segmentIndex = Math.max(0, support.lengths.findIndex((length) => length > cursor) - 1); if (segmentIndex < 0) segmentIndex = support.points.length - 2; let startDistance = cursor; let from = pointAtMeasuredPathDistance(support, cursor); while (segmentIndex < support.points.length - 1) { const to = support.points[segmentIndex + 1]; const endDistance = support.lengths[segmentIndex + 1]; const hit = circleSegmentIntersection(previous, from, to, segmentLength); if (hit !== null && side * (hit.point.x - previous.x) >= -0.000001) { return { point: hit.point, cursor: THREE.MathUtils.lerp(startDistance, endDistance, hit.t) }; } segmentIndex += 1; from = support.points[segmentIndex]; startDistance = support.lengths[segmentIndex]; } return extendSupportPathEnd(support, previous, segmentLength, side, target); } function circleSegmentIntersection(center, from, to, radius) { const dx = to.x - from.x; const dy = to.y - from.y; const fx = from.x - center.x; const fy = from.y - center.y; const a = dx * dx + dy * dy; const b = 2 * (fx * dx + fy * dy); const c = fx * fx + fy * fy - radius * radius; const discriminant = b * b - 4 * a * c; if (a <= 0 || discriminant < 0) return null; const root = Math.sqrt(discriminant); const t = [(-b - root) / (2 * a), (-b + root) / (2 * a)] .filter((value) => value >= -0.000001 && value <= 1.000001) .sort((left, right) => left - right)[0]; if (t === undefined) return null; const clamped = THREE.MathUtils.clamp(t, 0, 1); return { t: clamped, point: { x: THREE.MathUtils.lerp(from.x, to.x, clamped), y: THREE.MathUtils.lerp(from.y, to.y, clamped) } }; } function extendSupportPathEnd(support, previous, segmentLength, side, target) { const last = support.points[support.points.length - 1]; const before = support.points[Math.max(0, support.points.length - 2)]; const supportDirection = normalizedVector(last.x - before.x, last.y - before.y); const targetDirection = normalizedVector(target.x - previous.x, target.y - previous.y); const direction = side * supportDirection.x >= 0.05 ? supportDirection : targetDirection; return { point: { x: previous.x + direction.x * segmentLength, y: previous.y + direction.y * segmentLength }, cursor: support.totalLength }; } function createMeasuredPath(points) { const lengths = [0]; for (let index = 1; index < points.length; index += 1) { const previous = points[index - 1]; const point = points[index]; lengths[index] = lengths[index - 1] + Math.hypot(point.x - previous.x, point.y - previous.y); } return { points, lengths, totalLength: lengths[lengths.length - 1] ?? 0 }; } function pointAtMeasuredPathDistance(support, distance) { const target = THREE.MathUtils.clamp(distance, 0, support.totalLength); for (let index = 0; index < support.points.length - 1; index += 1) { if (target <= support.lengths[index + 1]) { const from = support.points[index]; const to = support.points[index + 1]; const span = support.lengths[index + 1] - support.lengths[index] || 1; const t = (target - support.lengths[index]) / span; return { x: THREE.MathUtils.lerp(from.x, to.x, t), y: THREE.MathUtils.lerp(from.y, to.y, t) }; } } return { ...support.points[support.points.length - 1] }; } function calculateSpineWidth(bundleCount) { const minimumWidth = 0.006; if (bundleCount <= 1) return minimumWidth; const targetArcLength = (bundleCount - 1) * PROCEDURAL_BOOK.PROFILE.bundleSpacing + PROCEDURAL_BOOK.OPEN_SEAM_GAP; let low = minimumWidth; let high = Math.max(minimumWidth, bundleCount * PROCEDURAL_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 PROCEDURAL_BOOK.PROFILE.bundleSpacing; return Math.max(0.001, (measureSpineArcLength(spineWidth) - PROCEDURAL_BOOK.OPEN_SEAM_GAP) / stackIntervals); } function calculateLeftBundleCount(context, bundleCount) { return THREE.MathUtils.clamp(Math.round(bundleCount * context.readingProgress), 1, bundleCount - 1); } function buildSpineArcSamples(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; } 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; } 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 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 spineCurvePoint(t, spineWidth) { const radiusX = spineArcHalf(spineWidth); const radiusY = 0.018; const baseY = PROCEDURAL_BOOK.PROFILE.tableY + PROCEDURAL_BOOK.PROFILE.coverThickness + 0.002; const theta = Math.PI * (1 - THREE.MathUtils.clamp(t, 0, 1)); return { t: THREE.MathUtils.clamp(t, 0, 1), x: Math.cos(theta) * radiusX, y: baseY + Math.sin(theta) * radiusY }; } function spineArcHalf(spineWidth) { return spineWidth * 0.42; } function hingeInset() { return Math.max(0.001, PROCEDURAL_BOOK.PROFILE.raisedHingeY - PROCEDURAL_BOOK.PROFILE.coverThickness); } function coverProfilePoints(spineWidth, coverOuterX) { return coverProfilePointsFromFrame(spineArcHalf(spineWidth), coverOuterX); } function coverProfilePointsFromFrame(spineHalf, coverOuterX) { const hingeX = spineHalf + hingeInset(); const outerTopY = PROCEDURAL_BOOK.PROFILE.tableY + PROCEDURAL_BOOK.PROFILE.coverThickness; const connectionTopY = PROCEDURAL_BOOK.PROFILE.raisedHingeY; const spineTopY = PROCEDURAL_BOOK.PROFILE.tableY + PROCEDURAL_BOOK.PROFILE.coverThickness; return [ { x: -coverOuterX, y: outerTopY }, { x: -hingeX, y: connectionTopY }, { x: -spineHalf, y: spineTopY }, { x: spineHalf, y: spineTopY }, { x: hingeX, y: connectionTopY }, { x: coverOuterX, y: outerTopY } ]; } function pageSegmentLengths(totalLength, segments) { const weights = []; for (let index = 0; index < segments; index += 1) { const u = index / Math.max(1, segments - 1); weights.push(0.32 + 1.68 * u * u); } const total = weights.reduce((sum, weight) => sum + weight, 0); return weights.map((weight) => totalLength * weight / total); } function restingTarget(side, foreEdgeX, rank, sideCount, bundleSpacing) { const local = sideCount <= 1 ? 0 : rank / (sideCount - 1); const foreCurve = 0.11 * Math.sin(Math.PI * local); const x = side * (foreEdgeX - foreCurve); const y = PROCEDURAL_BOOK.PROFILE.coverThickness + PROCEDURAL_BOOK.PROFILE.paperContactOffset + rank * bundleSpacing + 0.002 * Math.sin(Math.PI * local); return { x, y }; } function offsetPaperSupportPath(points, distance) { return points.map((point, index) => { const normal = upwardNormalAt(points, 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(context, x) { const ax = Math.abs(x); const profile = coverProfilePointsFromFrame(context.activeSpineHalf, context.activeCoverOuterX) .filter((point) => point.x >= 0) .sort((a, b) => a.x - b.x); if (ax <= profile[0].x) return profile[0].y; for (let index = 0; index < profile.length - 1; index += 1) { const from = profile[index]; const to = profile[index + 1]; if (ax <= to.x) { const t = (ax - from.x) / (to.x - from.x || 1); return THREE.MathUtils.lerp(from.y, to.y, t); } } return profile[profile.length - 1].y; } function coverClearance(bundleCount) { return PROCEDURAL_BOOK.PROFILE.paperContactOffset + 0.0002 * bundleCount; } function compactPath(path) { const compacted = []; path.forEach((point) => { const previous = compacted[compacted.length - 1]; if (!previous || Math.hypot(point.x - previous.x, point.y - previous.y) > 0.000001) { compacted.push(point); } }); return compacted; } function normalizedVector(x, y) { const length = Math.hypot(x, y) || 0.0001; return { x: x / length, y: y / length }; } function configurePartMaterial(context, material, part) { if (context.configuredMaterials.has(material)) return; context.configuredMaterials.add(material); context.configureMaterial(material, part); } function tagBookMeshes(group, context) { group.traverse((object) => { if (!object.isMesh) return; object.castShadow = false; object.receiveShadow = false; object.userData.isProceduralBookMesh = true; }); }