Checkpoint WebGL procedural book lab

This commit is contained in:
2026-06-05 22:51:30 +02:00
parent 80d29ed2d2
commit ca38f9ce92
5 changed files with 2004 additions and 213 deletions
+167 -90
View File
@@ -6,10 +6,49 @@ This document captures the agreed direction for the WebGL book UI. Later decisio
Build a beautiful, readable, extensible WebGL book interface for the interactive fiction UI. Build a beautiful, readable, extensible WebGL book interface for the interactive fiction UI.
The first stable milestone is a standalone 3D scene: an open book lying correctly on a polished wooden table, lit only by flickering candles, with the two existing application pages rendered as crisp dynamic textures on the actual book pages. The current stable milestone is `public/webgl-book-lab.html`: an open procedural book lying on a polished wooden table, lit by flickering candles, with the existing application page content rendered into textures that are applied to the actual top surfaces of the paper stacks.
The later product goal is a procedural book UI that supports virtual scrolling, animated page flips, dynamic page stacks, and content backfilling across spreads. The later product goal is a procedural book UI that supports virtual scrolling, animated page flips, dynamic page stacks, and content backfilling across spreads.
## Current Implementation Snapshot
This section records the current state after the procedural book integration work.
- The active standalone scene is `public/webgl-book-lab.html`.
- The intended local test URL is `http://localhost:3001/webgl-book-lab.html`.
- The current test server is expected to be the single Node process listening on port `3001`.
- The procedural book model lives in `public/js/procedural-book-model.js`.
- The WebGL lab integration lives in `public/js/webgl-book-lab.js`.
- The old fixed-box book has been removed from the lab scene.
- The new procedural book is generated from fixed page dimensions, a calculated spine arc, cover panels, hinge panels, page-stack spline lines, stack bodies, and animated flipping page geometry.
- Book page count is clamped to 40-500 pages.
- Page count changes in 10-page bundle increments.
- Each visible bundle line represents 10 pages.
- Reading progress controls the left/right split of page bundles along the spine arc.
- The spine grows with page count and pushes the covers outward; the covers do not shrink to pay for spine growth.
- `PAGE_SPLINE_LENGTH` must match `PAGE_WIDTH`.
- Cover width is derived from page width plus cover overhang.
- The spine bottom is aligned to the table plane with only a tiny render clearance.
- The book controls are in the top bar: fast backward, backward, progress slider, page-count slider, forward, and fast forward.
- Slow page flips and fast 10-page transitions are implemented.
- Fast transitions run overlapping flip animations before shifting the book by one bundle.
- The readable page content belongs on the visible top cap of the paper stacks.
- No separate floating reading-surface overlay should be added on top of the stack cap.
- Extreme progress states must use the same top-cap/material topology as normal stack states.
- Synthetic hair/support pages used for empty-side extremes must not receive page content if they are not the actual readable stack top.
- The spine arc and cloth spine must not receive page-content textures.
- Page-content textures are high-resolution canvas textures with hardcover-style margins.
- The current page canvases are deliberately smoother than the old noisy page texture.
- Page-stack side lines are generated as textures, not as free-standing line meshes.
- Stack side textures should have consistent orientation and line count on all visible stack sides.
- Cover, hinge, spine base, and cover edge materials use leather-style materials with procedural leather color and normal maps.
- The book, table, candles, and flipping pages have `castShadow` and `receiveShadow` disabled.
- Three.js/OpenGL primitive shadows must stay disabled.
- Candle shadows are implemented in the custom table/book shader path, not through Three.js shadow maps.
- Scene SSAO is present as a postprocessing pass, but the procedural book and flip page are currently excluded from that AO pass because the previous AO interaction caused misleading/inset visual artifacts.
- The table reflection path remains active and should include the book and candles.
- Temporary screenshots and generated debug images are not product assets unless explicitly promoted.
## Non-Negotiable Workflow Rules ## Non-Negotiable Workflow Rules
- Do not continue visual coding without a concrete plan for the current sprint. - Do not continue visual coding without a concrete plan for the current sprint.
@@ -26,6 +65,10 @@ The later product goal is a procedural book UI that supports virtual scrolling,
- Do not introduce fallback visual cheats when the agreed task fails. If the real technique is not working, stop, document the failure, and investigate the real technique. - Do not introduce fallback visual cheats when the agreed task fails. If the real technique is not working, stop, document the failure, and investigate the real technique.
- Do not let a fallback or diagnostic layer become part of the final composite unless it is explicitly approved as a permanent art-direction layer. - Do not let a fallback or diagnostic layer become part of the final composite unless it is explicitly approved as a permanent art-direction layer.
- When screenshot capture fails, fix the capture/test tooling before continuing visual iteration. - When screenshot capture fails, fix the capture/test tooling before continuing visual iteration.
- Do not enable `renderer.shadowMap.enabled`.
- Do not set any mesh `castShadow` or `receiveShadow` flag to `true`.
- Do not use Three.js primitive shadow maps as a fallback for candle shadows.
- Candle shadows must remain a custom shader responsibility.
## Repository And Branch Rules ## Repository And Branch Rules
@@ -68,6 +111,18 @@ During visual development:
- Text must be crisp enough for reading. - Text must be crisp enough for reading.
- Page texture resolution must be high enough that the projected text remains crisp at intended camera distances. - Page texture resolution must be high enough that the projected text remains crisp at intended camera distances.
- The old UI/html creation module must be kept for reference while the new module replaces it. - The old UI/html creation module must be kept for reference while the new module replaces it.
- The open book is made from named parts:
- Spine: the central cloth/red arc and supporting center area.
- Hinge: the angled transition from spine top height to cover/table height.
- Cover: the main leather cover panel that supports each page stack.
- Cover overhang: the small cover border extending beyond the paper stack.
- The hinge width is the width required to descend from spine-top height to spine-bottom/table height at the chosen 45 degree hinge angle.
- The cover starts at spine-top height near the hinge and descends until its outer edge touches the table.
- The cover must extend under the page stack by the same overhang along book width as along book depth.
- The paper stack must have closed bottom geometry where needed; it must not reveal empty inside faces from below.
- The paper stack top is the readable content surface.
- At progress `0.00`, `0.03`, `0.07`, and other extreme values, the surface topology and material assignment must remain consistent.
- The right page must not become a different material or broken cap merely because the left side has no full stack yet.
### Later Product Goal ### Later Product Goal
@@ -90,11 +145,23 @@ The book should become a dynamic procedural object:
- Page content must be rendered into textures applied to the actual page geometry. - Page content must be rendered into textures applied to the actual page geometry.
- No separate reading-surface overlay on top of the book model. - No separate reading-surface overlay on top of the book model.
- The visible top cap of the paper stack is the page display surface.
- Do not add a coincident floating page mesh over the stack cap to display content; that creates z-fighting risk and violates the intended architecture.
- If the page content is wrong at an extreme progress state, fix the stack-cap topology or material assignment, not by adding a second page surface.
- The left page texture must contain the full left page of the old layout. - The left page texture must contain the full left page of the old layout.
- The right page texture must contain the dynamic typeset text from the original module. - The right page texture must contain the dynamic typeset text from the original module.
- Text and lines must respect the original page proportions. - Text and lines must respect the original page proportions.
- Texture capture/generation must not silently crop the content. - Texture capture/generation must not silently crop the content.
- The page texture pipeline must support future virtualized content. - The page texture pipeline must support future virtualized content.
- Page content should use typical hardcover novel margins:
- Larger inner/gutter margin.
- Smaller outer margin.
- Comfortable top margin.
- Larger bottom margin.
- The DOM/canvas source used for page content must have the same aspect ratio and orientation as the physical page surface.
- Page textures must be smooth enough that projected content does not reveal unwanted construction lines, stack lines, or noisy paper grain.
- Neutral paper texture and content-page texture should have compatible filtering, mipmapping, and anisotropy.
- Spine arc, hinge, cloth, and support-strip materials must not accidentally receive page-content texture.
## Camera Requirements ## Camera Requirements
@@ -191,15 +258,15 @@ Wax shader:
## Shadow Requirements ## Shadow Requirements
This is currently a regression area. The candle shadow system was previously better, and it must be treated as a locked baseline unless a specific shadow-restoration sprint is active. This project does not use Three.js/OpenGL primitive shadow maps for the book, table, or candles. Shadows are owned by the custom shader pipeline.
Required behavior: Required behavior:
- Candle cast shadows must exist in the final composite. - Candle cast shadows must exist in the final composite.
- The previous working candle shadow behavior must be restored.
- All three candle bodies must cast visible shadows. - All three candle bodies must cast visible shadows.
- All three candle light/flame positions must participate. - All three candle light/flame positions must participate.
- The book must cast shadows onto the table from all three candles. - The book must cast shadows onto the table from all three candles.
- The paper stacks, visible page tops, covers, hinges, spine, candles, wax bodies, and table must use the custom shader lighting/shadow path where relevant.
- The candle shadows must respect wax translucency conceptually: wax transmission should soften/reduce the shadow rather than creating hard opaque cylinder shadows. - The candle shadows must respect wax translucency conceptually: wax transmission should soften/reduce the shadow rather than creating hard opaque cylinder shadows.
- Shadows should be soft and believable, not hard-edged cones or arbitrary blobs. - Shadows should be soft and believable, not hard-edged cones or arbitrary blobs.
- Shadows must move subtly with the animated flame/light positions. - Shadows must move subtly with the animated flame/light positions.
@@ -207,6 +274,8 @@ Required behavior:
- Reflections are not a substitute for candle cast shadows. - Reflections are not a substitute for candle cast shadows.
- SSAO is not a substitute for candle cast shadows. - SSAO is not a substitute for candle cast shadows.
- Candle shadows must not be weakened, removed, or repurposed while working on SSAO. - Candle shadows must not be weakened, removed, or repurposed while working on SSAO.
- `renderer.shadowMap.enabled` must stay `false`.
- `castShadow` and `receiveShadow` must stay `false` on scene meshes.
Debug proof: Debug proof:
@@ -217,10 +286,8 @@ Debug proof:
Implementation note: Implementation note:
- The prior candle shadow behavior was lost during SSAO/compositing work.
- The shadow debug view was not literally repurposed to show contact AO, but contact AO was incorrectly used as evidence of scene grounding while the real shadow regression remained unresolved.
- `tableDebug=shadow` must remain dedicated to candle cast-shadow proof. - `tableDebug=shadow` must remain dedicated to candle cast-shadow proof.
- If SSAO conflicts with existing cast shadows, SSAO loses until it is redesigned. - If SSAO conflicts with custom candle shadows or page readability, SSAO loses until it is redesigned.
## Ambient Occlusion Requirements ## Ambient Occlusion Requirements
@@ -240,12 +307,11 @@ Required behavior:
Current status: Current status:
- Scene-level SSAO has been added but is visually weak. - Scene-level SSAO has been added as a Three.js `SSAOPass`.
- The visible candle-bottom effect is currently analytic table-shader contact AO. This is a failed fallback, not an accepted feature. - Flames and glow sprites are excluded from AO.
- SSAO is not yet good enough to be considered the solved AO system. - The procedural book and animated flip page are currently excluded from AO because the previous AO interaction made the book look inset/incorrect and excluded the top page from the effect.
- `tableDebug=ao` currently proves only that a Three.js `SSAOPass` is wired. It does not prove useful visual AO. - `tableDebug=ao` proves the pass is wired, but the current AO result is not the accepted final AO design.
- The SSAO attempt has not yet produced a meaningful composite result. - AO work is paused until the book material/topology integration is stable.
- The analytic contact fallback confused the evaluation and must be removed so SSAO can be judged honestly.
Debug proof: Debug proof:
@@ -320,7 +386,7 @@ The standalone scene should support debug query modes:
Deprecated: Deprecated:
- `tableDebug=contact`: analytic contact fallback diagnostic. This must be removed with the fallback contact layer unless explicitly retained as a temporary cleanup check. - `tableDebug=contact`: removed/deprecated. It must not return as a final-composite fallback.
Debug views must be visually meaningful. A debug view that is too subtle to interpret is not useful proof. Debug views must be visually meaningful. A debug view that is too subtle to interpret is not useful proof.
@@ -354,54 +420,85 @@ Screenshot tooling:
4. Preserve unrelated `.env` changes unstaged. 4. Preserve unrelated `.env` changes unstaged.
5. Remove or ignore temporary screenshot files unless needed for explicit review. 5. Remove or ignore temporary screenshot files unless needed for explicit review.
### Phase 1: Remove The Analytic Contact Fallback ### Phase 1: Procedural Book Integration
Goal: remove the fallback contact-shadow/contact-AO cheat so the scene can be evaluated honestly. Goal: replace the old fixed-box book in `webgl-book-lab.html` with the procedural book model while preserving candle lighting, table reflection, and shader-owned shadows.
Steps: Steps:
1. Remove analytic contact darkening from the final table composite. 1. Import `createProceduralBookModel` into the WebGL lab.
2. Remove `surfaceContactOcclusion`, `candleContact`, and `candleFootOcclusion` from the final image unless they are only part of a temporary diagnostic that does not affect normal rendering. 2. Place the procedural book so the spine bottom touches the table plane with minimal render clearance.
3. Remove or clearly deprecate `tableDebug=contact`. 3. Add top-bar controls for progress, page count, backward, forward, fast backward, and fast forward.
4. Confirm `tableDebug=shadow` still shows only candle cast shadows. 4. Keep all book parts participating in custom candle shader lighting.
5. Confirm `tableDebug=ao` still shows only scene-level SSAO. 5. Keep all Three.js primitive shadow flags disabled.
6. Capture before/after screenshots proving the fallback was removed. 6. Preserve table reflection and candle rendering.
7. Run checks and build. 7. Ensure the right page/top cap is present at all progress values.
8. Ensure the readable page content projects onto the stack top cap.
Acceptance criteria: 9. Ensure spine arc/support/cloth materials do not receive page content.
- The normal composite no longer contains analytic contact fallback darkening.
- Contact darkening is not used as a substitute for SSAO or cast shadows.
- Any remaining grounding must come from real shadows, real SSAO, or accepted reflection/lighting behavior.
- Debug modes are no longer ambiguous.
### Phase 2: Restore And Protect Candle Cast Shadows
Goal: restore the candle shadows that were working before SSAO work.
Steps:
1. Inspect the last known good candle-shadow checkpoint.
2. Identify exactly which changes removed or masked candle shadows.
3. Restore shadow contribution without disturbing accepted table dust/grease/reflection work.
4. Verify whether `tableMesh.receiveShadow = false` is still valid. If primitive shadow receiving was part of the working candle shadows, restore it or provide a proven shader replacement.
5. Verify whether candle wax meshes need to cast primitive shadows or whether analytic shader shadows fully replace them.
6. Ensure all three candle flame positions and all three candle bodies are included.
7. Ensure the book participates in candle shadows.
8. Use `tableDebug=shadow` to prove the shadow field.
9. Use the normal composite to prove shadows are visible.
10. Run checks and build. 10. Run checks and build.
11. Commit only after visual proof.
Acceptance criteria: Acceptance criteria:
- All three candles cast visible shadows. - The old book is gone from the lab scene.
- Shadows are visible in final composite. - The procedural book is visible, correctly placed on the table, and controllable.
- Shadows are soft, not hard black cones. - Top page content appears on the actual stack top, not on a hovering overlay.
- No analytic contact fallback is used to fake shadows. - Extreme progress values render with the same topology/material rules as normal values.
- Reflections, dust, grease, normal map, and page textures remain intact. - No OpenGL shadow flags are enabled.
### Phase 3: Implement True SSAO ### Phase 2: Procedural Book Geometry And Solver
Goal: keep the book shape physically plausible and stable across progress/page-count values.
Steps:
1. Keep page width, page depth, and page spline length consistent.
2. Use deterministic line generation for page splines.
3. Keep fixed segment lengths whose sum equals page width.
4. Use shorter page-line segments near the spine and longer segments toward the page edge.
5. Make every line follow the closest legal underlying layer:
- First layer follows spine/hinge/cover profile and may touch it.
- Later layers follow the paper line below and keep one bundle spacing.
6. Keep the spine arc spacing proportional to page bundle count.
7. Keep the spine as small as needed for 40 pages and as large as needed up to the capped maximum.
8. Keep page count capped at 500 pages.
9. Keep cover width independent of spine growth.
10. Ensure stack bodies have closed sides, bottom where needed, and correct top cap normals.
Acceptance criteria:
- Left and right cover lengths remain the same even when stack sizes differ.
- Page widths remain the same on thick and thin stacks.
- No final line segment runs backward.
- The top caps face outward/upward and light correctly.
- The first and last page states remain valid.
- No stack body exposes an unintended hollow interior.
### Phase 3: Page Content And Materials
Goal: make the book read as a real leather-bound book with readable page content.
Steps:
1. Generate page canvas textures at the correct page aspect ratio.
2. Use hardcover-style margins.
3. Keep page content on stack top caps.
4. Keep neutral paper and content page textures smooth and consistently filtered.
5. Generate stack side lines as textures.
6. Keep stack side texture orientation consistent across front, back, left, and right sides.
7. Use procedural leather color and normal maps for cover, hinge, spine base, and cover edge.
8. Round the edge impression of cover panels without introducing open faces.
9. Verify cover, hinge, spine base, cover edge, paper side, paper top, and cloth spine material groups.
Acceptance criteria:
- Page content is readable and correctly oriented.
- Stack side lines are not free-standing meshes.
- Stack side lines meet at corners without visible mismatch.
- The visible page surface is smooth and does not reveal construction-line artifacts.
- Leather parts look like real cover geometry, not flat orange planes.
- Cloth spine remains visually distinct from leather cover parts.
### Phase 4: True SSAO Revisit
Goal: understand, fix, and complete real scene-level SSAO. Goal: understand, fix, and complete real scene-level SSAO.
@@ -427,7 +524,7 @@ Acceptance criteria:
- AO does not replace cast shadows. - AO does not replace cast shadows.
- No fallback contact darkening remains in the final composite. - No fallback contact darkening remains in the final composite.
### Phase 4: Reflection And Compositing Cleanup ### Phase 5: Reflection And Compositing Cleanup
Goal: make the table reflection physically coherent. Goal: make the table reflection physically coherent.
@@ -448,26 +545,6 @@ Acceptance criteria:
- Flame reflection does not appear as an impossible unoccluded blob. - Flame reflection does not appear as an impossible unoccluded blob.
- Table wood remains visible. - Table wood remains visible.
### Phase 5: Page Texture Quality
Goal: make the book usable as an actual reading UI.
Steps:
1. Audit old layout page dimensions.
2. Create high-resolution page texture canvases.
3. Render left and right old-layout content into those textures.
4. Preserve text orientation and aspect ratio.
5. Verify crispness at default and close camera distances.
6. Add debug/export view for page textures.
Acceptance criteria:
- Text is crisp.
- Left and right pages match old layout content and proportions.
- No mirrored or rotated page.
- No overlay reading surface.
### Phase 6: Integration Back Into App Shell ### Phase 6: Integration Back Into App Shell
Goal: connect the standalone scene back to the real app. Goal: connect the standalone scene back to the real app.
@@ -511,25 +588,25 @@ Acceptance criteria:
## Current Known Problems ## Current Known Problems
- Candle cast shadows are missing/regressed. - The current extreme-progress rendering still needs user screenshot validation after the latest stack-cap material and cap-winding fixes.
- Scene-level SSAO is visually weak. - The page-content texture must not appear on the spine arc or synthetic support strip.
- Analytic candle/book contact darkening exists as a fallback cheat and must be removed. - At `0.00` and very early progress, the side with no real stack must still render with the same topology/material rules as later states.
- Candle contact AO is not a substitute for shadows or true SSAO. - The current content page surface should stay smooth; construction lines from stack textures must not appear on readable content.
- The current shadow/composite path must be repaired before further visual polish. - Leather material quality is still provisional and may need further art-direction tuning.
- Reflection and dust/grease work are acceptable enough to preserve while fixing shadows. - Scene-level SSAO is not accepted as final and is currently not allowed to drive book appearance.
- Screenshot capture can stall at large viewport sizes; current reliable method uses smaller viewport and explicit screenshot timeout. - The table reflection path is active and should not be disabled to hide book/cover problems.
- Two pathless Windows Node processes may resist termination with access denied; they are not the `:3001` server.
## Next Immediate Task ## Next Immediate Task
Remove the analytic contact fallback completely, then investigate and fix true SSAO. Validate the latest procedural-book integration visually in the running `:3001` lab scene, especially the extreme progress states.
No other visual topic should be started until the following are true: The next visual work should focus on the book material/topology issues in this order:
- The final composite no longer uses analytic contact fallback darkening. 1. Confirm that content appears only on the intended stack-top page surfaces.
- `tableDebug=contact` is removed or explicitly marked deprecated and disconnected from the final composite. 2. Confirm that spine arc, hinge, cloth, and support strips do not receive page-content texture.
- `tableDebug=ao` is understood, documented, and made visually meaningful. 3. Confirm that the right stack has a valid top cap even when the left side has no full stack.
- The cause of the weak SSAO output is identified before intensity tuning. 4. Confirm that readable page surfaces are smooth and not showing construction-line artifacts.
- Any SSAO fix is proven in both debug and final composite. 5. Continue cover/hinge/spine leather material refinement only after the page-top topology is stable.
- Existing candle shadows are not weakened or repurposed during SSAO work. 6. Keep `:3001` as the single current test server.
- Static checks and build pass. 7. Keep OpenGL primitive shadows disabled.
- No orphaned processes remain.
+939
View File
@@ -0,0 +1,939 @@
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 halfDepth = depth * 0.5;
const edgeRadius = Math.min(depth * 0.015, thickness * 0.45, 0.035);
const edgeProfile = [
{ inset: edgeRadius, drop: 0 },
{ inset: edgeRadius * 0.48, drop: edgeRadius * 0.16 },
{ inset: edgeRadius * 0.12, drop: edgeRadius * 0.42 },
{ inset: 0, drop: edgeRadius * 0.72 },
];
const push = (point) => {
const index = positions.length / 3;
positions.push(point.x, point.y, point.z);
uvs.push(point.u, point.v);
return index;
};
const pushGroup = (materialIndex, quadIndices) => {
const start = indices.length;
indices.push(...quadIndices);
groups.push({ start, count: quadIndices.length, materialIndex });
};
const pointAt = (source, y, z, u, v) => ({ x: source.x, y, z, u, v });
const edgePoint = (source, side, profilePoint, u, bottom = false) => {
const topY = source.y;
const bottomY = source.y - thickness;
return pointAt(
source,
bottom ? bottomY + profilePoint.drop : topY - profilePoint.drop,
side * (halfDepth - profilePoint.inset),
u,
side > 0 ? 1 : 0
);
};
const addQuad = (materialIndex, a, b, c, d) => {
const base = positions.length / 3;
push(a);
push(b);
push(c);
push(d);
pushGroup(materialIndex, [base, base + 1, base + 2, base + 2, base + 1, base + 3]);
};
for (let i = 0; i < section.length - 1; i += 1) {
const left = section[i];
const right = section[i + 1];
const leftU = i / (section.length - 1);
const rightU = (i + 1) / (section.length - 1);
const topMaterial = coverSegmentMaterialIndex(i);
const innerFront = halfDepth - edgeRadius;
const innerBack = -halfDepth + edgeRadius;
addQuad(
topMaterial,
pointAt(left, left.y, innerFront, leftU, 1),
pointAt(right, right.y, innerFront, rightU, 1),
pointAt(left, left.y, innerBack, leftU, 0),
pointAt(right, right.y, innerBack, rightU, 0)
);
addQuad(
3,
pointAt(left, left.y - thickness, innerBack, leftU, 0),
pointAt(right, right.y - thickness, innerBack, rightU, 0),
pointAt(left, left.y - thickness, innerFront, leftU, 1),
pointAt(right, right.y - thickness, innerFront, rightU, 1)
);
[-1, 1].forEach((side) => {
for (let edgeIndex = 0; edgeIndex < edgeProfile.length - 1; edgeIndex += 1) {
const current = edgeProfile[edgeIndex];
const next = edgeProfile[edgeIndex + 1];
addQuad(
3,
edgePoint(left, side, current, leftU, false),
edgePoint(right, side, current, rightU, false),
edgePoint(left, side, next, leftU, false),
edgePoint(right, side, next, rightU, false)
);
addQuad(
3,
edgePoint(left, side, next, leftU, true),
edgePoint(right, side, next, rightU, true),
edgePoint(left, side, current, leftU, true),
edgePoint(right, side, current, rightU, true)
);
}
const sideEdge = edgeProfile[edgeProfile.length - 1];
addQuad(
3,
edgePoint(left, side, sideEdge, leftU, false),
edgePoint(right, side, sideEdge, rightU, false),
edgePoint(left, side, sideEdge, leftU, true),
edgePoint(right, side, sideEdge, rightU, true)
);
});
}
[0, section.length - 1].forEach((pointIndex) => {
const point = section[pointIndex];
const u = pointIndex / (section.length - 1);
if (pointIndex === 0) {
addQuad(
3,
pointAt(point, point.y, halfDepth, u, 1),
pointAt(point, point.y, -halfDepth, u, 0),
pointAt(point, point.y - thickness, halfDepth, u, 1),
pointAt(point, point.y - thickness, -halfDepth, u, 0)
);
} else {
addQuad(
3,
pointAt(point, point.y, halfDepth, u, 1),
pointAt(point, point.y - thickness, halfDepth, u, 1),
pointAt(point, point.y, -halfDepth, u, 0),
pointAt(point, point.y - thickness, -halfDepth, u, 0)
);
}
});
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 isHairOnlyPage = isSinglePage && sideLines[0].isHairPage === true;
const bodyLines = isSinglePage ? createSinglePageBodyLines(context, model, sideLines[0]) : sideLines;
const mesh = new THREE.Mesh(createLoftedLineBody(model, bodyLines, model.pageDepth), createStackBodyMaterials(context, model, side, isSinglePage, isHairOnlyPage));
mesh.userData.bookPart = side < 0 ? 'leftPages' : 'rightPages';
group.add(mesh);
});
}
function createStackBodyMaterials(context, model, side, isSinglePage = false, isHairOnlyPage = 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 = isHairOnlyPage
? context.materials.pageTop.clone()
: 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 allPagesOnOneSide = lines.length === model.bundleCount && lines.every((line) => line.isHairPage !== true);
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 originalU = smoothLines[row].length <= 1 ? 0.5 : col / (smoothLines[row].length - 1);
const pageDistance = side > 0
? point.x - model.spineHalf
: -model.spineHalf - point.x;
const pageU = allPagesOnOneSide
? THREE.MathUtils.clamp(pageDistance / model.pageWidth, 0, 1)
: originalU;
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 = allPagesOnOneSide ? smoothLines.length - 1 : 0;
const topRow = allPagesOnOneSide ? 0 : 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);
}
const pointA = smoothLines[topRow][col];
const pointB = smoothLines[topRow][col + 1];
const middleX = (pointA.x + pointB.x) * 0.5;
const isSpineArc = Math.abs(middleX) < model.spineHalf;
topGroups.push({
start: groupStart,
count: indices.length - groupStart,
materialIndex: allPagesOnOneSide && isSpineArc ? 4 : 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 supportPoints = line.points.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
];
}
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), 0, bundleCount);
}
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;
});
}
File diff suppressed because it is too large Load Diff
+89 -3
View File
@@ -26,15 +26,16 @@
position: fixed; position: fixed;
z-index: 10; z-index: 10;
inset: 0 0 auto; inset: 0 0 auto;
height: 38px; min-height: 38px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 18px; gap: 16px;
padding: 4px 14px;
box-sizing: border-box; box-sizing: border-box;
background: linear-gradient(180deg, rgba(13, 9, 6, 0.94), rgba(13, 9, 6, 0.58)); background: linear-gradient(180deg, rgba(13, 9, 6, 0.94), rgba(13, 9, 6, 0.58));
border-bottom: 1px solid rgba(214, 180, 125, 0.22); border-bottom: 1px solid rgba(214, 180, 125, 0.22);
pointer-events: none; pointer-events: auto;
} }
#lab_title { #lab_title {
@@ -46,6 +47,75 @@
#lab_status { #lab_status {
font-size: 13px; font-size: 13px;
color: rgba(241, 222, 192, 0.72); color: rgba(241, 222, 192, 0.72);
white-space: nowrap;
}
#book_controls {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 12px;
color: rgba(241, 222, 192, 0.86);
}
.control_group {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.control_group label {
white-space: nowrap;
}
#book_controls input[type="range"] {
width: clamp(96px, 15vw, 230px);
accent-color: #d79b36;
}
#book_controls output {
min-width: 38px;
font-variant-numeric: tabular-nums;
color: rgba(241, 222, 192, 0.76);
}
.transport_button {
width: 28px;
height: 26px;
display: grid;
place-items: center;
border: 1px solid rgba(214, 180, 125, 0.32);
background: rgba(37, 19, 11, 0.72);
color: #f1dec0;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
line-height: 1;
}
.transport_button:disabled {
opacity: 0.38;
cursor: default;
}
@media (max-width: 900px) {
#lab_status {
display: none;
}
#book_controls {
justify-content: flex-end;
gap: 6px;
}
.control_group label {
display: none;
}
} }
</style> </style>
</head> </head>
@@ -53,6 +123,22 @@
<canvas id="scene" aria-label="Procedural book scene lab"></canvas> <canvas id="scene" aria-label="Procedural book scene lab"></canvas>
<div id="lab_menu"> <div id="lab_menu">
<div id="lab_title">Procedural Book Lab</div> <div id="lab_title">Procedural Book Lab</div>
<div id="book_controls" aria-label="Book controls">
<button class="transport_button" id="fast_backward" type="button" title="Fast backward" aria-label="Fast backward"></button>
<button class="transport_button" id="flip_backward" type="button" title="Backward" aria-label="Backward"></button>
<div class="control_group">
<label for="progress_control">Progress</label>
<input id="progress_control" type="range" min="0" max="1" step="0.001">
<output id="progress_value" for="progress_control">0.28</output>
</div>
<div class="control_group">
<label for="page_count_control">Pages</label>
<input id="page_count_control" type="range" min="40" max="500" step="10">
<output id="page_count_value" for="page_count_control">240</output>
</div>
<button class="transport_button" id="flip_forward" type="button" title="Forward" aria-label="Forward"></button>
<button class="transport_button" id="fast_forward" type="button" title="Fast forward" aria-label="Fast forward"></button>
</div>
<div id="lab_status">standalone scene</div> <div id="lab_status">standalone scene</div>
</div> </div>
<script type="module" src="/js/webgl-book-lab.js"></script> <script type="module" src="/js/webgl-book-lab.js"></script>
+6 -3
View File
@@ -3,6 +3,8 @@ const path = require('path');
const sourcePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-lab.js'); const sourcePath = path.join(__dirname, '..', 'public', 'js', 'webgl-book-lab.js');
const source = fs.readFileSync(sourcePath, 'utf8'); const source = fs.readFileSync(sourcePath, 'utf8');
const proceduralBookPath = path.join(__dirname, '..', 'public', 'js', 'procedural-book-model.js');
const proceduralBookSource = fs.readFileSync(proceduralBookPath, 'utf8');
const checks = [ const checks = [
['scene-level SSAO import', /SSAOPass/.test(source)], ['scene-level SSAO import', /SSAOPass/.test(source)],
@@ -12,17 +14,18 @@ const checks = [
['static table maps are loaded from disk', /table_normal_2k\.png/.test(source) && /table_dust_4k\.png/.test(source) && /table_grease_4k\.png/.test(source)], ['static table maps are loaded from disk', /table_normal_2k\.png/.test(source) && /table_dust_4k\.png/.test(source) && /table_grease_4k\.png/.test(source)],
['runtime table map generators removed from page', !/function createTableNormalTexture|function createTableDustTexture|function createTableGreaseTexture/.test(source)], ['runtime table map generators removed from page', !/function createTableNormalTexture|function createTableDustTexture|function createTableGreaseTexture/.test(source)],
['table primitive shadow receiving disabled', /tableMesh\.receiveShadow = false/.test(source)], ['table primitive shadow receiving disabled', /tableMesh\.receiveShadow = false/.test(source)],
['flames excluded from AO', /excludeFromAo = true/.test(source) && /aoExcludedObjects\.push\(child\)/.test(source)], ['flames excluded from AO', /excludeFromAo = true/.test(source) && /aoExcludedObjects\.add\(child\)/.test(source)],
['AO pass hides excluded objects with cleanup', /sceneAoPass\.render = \(\.\.\.args\) =>/.test(source) && /finally/.test(source)], ['AO pass hides excluded objects with cleanup', /sceneAoPass\.render = \(\.\.\.args\) =>/.test(source) && /finally/.test(source)],
['AO uses scene-scale sampling', /new SSAOPass\(scene, camera, 1, 1, 64\)/.test(source) && /sceneAoPass\.kernelRadius = 0\.48/.test(source) && /sceneAoPass\.minDistance = 0\.00025/.test(source) && /sceneAoPass\.maxDistance = 0\.065/.test(source)], ['AO uses scene-scale sampling', /new SSAOPass\(scene, camera, 1, 1, 64\)/.test(source) && /sceneAoPass\.kernelRadius = 0\.48/.test(source) && /sceneAoPass\.minDistance = 0\.00025/.test(source) && /sceneAoPass\.maxDistance = 0\.065/.test(source)],
['AO debug shows blurred occlusion map', /tableDebugName === 'ao' && SSAOPass\.OUTPUT\?\.Blur/.test(source) && /sceneAoPass\.output = SSAOPass\.OUTPUT\.Blur/.test(source)], ['AO debug shows blurred occlusion map', /tableDebugName === 'ao' && SSAOPass\.OUTPUT\?\.Blur/.test(source) && /sceneAoPass\.output = SSAOPass\.OUTPUT\.Blur/.test(source)],
['direct candle shadow lobe present', /candlePlanarShadowLobe/.test(source) && /candlePlanarShadowField/.test(source)], ['direct candle shadow lobe present', /candlePlanarShadowLobe/.test(source) && /candlePlanarShadowField/.test(source)],
['direct candle shadow contributes to final table shader', /max\(candleProjectedShadowField\(vTableWorldPosition\), candlePlanarShadowField\(vTableWorldPosition\)\)/.test(source) && /bookMeshShadowField\(vTableWorldPosition\)/.test(source)], ['direct candle shadow contributes to final table shader', /max\(candleProjectedShadowField\(vTableWorldPosition\), candlePlanarShadowField\(vTableWorldPosition\)\)/.test(source) && /bookMeshShadowField\(vTableWorldPosition\)/.test(source)],
['book shadows use real light-space depth maps', /bookShadowTargets/.test(source) && /MeshDepthMaterial/.test(source) && /updateBookShadowMaps/.test(source) && /bookMeshShadowField/.test(source) && /bookShadowMaps\[0\]/.test(source)], ['book shadows use real light-space depth maps', /bookShadowTargets/.test(source) && /MeshDepthMaterial/.test(source) && /updateBookShadowMaps/.test(source) && /bookMeshShadowField/.test(source) && /bookShadowMaps\[0\]/.test(source)],
['book materials receive real shadow maps', /configureBookShadowReceiver\(materials\.leftPage/.test(source) && /bookReceiverShadowField/.test(source) && /bookShadowReceiverStrength/.test(source)], ['book materials receive real shadow maps', /configureBookShadowReceiver\(materials\.leftPage/.test(source) && /bookReceiverShadowField/.test(source) && /bookShadowReceiverStrength/.test(source) && /configureMaterial\(material, part\)/.test(source)],
['book uses modular solved procedural body geometry', /createProceduralBookModel/.test(source) && /currentProceduralBookModel/.test(source) && /simulatePageLines/.test(proceduralBookSource) && /createLoftedLineBody/.test(proceduralBookSource) && /buildSupportSolvedLine/.test(proceduralBookSource)],
['proxy book shadow shortcuts are forbidden', !/bookPlanarShadowLobe|bookProjectedShadowField|bookBoxShadow|segmentBoxHit/.test(source)], ['proxy book shadow shortcuts are forbidden', !/bookPlanarShadowLobe|bookProjectedShadowField|bookBoxShadow|segmentBoxHit/.test(source)],
['final candle shadow is visible in composite', /candleOcclusion = clamp\(candleProjectedShadow \* 1\.46, 0\.0, 0\.82\)/.test(source) && /vec3\(0\.19, 0\.15, 0\.115\), candleOcclusion/.test(source)], ['final candle shadow is visible in composite', /candleOcclusion = clamp\(candleProjectedShadow \* 1\.46, 0\.0, 0\.82\)/.test(source) && /vec3\(0\.19, 0\.15, 0\.115\), candleOcclusion/.test(source)],
['primitive candle shadow shortcuts stay disabled', /wax\.castShadow = false/.test(source) && /wick\.castShadow = false/.test(source) && /leftPage\.receiveShadow = false/.test(source) && /rightPage\.receiveShadow = false/.test(source)], ['primitive candle shadow shortcuts stay disabled', /wax\.castShadow = false/.test(source) && /wick\.castShadow = false/.test(source) && !/bookPlanarShadowLobe|bookProjectedShadowField|bookBoxShadow|segmentBoxHit/.test(source)],
['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)], ['analytic contact fallback removed', !/surfaceContactOcclusion|candleContactField|candleContactOcclusion|bookContactField|candleFootOcclusion|contactAo/.test(source)],
['debug AO remains scene-level', /scene debug: SSAO/.test(source)], ['debug AO remains scene-level', /scene debug: SSAO/.test(source)],
['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)], ['contact debug mode removed', !/contact:\s*9|tableDebugMode == 9/.test(source)],