Checkpoint packed spine spacing
This commit is contained in:
@@ -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 = [];
|
||||
|
||||
@@ -74,6 +74,6 @@
|
||||
<button id="fast_forward" type="button">Fast Forward</button>
|
||||
<output id="flip_count">0 / 10</output>
|
||||
</div>
|
||||
<script type="module" src="/js/webgl-book-shape-lab.js?v=stack-texture-lines-5"></script>
|
||||
<script type="module" src="/js/webgl-book-shape-lab.js?v=packed-spine-seam-2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user