1038 lines
42 KiB
JavaScript
1038 lines
42 KiB
JavaScript
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 coverProfileXs = section.map((point) => point.x);
|
|
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) => {
|
|
const candidates = [];
|
|
for (let step = 0; step <= steps; step += 1) candidates.push(step / steps);
|
|
if (Math.abs(fromZ - toZ) < 0.000001) {
|
|
coverProfileXs.forEach((x) => {
|
|
const t = (x - fromX) / (toX - fromX || 1);
|
|
if (t > 0 && t < 1) candidates.push(t);
|
|
});
|
|
}
|
|
candidates
|
|
.sort((a, b) => a - b)
|
|
.forEach((t) => {
|
|
if (points.length && Math.abs(t) < 0.000001) return;
|
|
const x = THREE.MathUtils.lerp(fromX, toX, t);
|
|
const z = THREE.MathUtils.lerp(fromZ, toZ, t);
|
|
const previous = points[points.length - 1];
|
|
if (previous && Math.hypot(previous.x - x, previous.z - z) < 0.000001) return;
|
|
points.push({ x, z });
|
|
});
|
|
};
|
|
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 ? '#fff1c8' : '#fff7d7';
|
|
const lineColor = '#c39a4b';
|
|
const layerTextures = createStackLayerTextures(context, model.bundleCount, baseColor, lineColor);
|
|
const surface = new THREE.MeshStandardMaterial({
|
|
map: layerTextures.color,
|
|
normalMap: layerTextures.normal,
|
|
normalScale: new THREE.Vector2(0.034, 0.034),
|
|
roughnessMap: layerTextures.roughness,
|
|
roughness: 0.88,
|
|
metalness: 0,
|
|
envMapIntensity: 0.06,
|
|
side: THREE.DoubleSide
|
|
});
|
|
const edge = surface.clone();
|
|
edge.map = layerTextures.color;
|
|
edge.normalMap = layerTextures.normal;
|
|
edge.roughnessMap = layerTextures.roughness;
|
|
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 createStackLayerTextures(context, bundleCount, baseColor, lineColor) {
|
|
const canvas = document.createElement('canvas');
|
|
const normalCanvas = document.createElement('canvas');
|
|
const roughnessCanvas = document.createElement('canvas');
|
|
canvas.width = 2048;
|
|
canvas.height = 1024;
|
|
normalCanvas.width = canvas.width;
|
|
normalCanvas.height = canvas.height;
|
|
roughnessCanvas.width = canvas.width;
|
|
roughnessCanvas.height = canvas.height;
|
|
const context2d = canvas.getContext('2d');
|
|
const normalContext = normalCanvas.getContext('2d');
|
|
const roughnessContext = roughnessCanvas.getContext('2d');
|
|
context2d.fillStyle = baseColor;
|
|
context2d.fillRect(0, 0, canvas.width, canvas.height);
|
|
normalContext.fillStyle = 'rgb(128, 128, 255)';
|
|
normalContext.fillRect(0, 0, normalCanvas.width, normalCanvas.height);
|
|
roughnessContext.fillStyle = 'rgb(224, 224, 224)';
|
|
roughnessContext.fillRect(0, 0, roughnessCanvas.width, roughnessCanvas.height);
|
|
|
|
const irregular = (seed) => {
|
|
const value = Math.sin(seed * 12.9898 + 78.233) * 43758.5453;
|
|
return value - Math.floor(value);
|
|
};
|
|
const drawLayerLine = (v, alpha, width, normalStrength) => {
|
|
const y = (1 - v) * canvas.height;
|
|
context2d.beginPath();
|
|
context2d.moveTo(-8, y);
|
|
context2d.lineTo(canvas.width + 8, y);
|
|
context2d.strokeStyle = lineColor;
|
|
context2d.globalAlpha = alpha;
|
|
context2d.lineWidth = width;
|
|
context2d.lineCap = 'square';
|
|
context2d.stroke();
|
|
|
|
normalContext.beginPath();
|
|
normalContext.moveTo(-8, y);
|
|
normalContext.lineTo(normalCanvas.width + 8, y);
|
|
const normalByte = Math.round(128 + normalStrength * 68);
|
|
normalContext.strokeStyle = `rgb(128, ${normalByte}, 255)`;
|
|
normalContext.globalAlpha = Math.min(1, alpha * 0.85);
|
|
normalContext.lineWidth = Math.max(1, width * 0.72);
|
|
normalContext.stroke();
|
|
|
|
roughnessContext.beginPath();
|
|
roughnessContext.moveTo(-8, y);
|
|
roughnessContext.lineTo(roughnessCanvas.width + 8, y);
|
|
const roughnessByte = Math.round(218 + alpha * 28);
|
|
roughnessContext.strokeStyle = `rgb(${roughnessByte}, ${roughnessByte}, ${roughnessByte})`;
|
|
roughnessContext.globalAlpha = Math.min(1, alpha * 0.72);
|
|
roughnessContext.lineWidth = Math.max(1, width * 0.8);
|
|
roughnessContext.stroke();
|
|
};
|
|
|
|
for (let row = 0; row < bundleCount; row += 1) {
|
|
const rowV = bundleCount <= 1 ? 0.5 : row / (bundleCount - 1);
|
|
const rowAccent = 0.5 + irregular(row + 0.37) * 0.28;
|
|
drawLayerLine(rowV, rowAccent, 2.6 + irregular(row + 2.13) * 0.8, 0.58);
|
|
if (row >= bundleCount - 1) continue;
|
|
const nextV = (row + 1) / Math.max(1, bundleCount - 1);
|
|
const interval = nextV - rowV;
|
|
const microLines = 12;
|
|
for (let sub = 1; sub < microLines; sub += 1) {
|
|
const seed = row * 17.0 + sub * 3.0;
|
|
const t = THREE.MathUtils.clamp((sub + (irregular(seed) - 0.5) * 0.22) / microLines, 0.04, 0.96);
|
|
const v = rowV + interval * t;
|
|
const alpha = 0.3 + irregular(seed + 1.91) * 0.24;
|
|
const width = 1.05 + irregular(seed + 5.47) * 1.05;
|
|
drawLayerLine(v, alpha, width, 0.34 + irregular(seed + 9.17) * 0.26);
|
|
}
|
|
}
|
|
context2d.globalAlpha = 1;
|
|
normalContext.globalAlpha = 1;
|
|
roughnessContext.globalAlpha = 1;
|
|
|
|
const colorTexture = new THREE.CanvasTexture(canvas);
|
|
const normalTexture = new THREE.CanvasTexture(normalCanvas);
|
|
const roughnessTexture = new THREE.CanvasTexture(roughnessCanvas);
|
|
[colorTexture, normalTexture, roughnessTexture].forEach((texture) => {
|
|
texture.wrapS = THREE.RepeatWrapping;
|
|
texture.wrapT = THREE.RepeatWrapping;
|
|
texture.anisotropy = context.maxAnisotropy;
|
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
|
texture.magFilter = THREE.LinearFilter;
|
|
texture.generateMipmaps = true;
|
|
texture.needsUpdate = true;
|
|
});
|
|
colorTexture.colorSpace = THREE.SRGBColorSpace;
|
|
normalTexture.colorSpace = THREE.NoColorSpace;
|
|
roughnessTexture.colorSpace = THREE.NoColorSpace;
|
|
return { color: colorTexture, normal: normalTexture, roughness: roughnessTexture };
|
|
}
|
|
|
|
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;
|
|
});
|
|
}
|