Checkpoint WebGL procedural book lab
This commit is contained in:
+167
-90
@@ -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.
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
+803
-117
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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)],
|
||||||
|
|||||||
Reference in New Issue
Block a user