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 @@
-
+