fix: make nightdrive signage reliable and legible
@@ -0,0 +1,88 @@
|
|||||||
|
# Nightdrive sign reliability and legibility evidence
|
||||||
|
|
||||||
|
Status date: 2026-09-03
|
||||||
|
|
||||||
|
This directory records the bounded Three.js roadside-sign pass. The portfolio content, panels, navigation, HUD, controls, car, road, terrain, sun, atmosphere, camera, and forward-travel direction were not redesigned.
|
||||||
|
|
||||||
|
## Shared causes found
|
||||||
|
|
||||||
|
The supplied Profile failure was not one isolated coordinate defect:
|
||||||
|
|
||||||
|
1. The former candidate gate discarded large deltas and multi-boundary samples before lifecycle reconciliation. Slow threshold crossings and rapid arrival therefore followed different paths, and the latter could skip the relevant destination.
|
||||||
|
2. Dwell time was derived from the original latch time. A delayed frame could consume the readable interval before the first dwell frame, while no further input could leave another sign frozen indefinitely.
|
||||||
|
3. Every desktop label inherited one right-side pose. Profile therefore competed directly with its right-side content card, while one immediate responsive remap could move a live sign to a different pose family.
|
||||||
|
4. The 1024×288 source was stretched onto a 3.93:1 face, could be drawn before the intended font was ready, and was small enough relative to the projected dwell face to look soft after perspective, mip selection, and post-processing.
|
||||||
|
5. The former departure could advance toward and through the camera plane. A late departure frame consequently expanded instead of receding.
|
||||||
|
6. A scene rebuild copied the lifecycle fields but omitted the latched responsive profile, so a quality/resize rebuild could silently replace a live composition.
|
||||||
|
|
||||||
|
## Canonical route contract
|
||||||
|
|
||||||
|
Only forward `passive-scroll` samples with motion enabled are eligible. The opening route is deliberately ineligible. A normal sample selects the next route once its heading is within 0.55 viewport heights of the activation line. A sample that crosses a section boundary selects the eligible section actually reached. Large and multi-section jumps coalesce to that latest relevant destination rather than queueing intermediate labels.
|
||||||
|
|
||||||
|
| Route target | Face label | Marker | Corridor | Trigger | Dwell placement intent |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| `start` | Start | 01 | none | ineligible | no sign |
|
||||||
|
| `profile` | Profile | 02 | left | ≤0.55vh or actual arrival | opposite the right Profile card |
|
||||||
|
| `experience` | Experience | 03 | right | ≤0.55vh or actual arrival | opposite the left Career card |
|
||||||
|
| `infrastructure` | Infrastructure | 04 | outer-right | ≤0.55vh or actual arrival | outside the broad systems content corridor |
|
||||||
|
| `selected-builds` | Selected builds | 05 | outer-right | ≤0.55vh or actual arrival | outside the projects content corridor |
|
||||||
|
| `additional-work` | Additional work | 06 | left | ≤0.55vh or actual arrival | away from the dominant content surface |
|
||||||
|
| `toolkit` | Toolkit | 07 | outer-left | ≤0.55vh or actual arrival | outside the toolkit content corridor |
|
||||||
|
| `contact` | Contact | 08 | right | ≤0.55vh or actual arrival | opposite the Contact destination content |
|
||||||
|
|
||||||
|
The controller owns exactly one visible target. A concealed sign can retarget immediately. A visible label is immutable; a newer candidate is coalesced as the pending target. Completed targets are suppressed for the current journey so small oscillations cannot spam them. A stale readable target completes its minimum dwell before departing. Initialization, explicit navigation (including skip links and direct fragments), history restoration, Static mode, and reduced motion remain intentionally suppressed. Visibility loss resets transient lifecycle state.
|
||||||
|
|
||||||
|
## Lifecycle contract
|
||||||
|
|
||||||
|
| Phase | Duration | Behavior |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| Anticipate | 300ms | target/profile latch; sign remains concealed at its horizon pose |
|
||||||
|
| Approach | 900ms, reduced by at most 250ms with acceleration | elapsed-time interpolation from horizon to dwell; opacity reaches full early in approach |
|
||||||
|
| Dwell | at least 1,400ms from the first actual dwell sample | fixed readable pose; reverse/passed/stale input cannot shorten the minimum |
|
||||||
|
| Depart | 700ms | exits laterally and recedes 32 world units; never reverses through the car or crosses the camera |
|
||||||
|
| Cooldown | 350ms | prevents immediate replay; retains only the latest valid pending target |
|
||||||
|
|
||||||
|
The controller continues sampling without input while a sign is live, so release cannot freeze it. A delayed first dwell frame starts the dwell clock at that observed frame rather than skipping readability. Resize/orientation and scene-quality rebuilds preserve the latched `wide`, `tall`, or `short` profile until the lifecycle is concealed; the newly measured family applies to the next sign.
|
||||||
|
|
||||||
|
## Responsive pose and texture contract
|
||||||
|
|
||||||
|
`short` applies below 560px height in landscape, `tall` below a 1.05 aspect ratio, and `wide` otherwise. Each family has inner left/right and outer left/right roadside poses. Dwell face-width assertions are 120px at 390×844, 130px at 844×390, 150px at 768×1024, 180px at 1440×900, and 240px at 2560×1440. Every readable face must stay at least 8px inside the viewport, 16px clear of active and adjacent opaque content, and 8px clear of fixed navigation, controls, and HUD.
|
||||||
|
|
||||||
|
The face is an exact 4:1 plane backed by a 2048×512 4:1 canvas. It uses sRGB, trilinear mipmaps, linear magnification, up to 8× renderer-supported anisotropy, and a non-tone-mapped face material. The bundled Geist Mono marker and label weights are awaited before the first draw. A target change redraws only while concealed; there is no per-frame canvas redraw or texture replacement. The housing, clipped bevel, backplate, bracket, brace, narrow post, and dynamically compensated footing remain stable render-owned Three.js objects.
|
||||||
|
|
||||||
|
## Measured browser matrix
|
||||||
|
|
||||||
|
All recorded dwell frames reported a 2048×512 texture, 8× anisotropy, and `fontReadyWhenDrawn: true`.
|
||||||
|
|
||||||
|
| Scenario | CSS face width | Renderer ratio | Source texels per rendered face pixel | Content separation |
|
||||||
|
| --- | ---: | ---: | ---: | --- |
|
||||||
|
| 390×844 portrait, device DPR 3 / High | 150.64px | 1.25 | 10.87 | pass |
|
||||||
|
| 844×390 short landscape, DPR 1 / High | 134.49px | 1 | 15.23 | active + adjacent pass |
|
||||||
|
| 768×1024 tablet, DPR 1 / High | 182.76px | 1 | 11.21 | pass |
|
||||||
|
| 1440×900 desktop, DPR 1 / High | 195.05px | 1 | 10.50 | pass |
|
||||||
|
| 1440×900 desktop, device DPR 2 / High | 195.06px | 1.5 | 7.00 | pass |
|
||||||
|
| 1440×900 desktop, DPR 1 / Low | 195.06px | 1 | 10.50 | pass |
|
||||||
|
| 2560×1440 ultrawide, DPR 1 / High | 312.09px | 1 | 6.56 | pass |
|
||||||
|
|
||||||
|
The ratios above divide the 2,048 source texels by CSS face width × renderer ratio. They describe source sampling headroom; they are not an invented GPU-inspector measurement.
|
||||||
|
|
||||||
|
## Evidence index
|
||||||
|
|
||||||
|
- Before: [`before/profile-overlap-1280x720-dpr1.25.png`](before/profile-overlap-1280x720-dpr1.25.png)
|
||||||
|
- Desktop Profile/right-card separation: [`after/profile-desktop-1440x900-dpr1-high.png`](after/profile-desktop-1440x900-dpr1-high.png)
|
||||||
|
- Desktop Experience/left-card separation: [`after/experience-desktop-1440x900-dpr1-high.png`](after/experience-desktop-1440x900-dpr1-high.png)
|
||||||
|
- Responsive: [`after/profile-portrait-390x844-dpr3-high.png`](after/profile-portrait-390x844-dpr3-high.png), [`after/profile-short-844x390-dpr1-high.png`](after/profile-short-844x390-dpr1-high.png), [`after/profile-tablet-768x1024-dpr1-high.png`](after/profile-tablet-768x1024-dpr1-high.png), [`after/profile-ultrawide-2560x1440-dpr1-high.png`](after/profile-ultrawide-2560x1440-dpr1-high.png)
|
||||||
|
- DPR/quality: [`after/profile-desktop-1440x900-dpr2-high.png`](after/profile-desktop-1440x900-dpr2-high.png), [`after/profile-desktop-1440x900-dpr1-low-quality.png`](after/profile-desktop-1440x900-dpr1-low-quality.png), [`after/profile-desktop-1440x900-dpr1-high-quality.png`](after/profile-desktop-1440x900-dpr1-high-quality.png)
|
||||||
|
- Lifecycle: [`lifecycle/profile-anticipate.png`](lifecycle/profile-anticipate.png), [`lifecycle/profile-approach.png`](lifecycle/profile-approach.png), [`lifecycle/profile-dwell.png`](lifecycle/profile-dwell.png), [`lifecycle/profile-depart.png`](lifecycle/profile-depart.png)
|
||||||
|
- Adverse input: [`lifecycle/profile-reverse-during-approach.png`](lifecycle/profile-reverse-during-approach.png), [`lifecycle/profile-dwell-after-reverse.png`](lifecycle/profile-dwell-after-reverse.png), [`lifecycle/repeated-tiny-input-dwell.png`](lifecycle/repeated-tiny-input-dwell.png), [`lifecycle/rapid-multi-section-reconciliation.png`](lifecycle/rapid-multi-section-reconciliation.png)
|
||||||
|
- Visible resize: [`lifecycle/resize-visible-before.png`](lifecycle/resize-visible-before.png), [`lifecycle/resize-visible-after-844x390.png`](lifecycle/resize-visible-after-844x390.png)
|
||||||
|
- Suppressed modes: [`after/static-mode-1440x900.png`](after/static-mode-1440x900.png), [`after/reduced-motion-390x844-dpr3.png`](after/reduced-motion-390x844-dpr3.png)
|
||||||
|
- Machine-readable measurements: [`browser-matrix.json`](browser-matrix.json), [`lifecycle/trace.json`](lifecycle/trace.json), [`browser-console.json`](browser-console.json)
|
||||||
|
|
||||||
|
The lifecycle trace records target, phase, progress, opacity, projected face bounds, active section, direction, responsive profile, texture state, renderer ratio, reverse handling, repeated-small-input dwell, rapid multi-section reconciliation, visible resize, and the captured departure frame. Its rapid reconciliation contains only `Selected builds`; the visible Profile label never mutates.
|
||||||
|
|
||||||
|
Three.js DevTools was unavailable for the final local preview (`No Three.js app connected`), so no replacement draw-call or inspector-memory result is claimed here. Allocation and transfer impact are verified by the repository's authenticated runtime-budget and production-build harnesses instead.
|
||||||
|
|
||||||
|
## Asset ownership
|
||||||
|
|
||||||
|
No additional Blender asset was justified. The sign needs dynamic labels, route candidacy, lifecycle motion, responsive placement, and quality-tier integration; lightweight stable Three.js geometry preserves those controls without another GLB or asset transfer. The finite authored car remains Blender-owned and unchanged. Dynamic sign text and behavior, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned.
|
||||||
|
After Width: | Height: | Size: 322 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 348 KiB |
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 748 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 67 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
{
|
||||||
|
"generatedAt": "2026-09-02T23:06:25.825Z",
|
||||||
|
"scenarios": [
|
||||||
|
{
|
||||||
|
"name": "desktop Profile beside right panel",
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 3817.9,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"bounds": {
|
||||||
|
"left": 416.97990319603,
|
||||||
|
"top": 421.80306615142877,
|
||||||
|
"right": 612.0313301500913,
|
||||||
|
"bottom": 469.98894347500817,
|
||||||
|
"width": 195.0514269540613,
|
||||||
|
"height": 48.185877323579405
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-desktop-1440x900-dpr1-high.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop Experience beside left panel",
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "experience",
|
||||||
|
"frame": {
|
||||||
|
"now": 3028.7,
|
||||||
|
"targetId": "experience",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"activeSectionId": "profile",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "right",
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"bounds": {
|
||||||
|
"left": 862.8430142691166,
|
||||||
|
"top": 422.07400076838144,
|
||||||
|
"right": 1055.992547838176,
|
||||||
|
"bottom": 469.2669788703455,
|
||||||
|
"width": 193.14953356905949,
|
||||||
|
"height": 47.19297810196406
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 57.59375,
|
||||||
|
"top": 680.28125,
|
||||||
|
"right": 719.984375,
|
||||||
|
"bottom": 1830.875
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/experience-desktop-1440x900-dpr1-high.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "portrait Profile",
|
||||||
|
"viewport": {
|
||||||
|
"width": 390,
|
||||||
|
"height": 844
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 3,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 2975.5,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "tall",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 45.214151854185765,
|
||||||
|
"top": 393.78359453769036,
|
||||||
|
"right": 195.85026402457592,
|
||||||
|
"bottom": 431.69356317041803,
|
||||||
|
"width": 150.63611217039016,
|
||||||
|
"height": 37.90996863272767
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1.25
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 16,
|
||||||
|
"top": 651,
|
||||||
|
"right": 374,
|
||||||
|
"bottom": 1187.109375
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-portrait-390x844-dpr3-high.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "short landscape Profile",
|
||||||
|
"viewport": {
|
||||||
|
"width": 844,
|
||||||
|
"height": 390
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 2876.2,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "short",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 299.66361248256146,
|
||||||
|
"top": 191.1365135995139,
|
||||||
|
"right": 434.1535837811257,
|
||||||
|
"bottom": 225.4414949799874,
|
||||||
|
"width": 134.48997129856423,
|
||||||
|
"height": 34.30498138047349
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 303.828125,
|
||||||
|
"top": 286.03125,
|
||||||
|
"right": 810.21875,
|
||||||
|
"bottom": 700.40625
|
||||||
|
},
|
||||||
|
"adjacentPanel": {
|
||||||
|
"left": 33.75,
|
||||||
|
"top": -138,
|
||||||
|
"right": 523.265625,
|
||||||
|
"bottom": 174.03125
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-short-844x390-dpr1-high.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tablet Profile",
|
||||||
|
"viewport": {
|
||||||
|
"width": 768,
|
||||||
|
"height": 1024
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 2441.6,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"viewportProfile": "tall",
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"bounds": {
|
||||||
|
"left": 202.26939254304233,
|
||||||
|
"top": 477.7671107266057,
|
||||||
|
"right": 385.03157389039734,
|
||||||
|
"bottom": 523.7621386015365,
|
||||||
|
"width": 182.762181347355,
|
||||||
|
"height": 45.99502787493083
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 276.46875,
|
||||||
|
"top": 792.828125,
|
||||||
|
"right": 737.265625,
|
||||||
|
"bottom": 1254.640625
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-tablet-768x1024-dpr1-high.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ultrawide Profile",
|
||||||
|
"viewport": {
|
||||||
|
"width": 2560,
|
||||||
|
"height": 1440
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 5781.4,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"bounds": {
|
||||||
|
"left": 795.1606507914319,
|
||||||
|
"top": 674.7812815235544,
|
||||||
|
"right": 1107.2526954957643,
|
||||||
|
"bottom": 751.8792940365541,
|
||||||
|
"width": 312.09204470433247,
|
||||||
|
"height": 77.09801251299973
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 1382.390625,
|
||||||
|
"top": 1107,
|
||||||
|
"right": 2457.578125,
|
||||||
|
"bottom": 1510.15625
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-ultrawide-2560x1440-dpr1-high.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop high DPR Profile",
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 2,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 4315.7,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"bounds": {
|
||||||
|
"left": 416.97709613178563,
|
||||||
|
"top": 421.7626066451545,
|
||||||
|
"right": 612.0323349380212,
|
||||||
|
"bottom": 469.94872191078514,
|
||||||
|
"width": 195.0552388062356,
|
||||||
|
"height": 48.18611526563063
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1.5
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-desktop-1440x900-dpr2-high.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop low quality",
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "low",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 2293.1,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"bounds": {
|
||||||
|
"left": 416.975431797155,
|
||||||
|
"top": 421.73866114795436,
|
||||||
|
"right": 612.032925818174,
|
||||||
|
"bottom": 469.92491685811837,
|
||||||
|
"width": 195.057494021019,
|
||||||
|
"height": 48.186255710164005
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-desktop-1440x900-dpr1-low-quality.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "desktop high quality",
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 3370.4,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"bounds": {
|
||||||
|
"left": 416.9769832458706,
|
||||||
|
"top": 421.76098148986165,
|
||||||
|
"right": 612.0323751296199,
|
||||||
|
"bottom": 469.9471062962213,
|
||||||
|
"width": 195.05539188374934,
|
||||||
|
"height": 48.18612480635966
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "after/profile-desktop-1440x900-dpr1-high-quality.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "resize before visible phase",
|
||||||
|
"viewport": {
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900
|
||||||
|
},
|
||||||
|
"deviceScaleFactor": 1,
|
||||||
|
"quality": "high",
|
||||||
|
"targetId": "profile",
|
||||||
|
"frame": {
|
||||||
|
"now": 4038.1,
|
||||||
|
"targetId": "profile",
|
||||||
|
"phase": "dwell",
|
||||||
|
"progress": 1,
|
||||||
|
"opacity": 1,
|
||||||
|
"activeSectionId": "start",
|
||||||
|
"direction": 0,
|
||||||
|
"visible": true,
|
||||||
|
"corridor": "left",
|
||||||
|
"viewportProfile": "wide",
|
||||||
|
"bounds": {
|
||||||
|
"left": 416.9840276279932,
|
||||||
|
"top": 421.8626810680434,
|
||||||
|
"right": 612.0298349796249,
|
||||||
|
"bottom": 470.0482063315882,
|
||||||
|
"width": 195.04580735163165,
|
||||||
|
"height": 48.185525263544775
|
||||||
|
},
|
||||||
|
"texture": {
|
||||||
|
"width": 2048,
|
||||||
|
"height": 512,
|
||||||
|
"anisotropy": 8,
|
||||||
|
"fontReadyWhenDrawn": true
|
||||||
|
},
|
||||||
|
"rendererPixelRatio": 1
|
||||||
|
},
|
||||||
|
"panel": {
|
||||||
|
"left": 777.59375,
|
||||||
|
"top": 681,
|
||||||
|
"right": 1382.390625,
|
||||||
|
"bottom": 1139.28125
|
||||||
|
},
|
||||||
|
"separatedFromPanel": true,
|
||||||
|
"screenshot": "lifecycle/resize-visible-before.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 350 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 353 KiB |
|
After Width: | Height: | Size: 360 KiB |
|
After Width: | Height: | Size: 377 KiB |
|
After Width: | Height: | Size: 354 KiB |
|
After Width: | Height: | Size: 115 KiB |
|
After Width: | Height: | Size: 350 KiB |
@@ -108,6 +108,33 @@ Fresh verification produced these results:
|
|||||||
|
|
||||||
The unchanged 20-second interaction harness still does not yield a valid long trace on this host, so no interaction latency or frame-cadence result is claimed. Its independent mobile-low run loaded content under the prescribed throttle but stayed in `data-scene-state="loading"` beyond the fixed five-second scene-ready window. Its independent desktop-high run again reached the Toolkit CDP action but exceeded the fixed 75-second test timeout, matching the previously documented host behavior. Neither assertion or budget was weakened. The authenticated allocation trace, deterministic live-uniform trace, responsive screenshots, and browser assertions are the measured evidence for this pass; physical-browser, cross-engine, and audience checks remain outstanding below.
|
The unchanged 20-second interaction harness still does not yield a valid long trace on this host, so no interaction latency or frame-cadence result is claimed. Its independent mobile-low run loaded content under the prescribed throttle but stayed in `data-scene-state="loading"` beyond the fixed five-second scene-ready window. Its independent desktop-high run again reached the Toolkit CDP action but exceeded the fixed 75-second test timeout, matching the previously documented host behavior. Neither assertion or budget was weakened. The authenticated allocation trace, deterministic live-uniform trace, responsive screenshots, and browser assertions are the measured evidence for this pass; physical-browser, cross-engine, and audience checks remain outstanding below.
|
||||||
|
|
||||||
|
## Nightdrive sign reliability and legibility pass
|
||||||
|
|
||||||
|
The 2026-09-03 sign-only continuation is retained under `docs/reviews/evidence/nightdrive-iteration/sign-reliability-pass/`. Its [`README.md`](evidence/nightdrive-iteration/sign-reliability-pass/README.md) records the exact per-route candidacy/corridor mapping, lifecycle contract, responsive pose families, texture contract, measured face sizes, and evidence index. The supplied Profile-overlap scenario remains in `before/`; corrected left/right content relationships, five viewport families, DPR 1/2/3 states, Low/High tiers, lifecycle/adverse-input states, Static, reduced motion, and empty console evidence are separate and do not overwrite earlier review sets.
|
||||||
|
|
||||||
|
The investigation identified shared controller, projection, and texture causes. Large deltas and multi-boundary samples were previously discarded before reconciliation; dwell was timed from the original latch instead of the first actual readable frame; every desktop sign inherited one right-side pose; a resize/quality rebuild omitted the latched responsive profile; the former departure could cross the camera plane; and the 1024×288 source did not match the face ratio and could be cached before the intended font was ready. These were fixed within the Three.js sign boundary. No content, CSS, navigation, HUD, control, car, road, terrain, sun, atmosphere, camera, or forward-motion redesign was introduced.
|
||||||
|
|
||||||
|
The final controller accepts only forward passive-scroll candidates with motion enabled. A normal next route becomes eligible within 0.55vh; a boundary crossing resolves to the eligible route actually reached. Concealed labels may retarget, visible labels never mutate, jumps retain only the latest relevant pending target, and completed transitions cannot spam on small oscillations. The clock-driven phases are 300ms anticipate, 650–900ms approach, at least 1,400ms dwell measured from the first actual dwell frame, 700ms lateral/receding departure, and 350ms cooldown. A visible approach now completes approach plus the entire dwell even after reversal or passing its target; the refreshed trace observed 1,483.4ms between its first sampled dwell frame and departure after reverse input. Initialization, explicit/skip/direct navigation, history restoration, Static, reduced motion, and hidden-document transitions remain intentionally suppressed or reset.
|
||||||
|
|
||||||
|
Route metadata places Profile, Additional work, and Toolkit in left/outer-left corridors; Experience and Contact in right corridors; and Infrastructure and Selected builds in outer-right corridors. The `wide`, `tall`, and `short` pose families are latched per lifecycle and survive scene-quality rebuilds. Browser projection assertions require the complete readable face to remain inside viewport edges and clear of the active panel, adjacent opening panel, navigation, controls, and HUD. Final measured face widths were 150.64px at 390×844/DPR 3, 134.49px at 844×390, 182.76px at 768×1024, 195.05px at 1440×900, and 312.09px at 2560×1440. The Profile and Experience desktop frames were measured on opposite sides of their corresponding content panels.
|
||||||
|
|
||||||
|
The sign face is now an exact 4:1 plane with a 2048×512 sRGB canvas texture, trilinear mipmaps, linear magnification, and up to 8× available anisotropy. The bundled Geist Mono faces are awaited before the first draw, and the face bypasses tone mapping to avoid post-process color distortion. Every recorded dwell reported `fontReadyWhenDrawn: true`; source sampling headroom ranged from 6.56 to 15.23 texture pixels per rendered face pixel across the recorded viewport/DPR matrix. Label redraw and texture upload occur only when a concealed target changes. Geometry, materials, texture, projection vectors, and mounting remain stable render-owned objects; test-only projection inspection runs only in the authenticated test build.
|
||||||
|
|
||||||
|
Fresh verification produced these final results:
|
||||||
|
|
||||||
|
- The production build gate passed type checking, all 43 unit files / 538 tests, client and SSR builds, prerender verification, and the transfer check.
|
||||||
|
- The focused controller test passed 30/30. The final focused controller/world/lifecycle/drive/resource group passed 179 tests in 5 files, including the strengthened visible-approach dwell assertions.
|
||||||
|
- The final complete four-worker browser matrix passed 178 checks with 44 deliberate project skips and no failures. An earlier pre-final run produced one known desktop heading-focus timeout under load; the exact unchanged test passed with one worker in 2.1 seconds, and it also passed in the final four-worker matrix.
|
||||||
|
- The focused desktop/mobile sign-and-journey run passed 13 checks with the one intentional duplicate responsive-matrix skip. Its responsive loop exercised the automatic Low cap at 390px, Medium cap at 768/844px, and High cap at 1440/2560px. The final complete matrix independently passed the same sign, passive-scroll, explicit-navigation/restoration, keyboard/accessibility, responsive, Static/reduced-motion, fallback, and flash-safety paths.
|
||||||
|
- Deployment verification passed 9 tests in 3 files. Its sandboxed attempt could not bind the local HTTP fixture (`listen EPERM`); the unchanged suite passed with local-network permission.
|
||||||
|
- The final authenticated 1440×900 High-tier allocation gate passed with device DPR 2 and renderer ratio 1.5. It measured 5,608,790 texture bytes, 748,476 geometry bytes, 161,344,784 render-target bytes, a 33,554,432-byte allowance, and 201,256,482 total accounted bytes. Peak scene-image concurrency was 1 and video concurrency was 0.
|
||||||
|
- Transfer verification passed with a 222,897-byte Brotli-accounted first-live-scene payload. The environment chunk measured 701,690 raw / 146,308 transfer bytes, application JavaScript 80,343 transfer bytes, and the unchanged car GLB 410,408 raw / 76,589 transfer bytes.
|
||||||
|
- `browser-console.json` contains an empty error array, and `git diff --check` passed before final review.
|
||||||
|
|
||||||
|
The independent long interaction harness remains invalid on this host and no frame-cadence or input-latency number is claimed. Its desktop High/DPR run again reached the Toolkit target lookup but exceeded the fixed 75-second timeout; its mobile Low/DPR run remained in scene loading until the fixed five-second deadline and entered the tested static failure path. Neither timeout, assertion, scene-ready deadline, nor budget was changed. Three.js DevTools also reported no connected app for the final preview, so the earlier DevTools draw-call profile remains baseline evidence rather than being relabeled as a new result.
|
||||||
|
|
||||||
|
No new Blender asset was justified. The finite authored car remains Blender-owned and unchanged. Dynamic sign text, route candidacy, lifecycle, responsive placement, road, terrain, atmosphere, sun, camera, and motion remain Three.js-owned. Physical-device, Firefox, Safari/WebKit, Edge, assistive-technology, and audience review remain the honest manual limitations below.
|
||||||
|
|
||||||
## Direction review checklist
|
## Direction review checklist
|
||||||
|
|
||||||
- [x] Information uses near-black opaque panels rather than neon glass.
|
- [x] Information uses near-black opaque panels rather than neon glass.
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ export function useJourneySections(
|
|||||||
if (headings.length === 0) return false;
|
if (headings.length === 0) return false;
|
||||||
|
|
||||||
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
const scrollingElement = document.scrollingElement ?? document.documentElement;
|
||||||
|
const viewportWidth = visualViewport?.width ?? document.documentElement.clientWidth;
|
||||||
const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
|
const viewportHeight = visualViewport?.height ?? document.documentElement.clientHeight;
|
||||||
const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
|
const viewportOffsetTop = visualViewport?.offsetTop ?? 0;
|
||||||
const activationY = activationLineDocumentY({
|
const activationY = activationLineDocumentY({
|
||||||
@@ -123,6 +124,7 @@ export function useJourneySections(
|
|||||||
direction: driveSnapshot.direction,
|
direction: driveSnapshot.direction,
|
||||||
acceleration: driveSnapshot.acceleration,
|
acceleration: driveSnapshot.acceleration,
|
||||||
deltaY: driveSnapshot.scrollY - previousScrollY,
|
deltaY: driveSnapshot.scrollY - previousScrollY,
|
||||||
|
viewportWidth,
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
crossedBoundaries,
|
crossedBoundaries,
|
||||||
candidateId,
|
candidateId,
|
||||||
|
|||||||
@@ -4,33 +4,21 @@ import type {
|
|||||||
SignControllerSample,
|
SignControllerSample,
|
||||||
SignSnapshot,
|
SignSnapshot,
|
||||||
} from '@/journey/types';
|
} from '@/journey/types';
|
||||||
|
import {
|
||||||
const ANTICIPATE_MS = 300;
|
SIGN_LIFECYCLE,
|
||||||
const APPROACH_MS = 900;
|
SIGN_ROUTE_ORDER,
|
||||||
const APPROACH_REDUCTION_MS = 250;
|
signRouteFor,
|
||||||
const DWELL_MS = 1_400;
|
signViewportProfileFor,
|
||||||
const DEPART_MS = 700;
|
type SignViewportProfile,
|
||||||
const COOLDOWN_MS = 350;
|
} from '@/journey/sign-routes';
|
||||||
const ELIGIBLE_DISTANCE_VH = 1.2;
|
|
||||||
const JUMP_DISTANCE_VH = 0.75;
|
|
||||||
const CONCEALED_OPACITY = 0.02;
|
|
||||||
const SECTION_ORDER: readonly SectionId[] = [
|
|
||||||
'start',
|
|
||||||
'profile',
|
|
||||||
'experience',
|
|
||||||
'infrastructure',
|
|
||||||
'selected-builds',
|
|
||||||
'additional-work',
|
|
||||||
'toolkit',
|
|
||||||
'contact',
|
|
||||||
];
|
|
||||||
|
|
||||||
interface VisibleState {
|
interface VisibleState {
|
||||||
kind: 'visible';
|
kind: 'visible';
|
||||||
targetId: SectionId;
|
targetId: SectionId;
|
||||||
latchedAt: number;
|
latchedAt: number;
|
||||||
approachDuration: number;
|
approachDuration: number;
|
||||||
departureRequested: boolean;
|
viewportProfile: SignViewportProfile;
|
||||||
|
dwellStartedAt: number | null;
|
||||||
departStartedAt: number | null;
|
departStartedAt: number | null;
|
||||||
generation: number;
|
generation: number;
|
||||||
}
|
}
|
||||||
@@ -51,56 +39,44 @@ const isFiniteSample = (input: SignControllerSample): boolean =>
|
|||||||
Number.isFinite(input.now) &&
|
Number.isFinite(input.now) &&
|
||||||
Number.isFinite(input.acceleration) &&
|
Number.isFinite(input.acceleration) &&
|
||||||
Number.isFinite(input.deltaY) &&
|
Number.isFinite(input.deltaY) &&
|
||||||
|
(input.viewportWidth === undefined || (
|
||||||
|
Number.isFinite(input.viewportWidth) && input.viewportWidth > 0
|
||||||
|
)) &&
|
||||||
Number.isFinite(input.viewportHeight) &&
|
Number.isFinite(input.viewportHeight) &&
|
||||||
Number.isFinite(input.crossedBoundaries) &&
|
Number.isFinite(input.crossedBoundaries) &&
|
||||||
input.viewportHeight > 0 &&
|
input.viewportHeight > 0 &&
|
||||||
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
|
(input.candidateDistanceVh === null || Number.isFinite(input.candidateDistanceVh));
|
||||||
|
|
||||||
const canLatch = (input: SignControllerSample): input is SignControllerSample & {
|
|
||||||
candidateId: SectionId;
|
|
||||||
candidateDistanceVh: number;
|
|
||||||
} =>
|
|
||||||
input.motionAllowed &&
|
|
||||||
input.source === 'passive-scroll' &&
|
|
||||||
input.direction === 1 &&
|
|
||||||
input.deltaY > 0 &&
|
|
||||||
input.deltaY <= input.viewportHeight * JUMP_DISTANCE_VH &&
|
|
||||||
input.crossedBoundaries <= 1 &&
|
|
||||||
input.candidateId !== null &&
|
|
||||||
input.candidateId !== input.activeSectionId &&
|
|
||||||
input.candidateDistanceVh !== null &&
|
|
||||||
input.candidateDistanceVh > 0 &&
|
|
||||||
input.candidateDistanceVh <= ELIGIBLE_DISTANCE_VH;
|
|
||||||
|
|
||||||
export function createSignController(): SignController {
|
export function createSignController(): SignController {
|
||||||
let state: State = { kind: 'idle' };
|
let state: State = { kind: 'idle' };
|
||||||
let lastNow: number | null = null;
|
let lastNow: number | null = null;
|
||||||
let generation = 0;
|
let generation = 0;
|
||||||
|
let pendingTargetId: SectionId | null = null;
|
||||||
|
const completedTargets = new Set<SectionId>();
|
||||||
|
|
||||||
const reset = (now?: number): void => {
|
const reset = (now?: number): void => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
state = { kind: 'idle' };
|
state = { kind: 'idle' };
|
||||||
|
pendingTargetId = null;
|
||||||
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
|
lastNow = now !== undefined && Number.isFinite(now) ? now : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const enterCooldown = (now: number): void => {
|
|
||||||
generation += 1;
|
|
||||||
state = { kind: 'cooldown', until: now + COOLDOWN_MS };
|
|
||||||
};
|
|
||||||
|
|
||||||
const latch = (
|
const latch = (
|
||||||
input: SignControllerSample & {
|
targetId: SectionId,
|
||||||
candidateId: SectionId;
|
now: number,
|
||||||
candidateDistanceVh: number;
|
acceleration: number,
|
||||||
},
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
): VisibleState => {
|
): VisibleState => {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
return {
|
return {
|
||||||
kind: 'visible',
|
kind: 'visible',
|
||||||
targetId: input.candidateId,
|
targetId,
|
||||||
latchedAt: input.now,
|
latchedAt: now,
|
||||||
approachDuration: APPROACH_MS - APPROACH_REDUCTION_MS * clamp01(input.acceleration),
|
approachDuration: SIGN_LIFECYCLE.approachMs
|
||||||
departureRequested: false,
|
- SIGN_LIFECYCLE.approachReductionMs * clamp01(acceleration),
|
||||||
|
viewportProfile: signViewportProfileFor(viewportWidth, viewportHeight),
|
||||||
|
dwellStartedAt: null,
|
||||||
departStartedAt: null,
|
departStartedAt: null,
|
||||||
generation,
|
generation,
|
||||||
};
|
};
|
||||||
@@ -115,27 +91,25 @@ export function createSignController(): SignController {
|
|||||||
|
|
||||||
const requestDeparture = (
|
const requestDeparture = (
|
||||||
visible: VisibleState,
|
visible: VisibleState,
|
||||||
current: SignSnapshot,
|
|
||||||
now: number,
|
now: number,
|
||||||
): void => {
|
): void => {
|
||||||
const dwellCompleteAt = visible.latchedAt
|
if (
|
||||||
+ ANTICIPATE_MS
|
visible.dwellStartedAt === null
|
||||||
+ visible.approachDuration
|
|| now < visible.dwellStartedAt + SIGN_LIFECYCLE.dwellMs
|
||||||
+ DWELL_MS;
|
) return;
|
||||||
if (current.phase === 'dwell' && now < dwellCompleteAt) {
|
|
||||||
visible.departureRequested = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
beginDeparture(visible, now);
|
beginDeparture(visible, now);
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
|
const snapshotVisible = (visible: VisibleState, now: number): SignSnapshot | null => {
|
||||||
if (visible.departStartedAt !== null) {
|
if (visible.departStartedAt !== null) {
|
||||||
const progress = clamp01((now - visible.departStartedAt) / DEPART_MS);
|
const progress = clamp01((now - visible.departStartedAt) / SIGN_LIFECYCLE.departMs);
|
||||||
if (progress >= 1) {
|
if (progress >= 1) {
|
||||||
|
if (visible.dwellStartedAt !== null) completedTargets.add(visible.targetId);
|
||||||
state = {
|
state = {
|
||||||
kind: 'cooldown',
|
kind: 'cooldown',
|
||||||
until: visible.departStartedAt + DEPART_MS + COOLDOWN_MS,
|
until: visible.departStartedAt
|
||||||
|
+ SIGN_LIFECYCLE.departMs
|
||||||
|
+ SIGN_LIFECYCLE.cooldownMs,
|
||||||
};
|
};
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -144,20 +118,22 @@ export function createSignController(): SignController {
|
|||||||
phase: 'depart',
|
phase: 'depart',
|
||||||
progress,
|
progress,
|
||||||
opacity: 1 - smoothstep(0.1, 1, progress),
|
opacity: 1 - smoothstep(0.1, 1, progress),
|
||||||
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Math.max(0, now - visible.latchedAt);
|
const elapsed = Math.max(0, now - visible.latchedAt);
|
||||||
if (elapsed < ANTICIPATE_MS) {
|
if (elapsed < SIGN_LIFECYCLE.anticipateMs) {
|
||||||
return {
|
return {
|
||||||
targetId: visible.targetId,
|
targetId: visible.targetId,
|
||||||
phase: 'anticipate',
|
phase: 'anticipate',
|
||||||
progress: clamp01(elapsed / ANTICIPATE_MS),
|
progress: clamp01(elapsed / SIGN_LIFECYCLE.anticipateMs),
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const approachElapsed = elapsed - ANTICIPATE_MS;
|
const approachElapsed = elapsed - SIGN_LIFECYCLE.anticipateMs;
|
||||||
if (approachElapsed < visible.approachDuration) {
|
if (approachElapsed < visible.approachDuration) {
|
||||||
const progress = clamp01(approachElapsed / visible.approachDuration);
|
const progress = clamp01(approachElapsed / visible.approachDuration);
|
||||||
return {
|
return {
|
||||||
@@ -165,17 +141,46 @@ export function createSignController(): SignController {
|
|||||||
phase: 'approach',
|
phase: 'approach',
|
||||||
progress,
|
progress,
|
||||||
opacity: smoothstep(0, 0.42, progress),
|
opacity: smoothstep(0, 0.42, progress),
|
||||||
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (visible.dwellStartedAt === null) visible.dwellStartedAt = now;
|
||||||
return {
|
return {
|
||||||
targetId: visible.targetId,
|
targetId: visible.targetId,
|
||||||
phase: 'dwell',
|
phase: 'dwell',
|
||||||
progress: 1,
|
progress: 1,
|
||||||
opacity: 1,
|
opacity: 1,
|
||||||
|
viewportProfile: visible.viewportProfile,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canonicalCandidate = (input: SignControllerSample): SectionId | null => {
|
||||||
|
if (
|
||||||
|
input.direction !== 1
|
||||||
|
|| input.deltaY <= 0
|
||||||
|
|| input.source !== 'passive-scroll'
|
||||||
|
|| !input.motionAllowed
|
||||||
|
) return null;
|
||||||
|
|
||||||
|
if (input.crossedBoundaries > 0) {
|
||||||
|
const route = signRouteFor(input.activeSectionId);
|
||||||
|
return route.eligible && !completedTargets.has(route.id) ? route.id : null;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
input.candidateId === null
|
||||||
|
|| input.candidateId === input.activeSectionId
|
||||||
|
|| input.candidateDistanceVh === null
|
||||||
|
|| input.candidateDistanceVh <= 0
|
||||||
|
) return null;
|
||||||
|
const route = signRouteFor(input.candidateId);
|
||||||
|
return route.eligible
|
||||||
|
&& input.candidateDistanceVh <= route.triggerMaximumVh
|
||||||
|
&& !completedTargets.has(route.id)
|
||||||
|
? route.id
|
||||||
|
: null;
|
||||||
|
};
|
||||||
|
|
||||||
const sample = (input: SignControllerSample): SignSnapshot | null => {
|
const sample = (input: SignControllerSample): SignSnapshot | null => {
|
||||||
if (!isFiniteSample(input) || (lastNow !== null && input.now < lastNow)) {
|
if (!isFiniteSample(input) || (lastNow !== null && input.now < lastNow)) {
|
||||||
reset();
|
reset();
|
||||||
@@ -184,85 +189,113 @@ export function createSignController(): SignController {
|
|||||||
lastNow = input.now;
|
lastNow = input.now;
|
||||||
|
|
||||||
if (!input.motionAllowed || input.source !== 'passive-scroll') {
|
if (!input.motionAllowed || input.source !== 'passive-scroll') {
|
||||||
|
if (input.source !== 'passive-scroll') {
|
||||||
|
const route = signRouteFor(input.activeSectionId);
|
||||||
|
if (route.eligible) completedTargets.add(route.id);
|
||||||
|
}
|
||||||
reset(input.now);
|
reset(input.now);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const candidate = canonicalCandidate(input);
|
||||||
const discontinuous =
|
const discontinuous =
|
||||||
input.crossedBoundaries > 1 ||
|
input.crossedBoundaries > 1
|
||||||
Math.abs(input.deltaY) > input.viewportHeight * JUMP_DISTANCE_VH;
|
|| Math.abs(input.deltaY) > input.viewportHeight * 0.75;
|
||||||
if (discontinuous) {
|
|
||||||
if (state.kind !== 'visible') return null;
|
|
||||||
const current = snapshotVisible(state, input.now);
|
|
||||||
if (current === null || current.opacity <= CONCEALED_OPACITY) {
|
|
||||||
enterCooldown(input.now);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
requestDeparture(state, current, input.now);
|
|
||||||
return snapshotVisible(state, input.now);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state.kind === 'cooldown') {
|
if (state.kind === 'cooldown') {
|
||||||
|
if (candidate !== null) pendingTargetId = candidate;
|
||||||
if (input.now < state.until) return null;
|
if (input.now < state.until) return null;
|
||||||
state = { kind: 'idle' };
|
state = { kind: 'idle' };
|
||||||
|
if (pendingTargetId !== null && !completedTargets.has(pendingTargetId)) {
|
||||||
|
const targetId = pendingTargetId;
|
||||||
|
pendingTargetId = null;
|
||||||
|
state = latch(
|
||||||
|
targetId,
|
||||||
|
input.now,
|
||||||
|
input.acceleration,
|
||||||
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
|
input.viewportHeight,
|
||||||
|
);
|
||||||
|
return snapshotVisible(state, input.now);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.kind === 'idle') {
|
if (state.kind === 'idle') {
|
||||||
if (!canLatch(input)) return null;
|
if (candidate === null) return null;
|
||||||
state = latch(input);
|
state = latch(
|
||||||
|
candidate,
|
||||||
|
input.now,
|
||||||
|
input.acceleration,
|
||||||
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
|
input.viewportHeight,
|
||||||
|
);
|
||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (candidate !== null && candidate !== state.targetId) {
|
||||||
|
pendingTargetId = candidate;
|
||||||
|
}
|
||||||
const current = snapshotVisible(state, input.now);
|
const current = snapshotVisible(state, input.now);
|
||||||
if (current === null) return null;
|
if (current === null) return null;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
current.opacity <= CONCEALED_OPACITY
|
current.opacity <= SIGN_LIFECYCLE.concealedOpacity
|
||||||
&& canLatch(input)
|
&& candidate !== null
|
||||||
&& input.candidateId !== state.targetId
|
&& candidate !== state.targetId
|
||||||
) {
|
) {
|
||||||
state = latch(input);
|
pendingTargetId = null;
|
||||||
|
state = latch(
|
||||||
|
candidate,
|
||||||
|
input.now,
|
||||||
|
input.acceleration,
|
||||||
|
input.viewportWidth ?? input.viewportHeight * (16 / 9),
|
||||||
|
input.viewportHeight,
|
||||||
|
);
|
||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeIndex = SECTION_ORDER.indexOf(input.activeSectionId);
|
if (discontinuous && state.departStartedAt === null) {
|
||||||
const targetIndex = SECTION_ORDER.indexOf(state.targetId);
|
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
|
||||||
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
|
state = { kind: 'idle' };
|
||||||
if (targetPassed && state.departStartedAt === null) {
|
|
||||||
if (current.opacity <= CONCEALED_OPACITY) {
|
|
||||||
enterCooldown(input.now);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
requestDeparture(state, current, input.now);
|
requestDeparture(state, input.now);
|
||||||
|
return snapshotVisible(state, input.now);
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeIndex = SIGN_ROUTE_ORDER.indexOf(input.activeSectionId);
|
||||||
|
const targetIndex = SIGN_ROUTE_ORDER.indexOf(state.targetId);
|
||||||
|
const targetPassed = activeIndex >= 0 && targetIndex >= 0 && activeIndex > targetIndex;
|
||||||
|
if (targetPassed && state.departStartedAt === null) {
|
||||||
|
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
|
||||||
|
state = { kind: 'idle' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
requestDeparture(state, input.now);
|
||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.direction < 0) {
|
if (input.direction < 0) {
|
||||||
if (current.opacity <= CONCEALED_OPACITY) {
|
if (current.opacity <= SIGN_LIFECYCLE.concealedOpacity) {
|
||||||
state = { kind: 'idle' };
|
state = { kind: 'idle' };
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (state.departStartedAt === null) requestDeparture(state, current, input.now);
|
if (state.departStartedAt === null) requestDeparture(state, input.now);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.departStartedAt === null) {
|
if (state.departStartedAt === null) {
|
||||||
const dwellStartedAt = state.latchedAt + ANTICIPATE_MS + state.approachDuration;
|
const dwellStartedAt = state.dwellStartedAt;
|
||||||
const dwellComplete = input.now >= dwellStartedAt + DWELL_MS;
|
const dwellComplete = dwellStartedAt !== null
|
||||||
if (
|
&& input.now >= dwellStartedAt + SIGN_LIFECYCLE.dwellMs;
|
||||||
dwellComplete
|
if (dwellComplete) beginDeparture(state, input.now);
|
||||||
&& (state.departureRequested || input.activeSectionId === state.targetId)
|
|
||||||
) {
|
|
||||||
beginDeparture(state, input.now);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshotVisible(state, input.now);
|
return snapshotVisible(state, input.now);
|
||||||
};
|
};
|
||||||
|
|
||||||
const needsFrame = (): boolean => {
|
const needsFrame = (): boolean => {
|
||||||
if (state.kind !== 'visible' || lastNow === null) return false;
|
if (state.kind === 'cooldown') return pendingTargetId !== null;
|
||||||
if (state.departStartedAt !== null) return true;
|
return state.kind === 'visible' && lastNow !== null;
|
||||||
return lastNow < state.latchedAt + ANTICIPATE_MS + state.approachDuration + DWELL_MS;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return { sample, reset, needsFrame };
|
return { sample, reset, needsFrame };
|
||||||
|
|||||||
75
src/journey/sign-routes.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { SectionId } from '@/content/types';
|
||||||
|
|
||||||
|
export type SignCorridor = 'left' | 'right' | 'outer-left' | 'outer-right';
|
||||||
|
export type SignViewportProfile = 'wide' | 'tall' | 'short';
|
||||||
|
|
||||||
|
export interface SignRouteSpec {
|
||||||
|
id: SectionId;
|
||||||
|
label: string;
|
||||||
|
marker: string;
|
||||||
|
eligible: boolean;
|
||||||
|
corridor: SignCorridor | null;
|
||||||
|
triggerMaximumVh: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SIGN_LIFECYCLE = Object.freeze({
|
||||||
|
anticipateMs: 300,
|
||||||
|
approachMs: 900,
|
||||||
|
approachReductionMs: 250,
|
||||||
|
dwellMs: 1_400,
|
||||||
|
departMs: 700,
|
||||||
|
cooldownMs: 350,
|
||||||
|
concealedOpacity: 0.02,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SIGN_ROUTE_ORDER: readonly SectionId[] = [
|
||||||
|
'start',
|
||||||
|
'profile',
|
||||||
|
'experience',
|
||||||
|
'infrastructure',
|
||||||
|
'selected-builds',
|
||||||
|
'additional-work',
|
||||||
|
'toolkit',
|
||||||
|
'contact',
|
||||||
|
];
|
||||||
|
|
||||||
|
const routeSpecs: Readonly<Record<SectionId, SignRouteSpec>> = Object.freeze({
|
||||||
|
start: {
|
||||||
|
id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0,
|
||||||
|
},
|
||||||
|
profile: {
|
||||||
|
id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
||||||
|
},
|
||||||
|
experience: {
|
||||||
|
id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
|
||||||
|
},
|
||||||
|
infrastructure: {
|
||||||
|
id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
|
||||||
|
},
|
||||||
|
'selected-builds': {
|
||||||
|
id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55,
|
||||||
|
},
|
||||||
|
'additional-work': {
|
||||||
|
id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55,
|
||||||
|
},
|
||||||
|
toolkit: {
|
||||||
|
id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55,
|
||||||
|
},
|
||||||
|
contact: {
|
||||||
|
id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function signRouteFor(id: SectionId): SignRouteSpec {
|
||||||
|
return routeSpecs[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signViewportProfileFor(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): SignViewportProfile {
|
||||||
|
const safeWidth = Number.isFinite(width) ? Math.max(1, width) : 1;
|
||||||
|
const safeHeight = Number.isFinite(height) ? Math.max(1, height) : 1;
|
||||||
|
const aspect = safeWidth / safeHeight;
|
||||||
|
return safeHeight < 560 && aspect >= 1 ? 'short' : aspect < 1.05 ? 'tall' : 'wide';
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SectionId } from "@/content/types";
|
import type { SectionId } from "@/content/types";
|
||||||
|
import type { SignViewportProfile } from '@/journey/sign-routes';
|
||||||
|
|
||||||
export interface ScrollDriveSample {
|
export interface ScrollDriveSample {
|
||||||
scrollY: number;
|
scrollY: number;
|
||||||
@@ -38,6 +39,7 @@ export interface SignSnapshot {
|
|||||||
phase: SignPhase;
|
phase: SignPhase;
|
||||||
progress: number;
|
progress: number;
|
||||||
opacity: number;
|
opacity: number;
|
||||||
|
viewportProfile?: SignViewportProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SignControllerSample {
|
export interface SignControllerSample {
|
||||||
@@ -46,6 +48,7 @@ export interface SignControllerSample {
|
|||||||
direction: -1 | 0 | 1;
|
direction: -1 | 0 | 1;
|
||||||
acceleration: number;
|
acceleration: number;
|
||||||
deltaY: number;
|
deltaY: number;
|
||||||
|
viewportWidth?: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
crossedBoundaries: number;
|
crossedBoundaries: number;
|
||||||
candidateId: SectionId | null;
|
candidateId: SectionId | null;
|
||||||
|
|||||||
@@ -574,6 +574,9 @@ function copyJourneySnapshot(snapshot: JourneySnapshot): JourneySnapshot {
|
|||||||
phase: snapshot.sign.phase,
|
phase: snapshot.sign.phase,
|
||||||
progress: finite(snapshot.sign.progress),
|
progress: finite(snapshot.sign.progress),
|
||||||
opacity: finite(snapshot.sign.opacity),
|
opacity: finite(snapshot.sign.opacity),
|
||||||
|
...(snapshot.sign.viewportProfile === undefined
|
||||||
|
? {}
|
||||||
|
: { viewportProfile: snapshot.sign.viewportProfile }),
|
||||||
} : null,
|
} : null,
|
||||||
arrived: Boolean(snapshot.arrived),
|
arrived: Boolean(snapshot.arrived),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { createRoad } from '@/scene/world/road';
|
|||||||
import { createTerrain } from '@/scene/world/terrain';
|
import { createTerrain } from '@/scene/world/terrain';
|
||||||
import { createSun } from '@/scene/world/sun';
|
import { createSun } from '@/scene/world/sun';
|
||||||
import { createCar, loadGrandTourer } from '@/scene/world/car';
|
import { createCar, loadGrandTourer } from '@/scene/world/car';
|
||||||
import { createSign } from '@/scene/world/sign';
|
import { createSign, prepareSignTypography } from '@/scene/world/sign';
|
||||||
import { createAtmosphere } from '@/scene/world/atmosphere';
|
import { createAtmosphere } from '@/scene/world/atmosphere';
|
||||||
import { createPostProcessor } from '@/scene/post-processing';
|
import { createPostProcessor } from '@/scene/post-processing';
|
||||||
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
import { createDriveDynamics } from '@/scene/drive-dynamics';
|
||||||
@@ -97,7 +97,10 @@ export async function createProceduralSceneGeneration(
|
|||||||
const authoredCar = await loadGrandTourer(context.signal);
|
const authoredCar = await loadGrandTourer(context.signal);
|
||||||
const car = createCar(context.resources, authoredCar);
|
const car = createCar(context.resources, authoredCar);
|
||||||
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
|
||||||
const sign = createSign(context.resources);
|
await prepareSignTypography(context.signal);
|
||||||
|
const sign = createSign(context.resources, {
|
||||||
|
maximumAnisotropy: renderer.capabilities.getMaxAnisotropy(),
|
||||||
|
});
|
||||||
const atmosphere = createAtmosphere(context.resources, quality);
|
const atmosphere = createAtmosphere(context.resources, quality);
|
||||||
scene.add(
|
scene.add(
|
||||||
atmosphere.group,
|
atmosphere.group,
|
||||||
@@ -152,7 +155,7 @@ export async function createProceduralSceneGeneration(
|
|||||||
}
|
}
|
||||||
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
|
car.setDetail(width < 1_100 || height < 620 ? 'reduced' : 'full');
|
||||||
car.group.visible = height >= 340;
|
car.group.visible = height >= 340;
|
||||||
sign.setViewportAspect(camera.aspect);
|
sign.setViewportSize(width, height);
|
||||||
atmosphere.setViewportAspect(camera.aspect);
|
atmosphere.setViewportAspect(camera.aspect);
|
||||||
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
postProcessor?.resize(width, height, quality.pixelRatio * quality.postScale);
|
||||||
if (TEST_BUILD && context.quality !== 'medium') {
|
if (TEST_BUILD && context.quality !== 'medium') {
|
||||||
@@ -296,6 +299,21 @@ export async function createProceduralSceneGeneration(
|
|||||||
camera.fov = cameraState.fov;
|
camera.fov = cameraState.fov;
|
||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
if (TEST_BUILD && snapshot.sign !== null) {
|
||||||
|
const presentation = sign.inspect(camera, width, height);
|
||||||
|
if (presentation !== null) {
|
||||||
|
window.dispatchEvent(new CustomEvent('portfolio-sign-frame', {
|
||||||
|
detail: {
|
||||||
|
now,
|
||||||
|
...snapshot.sign,
|
||||||
|
activeSectionId: snapshot.activeSectionId,
|
||||||
|
direction: snapshot.direction,
|
||||||
|
...presentation,
|
||||||
|
rendererPixelRatio: renderer.getPixelRatio(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (postProcessor) {
|
if (postProcessor) {
|
||||||
postProcessor.setDynamics(response.bloomEnergy);
|
postProcessor.setDynamics(response.bloomEnergy);
|
||||||
postProcessor.render(scene, camera);
|
postProcessor.render(scene, camera);
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import type { SectionId } from '@/content/types';
|
import type { SectionId } from '@/content/types';
|
||||||
import type { SignSnapshot } from '@/journey/types';
|
import type { SignSnapshot } from '@/journey/types';
|
||||||
|
import {
|
||||||
|
signRouteFor,
|
||||||
|
signViewportProfileFor,
|
||||||
|
type SignCorridor,
|
||||||
|
type SignRouteSpec,
|
||||||
|
type SignViewportProfile,
|
||||||
|
} from '@/journey/sign-routes';
|
||||||
import type { SceneResourceBag } from '@/scene/resources';
|
import type { SceneResourceBag } from '@/scene/resources';
|
||||||
|
|
||||||
const routeLabels: Readonly<Record<SectionId, string>> = {
|
|
||||||
start: 'Start',
|
|
||||||
profile: 'Profile',
|
|
||||||
experience: 'Experience',
|
|
||||||
infrastructure: 'Infrastructure',
|
|
||||||
'selected-builds': 'Selected builds',
|
|
||||||
'additional-work': 'Additional work',
|
|
||||||
toolkit: 'Toolkit',
|
|
||||||
contact: 'Contact',
|
|
||||||
};
|
|
||||||
|
|
||||||
interface SignPose {
|
interface SignPose {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -21,22 +17,111 @@ interface SignPose {
|
|||||||
scale: number;
|
scale: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NARROW_SIGN_POSES = {
|
interface SignPoseSet {
|
||||||
horizon: { x: 5.2, y: 6.8, z: -142, scale: 0.15 },
|
horizon: SignPose;
|
||||||
dwell: { x: 5.8, y: 4.86, z: -20, scale: 0.82 },
|
dwell: SignPose;
|
||||||
} as const;
|
}
|
||||||
|
|
||||||
const WIDE_SIGN_POSES = {
|
interface ProjectingCamera {
|
||||||
horizon: { x: 9.1, y: 7.4, z: -160, scale: 0.18 },
|
updateMatrixWorld(force: boolean): void;
|
||||||
dwell: { x: 10.7, y: 6.64, z: -14, scale: 1.12 },
|
}
|
||||||
} as const;
|
|
||||||
|
const SIGN_MARKER_FONT = '560 46px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
|
const SIGN_LABEL_FONT = '660 164px "Geist Mono Variable", ui-monospace, monospace';
|
||||||
|
|
||||||
|
const SIGN_POSES: Readonly<Record<SignViewportProfile, Readonly<Record<SignCorridor, SignPoseSet>>>> = {
|
||||||
|
wide: {
|
||||||
|
left: {
|
||||||
|
horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
|
||||||
|
dwell: { x: -11.6, y: 7.9, z: -16, scale: 1.25 },
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
|
||||||
|
dwell: { x: 12.4, y: 7.9, z: -16, scale: 1.25 },
|
||||||
|
},
|
||||||
|
'outer-left': {
|
||||||
|
horizon: { x: -17.5, y: 7.4, z: -168, scale: 0.2 },
|
||||||
|
dwell: { x: -21.5, y: 8.55, z: -24, scale: 1.35 },
|
||||||
|
},
|
||||||
|
'outer-right': {
|
||||||
|
horizon: { x: 18.5, y: 7.4, z: -168, scale: 0.2 },
|
||||||
|
dwell: { x: 22.5, y: 8.55, z: -24, scale: 1.35 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tall: {
|
||||||
|
left: {
|
||||||
|
horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||||
|
dwell: { x: -5.2, y: 8.1, z: -24, scale: 1.3 },
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
|
||||||
|
dwell: { x: 5.2, y: 8.1, z: -24, scale: 1.3 },
|
||||||
|
},
|
||||||
|
'outer-left': {
|
||||||
|
horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||||
|
dwell: { x: -7.2, y: 8.72, z: -30, scale: 1.4 },
|
||||||
|
},
|
||||||
|
'outer-right': {
|
||||||
|
horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
|
||||||
|
dwell: { x: 7.2, y: 8.72, z: -30, scale: 1.4 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
short: {
|
||||||
|
left: {
|
||||||
|
horizon: { x: -7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||||
|
dwell: { x: -8.8, y: 5.45, z: -26, scale: 2.55 },
|
||||||
|
},
|
||||||
|
right: {
|
||||||
|
horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
|
||||||
|
dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.55 },
|
||||||
|
},
|
||||||
|
'outer-left': {
|
||||||
|
horizon: { x: -11.2, y: 6.8, z: -154, scale: 0.18 },
|
||||||
|
dwell: { x: -12.6, y: 5.8, z: -32, scale: 2.25 },
|
||||||
|
},
|
||||||
|
'outer-right': {
|
||||||
|
horizon: { x: 11.2, y: 6.8, z: -154, scale: 0.18 },
|
||||||
|
dwell: { x: 12.6, y: 5.8, z: -32, scale: 2.25 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export interface SignWorld {
|
export interface SignWorld {
|
||||||
group: SignGroup;
|
group: SignGroup;
|
||||||
|
inspect(
|
||||||
|
camera: ProjectingCamera,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): SignPresentation | null;
|
||||||
setViewportAspect(aspect: number): void;
|
setViewportAspect(aspect: number): void;
|
||||||
|
setViewportSize(width: number, height: number): void;
|
||||||
update(sign: SignSnapshot | null): void;
|
update(sign: SignSnapshot | null): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SignPresentation {
|
||||||
|
visible: boolean;
|
||||||
|
corridor: SignCorridor;
|
||||||
|
viewportProfile: SignViewportProfile;
|
||||||
|
bounds: {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
texture: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
anisotropy: number;
|
||||||
|
fontReadyWhenDrawn: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignRenderOptions {
|
||||||
|
maximumAnisotropy?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface SignGroup {
|
interface SignGroup {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
position: {
|
position: {
|
||||||
@@ -51,13 +136,28 @@ interface SignGroup {
|
|||||||
scale: { x: number; setScalar(scale: number): void };
|
scale: { x: number; setScalar(scale: number): void };
|
||||||
add(...objects: unknown[]): void;
|
add(...objects: unknown[]): void;
|
||||||
getObjectByName(name: string): unknown;
|
getObjectByName(name: string): unknown;
|
||||||
|
updateMatrixWorld(force: boolean): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function routeLabelFor(id: SectionId): string {
|
export function routeLabelFor(id: SectionId): string {
|
||||||
return routeLabels[id];
|
return signRouteFor(id).label;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSign(resources: SceneResourceBag): SignWorld {
|
export async function prepareSignTypography(signal: AbortSignal): Promise<void> {
|
||||||
|
const fonts = document.fonts;
|
||||||
|
if (fonts === undefined) return;
|
||||||
|
await Promise.all([
|
||||||
|
fonts.load(SIGN_MARKER_FONT),
|
||||||
|
fonts.load(SIGN_LABEL_FONT),
|
||||||
|
fonts.ready,
|
||||||
|
]);
|
||||||
|
if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSign(
|
||||||
|
resources: SceneResourceBag,
|
||||||
|
options: SignRenderOptions = {},
|
||||||
|
): SignWorld {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = 'roadside-sign';
|
group.name = 'roadside-sign';
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
@@ -103,7 +203,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const housing = new THREE.Mesh(
|
const housing = new THREE.Mesh(
|
||||||
resources.own('geometry', createSignBody(7.5, 2.3, 0.34, 0.2)),
|
resources.own('geometry', createSignBody(8.55, 2.65, 0.42, 0.22)),
|
||||||
housingMaterial,
|
housingMaterial,
|
||||||
);
|
);
|
||||||
housing.name = 'sign-housing';
|
housing.name = 'sign-housing';
|
||||||
@@ -111,7 +211,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
group.add(housing);
|
group.add(housing);
|
||||||
|
|
||||||
const backplate = new THREE.Mesh(
|
const backplate = new THREE.Mesh(
|
||||||
resources.own('geometry', createSignBody(7.2, 2.04, 0.18, 0.16)),
|
resources.own('geometry', createSignBody(8.24, 2.34, 0.2, 0.18)),
|
||||||
backplateMaterial,
|
backplateMaterial,
|
||||||
);
|
);
|
||||||
backplate.name = 'sign-backplate';
|
backplate.name = 'sign-backplate';
|
||||||
@@ -119,15 +219,18 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
group.add(backplate);
|
group.add(backplate);
|
||||||
|
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = 1_024;
|
canvas.width = 2_048;
|
||||||
canvas.height = 288;
|
canvas.height = 512;
|
||||||
const context = canvas.getContext('2d');
|
const context = canvas.getContext('2d');
|
||||||
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
const texture = resources.own('texture', new THREE.CanvasTexture(canvas));
|
||||||
texture.colorSpace = THREE.SRGBColorSpace;
|
texture.colorSpace = THREE.SRGBColorSpace;
|
||||||
texture.generateMipmaps = true;
|
texture.generateMipmaps = true;
|
||||||
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
texture.minFilter = THREE.LinearMipmapLinearFilter;
|
||||||
texture.magFilter = THREE.LinearFilter;
|
texture.magFilter = THREE.LinearFilter;
|
||||||
texture.anisotropy = 4;
|
texture.anisotropy = Math.min(
|
||||||
|
8,
|
||||||
|
Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)),
|
||||||
|
);
|
||||||
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
|
||||||
color: 0xffffff,
|
color: 0xffffff,
|
||||||
transparent: true,
|
transparent: true,
|
||||||
@@ -140,55 +243,56 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
toneMapped: false,
|
toneMapped: false,
|
||||||
}));
|
}));
|
||||||
const face = new THREE.Mesh(
|
const face = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.PlaneGeometry(6.92, 1.76)),
|
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
|
||||||
faceMaterial,
|
faceMaterial,
|
||||||
);
|
);
|
||||||
face.name = 'sign-face';
|
face.name = 'sign-face';
|
||||||
face.position.z = 0.545;
|
face.position.z = 0.625;
|
||||||
face.userData.textureUpdateCount = 0;
|
face.userData.textureUpdateCount = 0;
|
||||||
|
face.userData.fontReadyWhenDrawn = false;
|
||||||
group.add(face);
|
group.add(face);
|
||||||
|
|
||||||
const edgeLight = new THREE.Group();
|
const edgeLight = new THREE.Group();
|
||||||
edgeLight.name = 'sign-edge-light';
|
edgeLight.name = 'sign-edge-light';
|
||||||
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.42, 0.055));
|
const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.055, 1.62, 0.055));
|
||||||
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.16, 0.045, 0.055));
|
const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.32, 0.045, 0.055));
|
||||||
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
|
||||||
leftEdge.position.set(-3.36, 0, 0.585);
|
leftEdge.position.set(-3.9, 0, 0.665);
|
||||||
edgeLight.add(leftEdge);
|
edgeLight.add(leftEdge);
|
||||||
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
|
||||||
upperEdge.position.set(-2.81, 0.88, 0.585);
|
upperEdge.position.set(-3.28, 1, 0.665);
|
||||||
edgeLight.add(upperEdge);
|
edgeLight.add(upperEdge);
|
||||||
group.add(edgeLight);
|
group.add(edgeLight);
|
||||||
|
|
||||||
const routeAccent = new THREE.Mesh(
|
const routeAccent = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.BoxGeometry(1.05, 0.055, 0.065)),
|
resources.own('geometry', new THREE.BoxGeometry(1.18, 0.055, 0.065)),
|
||||||
routeMaterial,
|
routeMaterial,
|
||||||
);
|
);
|
||||||
routeAccent.name = 'sign-route-accent';
|
routeAccent.name = 'sign-route-accent';
|
||||||
routeAccent.position.set(2.77, -0.88, 0.585);
|
routeAccent.position.set(3.35, -1, 0.665);
|
||||||
group.add(routeAccent);
|
group.add(routeAccent);
|
||||||
|
|
||||||
const post = new THREE.Mesh(
|
const post = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.14, 4.85, 8)),
|
resources.own('geometry', new THREE.CylinderGeometry(0.095, 0.145, 5.05, 8)),
|
||||||
supportMaterial,
|
supportMaterial,
|
||||||
);
|
);
|
||||||
post.name = 'sign-post';
|
post.name = 'sign-post';
|
||||||
post.position.set(2.35, -3.32, -0.14);
|
post.position.set(2.75, -3.58, -0.16);
|
||||||
group.add(post);
|
group.add(post);
|
||||||
|
|
||||||
const bracket = new THREE.Group();
|
const bracket = new THREE.Group();
|
||||||
bracket.name = 'sign-bracket';
|
bracket.name = 'sign-bracket';
|
||||||
const arm = new THREE.Mesh(
|
const arm = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.BoxGeometry(2.5, 0.11, 0.15)),
|
resources.own('geometry', new THREE.BoxGeometry(2.95, 0.11, 0.15)),
|
||||||
supportMaterial,
|
supportMaterial,
|
||||||
);
|
);
|
||||||
arm.position.set(1.18, -1.08, -0.13);
|
arm.position.set(1.4, -1.24, -0.15);
|
||||||
bracket.add(arm);
|
bracket.add(arm);
|
||||||
const brace = new THREE.Mesh(
|
const brace = new THREE.Mesh(
|
||||||
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
|
resources.own('geometry', new THREE.BoxGeometry(0.1, 1.25, 0.12)),
|
||||||
supportMaterial,
|
supportMaterial,
|
||||||
);
|
);
|
||||||
brace.position.set(1.72, -1.56, -0.13);
|
brace.position.set(2.05, -1.74, -0.15);
|
||||||
brace.rotation.z = -0.72;
|
brace.rotation.z = -0.72;
|
||||||
bracket.add(brace);
|
bracket.add(brace);
|
||||||
group.add(bracket);
|
group.add(bracket);
|
||||||
@@ -198,7 +302,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
supportMaterial,
|
supportMaterial,
|
||||||
);
|
);
|
||||||
footing.name = 'sign-footing';
|
footing.name = 'sign-footing';
|
||||||
footing.position.set(2.35, -5.76, -0.14);
|
footing.position.set(2.75, -6.18, -0.16);
|
||||||
group.add(footing);
|
group.add(footing);
|
||||||
|
|
||||||
const fadeMaterials = [
|
const fadeMaterials = [
|
||||||
@@ -212,15 +316,24 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
const departureOrigin = new THREE.Vector3();
|
const departureOrigin = new THREE.Vector3();
|
||||||
let departureScale = 1;
|
let departureScale = 1;
|
||||||
let target: SectionId | null = null;
|
let target: SectionId | null = null;
|
||||||
let pendingNarrowViewport = false;
|
let pendingViewport: SignViewportProfile = 'wide';
|
||||||
let activeNarrowViewport = false;
|
let activeViewport: SignViewportProfile = 'wide';
|
||||||
|
let activeCorridor: SignCorridor = 'right';
|
||||||
let previousPhase: SignSnapshot['phase'] | null = null;
|
let previousPhase: SignSnapshot['phase'] | null = null;
|
||||||
|
const faceCornerCoordinates = [
|
||||||
|
[-4, -1, 0],
|
||||||
|
[4, -1, 0],
|
||||||
|
[4, 1, 0],
|
||||||
|
[-4, 1, 0],
|
||||||
|
] as const;
|
||||||
|
const projectedCorner = new THREE.Vector3();
|
||||||
|
|
||||||
const updateTexture = (id: SectionId): void => {
|
const updateTexture = (id: SectionId): void => {
|
||||||
if (target === id) return;
|
if (target === id) return;
|
||||||
target = id;
|
target = id;
|
||||||
face.userData.textureUpdateCount += 1;
|
face.userData.textureUpdateCount += 1;
|
||||||
if (context) drawLabel(context, routeLabelFor(id), canvas.width, canvas.height);
|
face.userData.fontReadyWhenDrawn = document.fonts?.check(SIGN_LABEL_FONT) ?? true;
|
||||||
|
if (context) drawLabel(context, signRouteFor(id), canvas.width, canvas.height);
|
||||||
texture.needsUpdate = true;
|
texture.needsUpdate = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -231,34 +344,92 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
const applyPose = (pose: SignPose): void => {
|
const applyPose = (pose: SignPose): void => {
|
||||||
group.position.set(pose.x, pose.y, pose.z);
|
group.position.set(pose.x, pose.y, pose.z);
|
||||||
group.scale.setScalar(pose.scale);
|
group.scale.setScalar(pose.scale);
|
||||||
|
plantMounting(pose.y, pose.scale);
|
||||||
|
};
|
||||||
|
|
||||||
|
const plantMounting = (groupY: number, groupScale: number): void => {
|
||||||
|
const safeScale = Math.max(0.001, groupScale);
|
||||||
|
const footingY = -groupY / safeScale;
|
||||||
|
const postTopY = -1.36;
|
||||||
|
const postHeight = Math.max(0.36, postTopY - footingY);
|
||||||
|
post.position.y = (postTopY + footingY) * 0.5;
|
||||||
|
post.scale.set(1 / safeScale, postHeight / 5.05, 1 / safeScale);
|
||||||
|
footing.position.y = footingY;
|
||||||
|
footing.scale.setScalar(1 / Math.max(1, safeScale));
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
group,
|
group,
|
||||||
|
inspect(camera, width, height) {
|
||||||
|
if (target === null || width <= 0 || height <= 0) return null;
|
||||||
|
group.updateMatrixWorld(true);
|
||||||
|
camera.updateMatrixWorld(true);
|
||||||
|
let left = Number.POSITIVE_INFINITY;
|
||||||
|
let top = Number.POSITIVE_INFINITY;
|
||||||
|
let right = Number.NEGATIVE_INFINITY;
|
||||||
|
let bottom = Number.NEGATIVE_INFINITY;
|
||||||
|
for (const [xPosition, yPosition, zPosition] of faceCornerCoordinates) {
|
||||||
|
projectedCorner
|
||||||
|
.set(xPosition, yPosition, zPosition)
|
||||||
|
.applyMatrix4(face.matrixWorld)
|
||||||
|
.project(camera);
|
||||||
|
const x = (projectedCorner.x + 1) * 0.5 * width;
|
||||||
|
const y = (1 - projectedCorner.y) * 0.5 * height;
|
||||||
|
left = Math.min(left, x);
|
||||||
|
top = Math.min(top, y);
|
||||||
|
right = Math.max(right, x);
|
||||||
|
bottom = Math.max(bottom, y);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
visible: group.visible,
|
||||||
|
corridor: activeCorridor,
|
||||||
|
viewportProfile: activeViewport,
|
||||||
|
bounds: {
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
right,
|
||||||
|
bottom,
|
||||||
|
width: right - left,
|
||||||
|
height: bottom - top,
|
||||||
|
},
|
||||||
|
texture: {
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
anisotropy: texture.anisotropy,
|
||||||
|
fontReadyWhenDrawn: face.userData.fontReadyWhenDrawn === true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
setViewportAspect(aspect) {
|
setViewportAspect(aspect) {
|
||||||
pendingNarrowViewport = !Number.isFinite(aspect) || aspect < 0.85;
|
pendingViewport = !Number.isFinite(aspect) || aspect < 0.85 ? 'tall' : 'wide';
|
||||||
if (!group.visible) activeNarrowViewport = pendingNarrowViewport;
|
if (previousPhase === null) activeViewport = pendingViewport;
|
||||||
|
},
|
||||||
|
setViewportSize(width, height) {
|
||||||
|
pendingViewport = signViewportProfileFor(width, height);
|
||||||
|
if (previousPhase === null) activeViewport = pendingViewport;
|
||||||
},
|
},
|
||||||
update(sign) {
|
update(sign) {
|
||||||
if (!sign) {
|
if (!sign) {
|
||||||
group.visible = false;
|
group.visible = false;
|
||||||
applyOpacity(0);
|
applyOpacity(0);
|
||||||
previousPhase = null;
|
previousPhase = null;
|
||||||
activeNarrowViewport = pendingNarrowViewport;
|
activeViewport = pendingViewport;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target !== null && target !== sign.targetId && group.visible) return;
|
if (target !== null && target !== sign.targetId && group.visible) return;
|
||||||
if (target !== sign.targetId) {
|
if (target !== sign.targetId) {
|
||||||
activeNarrowViewport = pendingNarrowViewport;
|
activeViewport = sign.viewportProfile ?? pendingViewport;
|
||||||
updateTexture(sign.targetId);
|
updateTexture(sign.targetId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const opacity = clamp01(sign.opacity);
|
const opacity = clamp01(sign.opacity);
|
||||||
const progress = smoothstep(clamp01(sign.progress));
|
const progress = smoothstep(clamp01(sign.progress));
|
||||||
const { horizon, dwell } = activeNarrowViewport
|
const route = signRouteFor(sign.targetId);
|
||||||
? NARROW_SIGN_POSES
|
const corridor = route.corridor ?? 'right';
|
||||||
: WIDE_SIGN_POSES;
|
activeCorridor = corridor;
|
||||||
|
const { horizon, dwell } = SIGN_POSES[activeViewport][corridor];
|
||||||
|
const side = corridor.includes('left') ? -1 : 1;
|
||||||
|
|
||||||
if (sign.phase === 'anticipate') {
|
if (sign.phase === 'anticipate') {
|
||||||
applyPose(horizon);
|
applyPose(horizon);
|
||||||
@@ -269,6 +440,7 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
|
||||||
);
|
);
|
||||||
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwell.scale, progress));
|
||||||
|
plantMounting(group.position.y, group.scale.x);
|
||||||
} else if (sign.phase === 'dwell') {
|
} else if (sign.phase === 'dwell') {
|
||||||
applyPose(dwell);
|
applyPose(dwell);
|
||||||
} else {
|
} else {
|
||||||
@@ -277,15 +449,18 @@ export function createSign(resources: SceneResourceBag): SignWorld {
|
|||||||
departureOrigin.copy(group.position);
|
departureOrigin.copy(group.position);
|
||||||
departureScale = group.scale.x;
|
departureScale = group.scale.x;
|
||||||
}
|
}
|
||||||
|
const lateralExit = activeViewport === 'wide'
|
||||||
|
? 14
|
||||||
|
: activeViewport === 'short' ? 10 : 8;
|
||||||
group.position.set(
|
group.position.set(
|
||||||
departureOrigin.x + progress * (activeNarrowViewport ? 2.2 : 4.4),
|
departureOrigin.x + progress * side * lateralExit,
|
||||||
departureOrigin.y + progress * 0.62,
|
departureOrigin.y + progress * 0.72,
|
||||||
THREE.MathUtils.lerp(departureOrigin.z, 28, progress),
|
departureOrigin.z - progress * 32,
|
||||||
);
|
);
|
||||||
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 1.06, progress));
|
group.scale.setScalar(departureScale * THREE.MathUtils.lerp(1, 0.82, progress));
|
||||||
}
|
}
|
||||||
|
|
||||||
group.rotation.y = activeNarrowViewport ? -0.14 : -0.23;
|
group.rotation.y = -side * (activeViewport === 'wide' ? 0.2 : 0.12);
|
||||||
applyOpacity(opacity);
|
applyOpacity(opacity);
|
||||||
group.visible = opacity > 0;
|
group.visible = opacity > 0;
|
||||||
previousPhase = sign.phase;
|
previousPhase = sign.phase;
|
||||||
@@ -325,32 +500,32 @@ function createClippedShape(width: number, height: number, cut: number) {
|
|||||||
|
|
||||||
function drawLabel(
|
function drawLabel(
|
||||||
context: CanvasRenderingContext2D,
|
context: CanvasRenderingContext2D,
|
||||||
label: string,
|
route: SignRouteSpec,
|
||||||
width: number,
|
width: number,
|
||||||
height: number,
|
height: number,
|
||||||
): void {
|
): void {
|
||||||
context.clearRect(0, 0, width, height);
|
context.clearRect(0, 0, width, height);
|
||||||
context.fillStyle = '#07111a';
|
context.fillStyle = '#07111a';
|
||||||
context.fillRect(0, 0, width, height);
|
context.fillRect(0, 0, width, height);
|
||||||
context.fillStyle = '#55d4df';
|
context.fillStyle = '#4cbac4';
|
||||||
context.fillRect(42, 36, 8, height - 72);
|
context.fillRect(86, 72, 12, height - 144);
|
||||||
context.fillStyle = '#91aab3';
|
context.fillStyle = '#879da4';
|
||||||
context.font = '500 23px "Geist Mono Variable", ui-monospace, monospace';
|
context.font = SIGN_MARKER_FONT;
|
||||||
context.textAlign = 'left';
|
context.textAlign = 'left';
|
||||||
context.textBaseline = 'middle';
|
context.textBaseline = 'middle';
|
||||||
context.fillText('NEXT / ROUTE', 82, 72);
|
context.fillText(`ROUTE ${route.marker} / NEXT`, 150, 126);
|
||||||
context.fillStyle = '#eef7f8';
|
context.fillStyle = '#c8d8da';
|
||||||
context.font = '650 74px "Geist Mono Variable", ui-monospace, monospace';
|
context.font = SIGN_LABEL_FONT;
|
||||||
context.fillText(label, 82, 170, width - 210);
|
context.fillText(route.label, 150, 326, width - 420);
|
||||||
context.strokeStyle = '#d99758';
|
context.strokeStyle = '#cf8e54';
|
||||||
context.lineWidth = 7;
|
context.lineWidth = 12;
|
||||||
context.lineCap = 'square';
|
context.lineCap = 'square';
|
||||||
context.beginPath();
|
context.beginPath();
|
||||||
context.moveTo(width - 116, 156);
|
context.moveTo(width - 250, 304);
|
||||||
context.lineTo(width - 74, 156);
|
context.lineTo(width - 158, 304);
|
||||||
context.lineTo(width - 92, 138);
|
context.lineTo(width - 198, 264);
|
||||||
context.moveTo(width - 74, 156);
|
context.moveTo(width - 158, 304);
|
||||||
context.lineTo(width - 92, 174);
|
context.lineTo(width - 198, 344);
|
||||||
context.stroke();
|
context.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ test('draws only the independently eligible next roadside sign during passive fo
|
|||||||
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
||||||
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.8));
|
const destination = Math.max(1, headingTop - viewportHeight * (0.35 + 0.45));
|
||||||
return { destination, viewportHeight, expectedLabel: 'Profile' };
|
return { destination, viewportHeight, expectedLabel: 'Profile' };
|
||||||
});
|
});
|
||||||
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
|
await page.evaluate(() => window.dispatchEvent(new WheelEvent('wheel', { deltaY: 1 })));
|
||||||
@@ -118,7 +118,7 @@ test('keeps a readable sign immutable through reversal, resize, and a multi-sect
|
|||||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
const headingTop = profile.getBoundingClientRect().top + window.scrollY;
|
||||||
return {
|
return {
|
||||||
destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.8)),
|
destination: Math.max(1, headingTop - viewportHeight * (0.35 + 0.45)),
|
||||||
viewportHeight,
|
viewportHeight,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -163,7 +163,159 @@ test('suppresses roadside signs for explicit navigation and history restoration'
|
|||||||
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
expect((await readSceneInstrumentation(page)).routeLabelsDrawn).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('reconciles an idle rapid jump to the latest active route without flashing intermediates', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const target = document.getElementById('selected-builds-heading');
|
||||||
|
if (!(target instanceof HTMLElement)) throw new Error('Missing selected builds heading');
|
||||||
|
const top = target.getBoundingClientRect().top + window.scrollY;
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
window.scrollTo(0, Math.max(1, top - viewportHeight * 0.35 + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect.poll(async () => (await readSceneInstrumentation(page)).routeLabelsDrawn)
|
||||||
|
.toContain('Selected builds');
|
||||||
|
expect([...new Set((await readSceneInstrumentation(page)).routeLabelsDrawn)])
|
||||||
|
.toEqual(['Selected builds']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the readable Profile face outside its destination panel with verified texel density', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}) => {
|
||||||
|
await page.setViewportSize({ width: 1440, height: 900 });
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
|
||||||
|
const destination = await page.evaluate(() => {
|
||||||
|
const profile = document.getElementById('profile-heading');
|
||||||
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
profile.getBoundingClientRect().top + window.scrollY
|
||||||
|
- viewportHeight * (0.35 + 0.45),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.some((frame) => frame.targetId === 'profile' && frame.phase === 'dwell')).toBe(true);
|
||||||
|
|
||||||
|
const instrumentation = await readSceneInstrumentation(page);
|
||||||
|
const frame = instrumentation.signFrames.findLast((candidate) => (
|
||||||
|
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
||||||
|
));
|
||||||
|
if (frame === undefined) throw new Error('Missing readable Profile sign frame');
|
||||||
|
const panel = await page.locator('#profile').evaluate((element) => {
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
||||||
|
});
|
||||||
|
expect(frame.bounds.right).toBeLessThanOrEqual(panel.left - 16);
|
||||||
|
expect(frame.texture).toMatchObject({
|
||||||
|
width: 2_048,
|
||||||
|
height: 512,
|
||||||
|
fontReadyWhenDrawn: true,
|
||||||
|
});
|
||||||
|
expect(frame.texture.width / (frame.bounds.width * frame.rendererPixelRatio))
|
||||||
|
.toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps a fresh readable sign inside responsive world-safe corridors', async ({
|
||||||
|
page,
|
||||||
|
distUrl,
|
||||||
|
}, testInfo) => {
|
||||||
|
test.skip(testInfo.project.name !== 'desktop-chromium', 'One exact responsive matrix is sufficient.');
|
||||||
|
|
||||||
|
for (const viewport of [
|
||||||
|
{ width: 390, height: 844, profile: 'tall', minimumFaceWidth: 120 },
|
||||||
|
{ width: 844, height: 390, profile: 'short', minimumFaceWidth: 130 },
|
||||||
|
{ width: 768, height: 1_024, profile: 'tall', minimumFaceWidth: 150 },
|
||||||
|
{ width: 1_440, height: 900, profile: 'wide', minimumFaceWidth: 180 },
|
||||||
|
{ width: 2_560, height: 1_440, profile: 'wide', minimumFaceWidth: 240 },
|
||||||
|
] as const) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto(distUrl, { waitUntil: 'domcontentloaded' });
|
||||||
|
await expect(page.locator('.journey-world')).toHaveAttribute('data-scene-state', 'running');
|
||||||
|
const destination = await page.evaluate(() => {
|
||||||
|
const profile = document.getElementById('profile-heading');
|
||||||
|
if (!(profile instanceof HTMLElement)) throw new Error('Missing profile heading');
|
||||||
|
const viewportHeight = window.visualViewport?.height ?? window.innerHeight;
|
||||||
|
return Math.max(
|
||||||
|
1,
|
||||||
|
profile.getBoundingClientRect().top + window.scrollY
|
||||||
|
- viewportHeight * (0.35 + 0.45),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await page.mouse.wheel(0, 1);
|
||||||
|
await page.evaluate((scrollY) => window.scrollTo(0, scrollY), destination);
|
||||||
|
await expect.poll(async () => (
|
||||||
|
await readSceneInstrumentation(page)
|
||||||
|
).signFrames.at(-1)?.phase, { intervals: [16], timeout: 5_000 }).toBe('dwell');
|
||||||
|
|
||||||
|
const frame = (await readSceneInstrumentation(page)).signFrames.findLast((candidate) => (
|
||||||
|
candidate.targetId === 'profile' && candidate.phase === 'dwell'
|
||||||
|
));
|
||||||
|
if (frame === undefined) throw new Error(`Missing Profile dwell at ${viewport.width}x${viewport.height}`);
|
||||||
|
const geometry = await page.evaluate(() => {
|
||||||
|
const boundsFor = (selector: string) => {
|
||||||
|
const element = document.querySelector(selector);
|
||||||
|
if (!(element instanceof HTMLElement)) return null;
|
||||||
|
const bounds = element.getBoundingClientRect();
|
||||||
|
return { left: bounds.left, top: bounds.top, right: bounds.right, bottom: bounds.bottom };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
panel: boundsFor('#profile'),
|
||||||
|
adjacentPanels: [boundsFor('.hero-journey__content')].filter((bounds) => bounds !== null),
|
||||||
|
fixedZones: [
|
||||||
|
boundsFor('.site-navigation'),
|
||||||
|
boundsFor('.drive-controls'),
|
||||||
|
boundsFor('.journey-hud'),
|
||||||
|
].filter((bounds) => bounds !== null),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (geometry.panel === null) throw new Error('Missing Profile panel bounds');
|
||||||
|
expect(frame.viewportProfile).toBe(viewport.profile);
|
||||||
|
expect(frame.bounds.left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(frame.bounds.right).toBeLessThanOrEqual(viewport.width - 8);
|
||||||
|
expect(frame.bounds.top).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(frame.bounds.bottom).toBeLessThanOrEqual(viewport.height - 8);
|
||||||
|
expect(frame.bounds.width).toBeGreaterThanOrEqual(viewport.minimumFaceWidth);
|
||||||
|
expect(rectanglesOverlap(frame.bounds, geometry.panel, 16)).toBe(false);
|
||||||
|
for (const adjacentPanel of geometry.adjacentPanels) {
|
||||||
|
expect(rectanglesOverlap(frame.bounds, adjacentPanel, 16)).toBe(false);
|
||||||
|
}
|
||||||
|
for (const fixedZone of geometry.fixedZones) {
|
||||||
|
expect(rectanglesOverlap(frame.bounds, fixedZone, 8)).toBe(false);
|
||||||
|
}
|
||||||
|
expect(frame.texture.fontReadyWhenDrawn).toBe(true);
|
||||||
|
expect(frame.texture.width / (frame.bounds.width * frame.rendererPixelRatio))
|
||||||
|
.toBeGreaterThanOrEqual(4);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function colorEnergy(color: string): number {
|
function colorEnergy(color: string): number {
|
||||||
const value = Number.parseInt(color.slice(1), 16);
|
const value = Number.parseInt(color.slice(1), 16);
|
||||||
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
|
return ((value >> 16) & 0xff) + ((value >> 8) & 0xff) + (value & 0xff);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rectanglesOverlap(
|
||||||
|
first: { left: number; top: number; right: number; bottom: number },
|
||||||
|
second: { left: number; top: number; right: number; bottom: number },
|
||||||
|
gap: number,
|
||||||
|
): boolean {
|
||||||
|
return !(
|
||||||
|
first.right <= second.left - gap
|
||||||
|
|| first.left >= second.right + gap
|
||||||
|
|| first.bottom <= second.top - gap
|
||||||
|
|| first.top >= second.bottom + gap
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,23 @@ export interface SceneInstrumentationSnapshot {
|
|||||||
routeLabelsDrawn: string[];
|
routeLabelsDrawn: string[];
|
||||||
clearColors: string[];
|
clearColors: string[];
|
||||||
unhandledRejections: string[];
|
unhandledRejections: string[];
|
||||||
|
signFrames: SignFrameInstrumentation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SignFrameInstrumentation {
|
||||||
|
now: number;
|
||||||
|
visible: boolean;
|
||||||
|
targetId: string;
|
||||||
|
phase: string;
|
||||||
|
progress: number;
|
||||||
|
opacity: number;
|
||||||
|
activeSectionId: string;
|
||||||
|
direction: number;
|
||||||
|
corridor: string;
|
||||||
|
viewportProfile: string;
|
||||||
|
bounds: { left: number; top: number; right: number; bottom: number; width: number; height: number };
|
||||||
|
texture: { width: number; height: number; anisotropy: number; fontReadyWhenDrawn: boolean };
|
||||||
|
rendererPixelRatio: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type InstrumentedWindow = Window & {
|
type InstrumentedWindow = Window & {
|
||||||
@@ -34,6 +51,7 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
|
|||||||
routeLabelsDrawn: [],
|
routeLabelsDrawn: [],
|
||||||
clearColors: [],
|
clearColors: [],
|
||||||
unhandledRejections: [],
|
unhandledRejections: [],
|
||||||
|
signFrames: [],
|
||||||
animationFrameIds: new Set<number>(),
|
animationFrameIds: new Set<number>(),
|
||||||
audioContexts: [],
|
audioContexts: [],
|
||||||
};
|
};
|
||||||
@@ -135,6 +153,11 @@ export async function installSceneInstrumentation(page: Page): Promise<void> {
|
|||||||
const reason = event.reason;
|
const reason = event.reason;
|
||||||
state.unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
|
state.unhandledRejections.push(reason instanceof Error ? reason.message : String(reason));
|
||||||
});
|
});
|
||||||
|
window.addEventListener('portfolio-sign-frame', (event) => {
|
||||||
|
const detail = (event as CustomEvent<SignFrameInstrumentation>).detail;
|
||||||
|
if (!detail || state.signFrames.length >= 600) return;
|
||||||
|
state.signFrames.push(structuredClone(detail));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,6 +173,7 @@ export async function readSceneInstrumentation(page: Page): Promise<SceneInstrum
|
|||||||
routeLabelsDrawn: [...state.routeLabelsDrawn],
|
routeLabelsDrawn: [...state.routeLabelsDrawn],
|
||||||
clearColors: [...state.clearColors],
|
clearColors: [...state.clearColors],
|
||||||
unhandledRejections: [...state.unhandledRejections],
|
unhandledRejections: [...state.unhandledRejections],
|
||||||
|
signFrames: state.signFrames.map((frame) => structuredClone(frame)),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,7 +216,13 @@ describe('scene controller lifecycle', () => {
|
|||||||
await controller.start();
|
await controller.start();
|
||||||
const input = journey({
|
const input = journey({
|
||||||
routeProgress: 0.25,
|
routeProgress: 0.25,
|
||||||
sign: { targetId: 'selected-builds', phase: 'approach', progress: 0.2, opacity: 0.5 },
|
sign: {
|
||||||
|
targetId: 'selected-builds',
|
||||||
|
phase: 'approach',
|
||||||
|
progress: 0.2,
|
||||||
|
opacity: 0.5,
|
||||||
|
viewportProfile: 'wide',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
controller.update(input);
|
controller.update(input);
|
||||||
@@ -225,7 +231,10 @@ describe('scene controller lifecycle', () => {
|
|||||||
harness.scheduler.flushFrame(16);
|
harness.scheduler.flushFrame(16);
|
||||||
|
|
||||||
expect(harness.generations[0]?.render).toHaveBeenCalledWith(
|
expect(harness.generations[0]?.render).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ routeProgress: 0.25, sign: expect.objectContaining({ progress: 0.2 }) }),
|
expect.objectContaining({
|
||||||
|
routeProgress: 0.25,
|
||||||
|
sign: expect.objectContaining({ progress: 0.2, viewportProfile: 'wide' }),
|
||||||
|
}),
|
||||||
16,
|
16,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { createSignController } from '@/journey/sign-controller';
|
import { createSignController } from '@/journey/sign-controller';
|
||||||
|
import { SIGN_ROUTE_ORDER, signRouteFor } from '@/journey/sign-routes';
|
||||||
import type { SignControllerSample } from '@/journey/types';
|
import type { SignControllerSample } from '@/journey/types';
|
||||||
|
|
||||||
const sample = (
|
const sample = (
|
||||||
@@ -10,16 +11,30 @@ const sample = (
|
|||||||
direction: 1,
|
direction: 1,
|
||||||
acceleration: 0,
|
acceleration: 0,
|
||||||
deltaY: 16,
|
deltaY: 16,
|
||||||
|
viewportWidth: 1_440,
|
||||||
viewportHeight: 800,
|
viewportHeight: 800,
|
||||||
crossedBoundaries: 0,
|
crossedBoundaries: 0,
|
||||||
candidateId: 'profile',
|
candidateId: 'profile',
|
||||||
candidateDistanceVh: 1,
|
candidateDistanceVh: 0.5,
|
||||||
activeSectionId: 'start',
|
activeSectionId: 'start',
|
||||||
motionAllowed: true,
|
motionAllowed: true,
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('clock-driven sign lifecycle', () => {
|
describe('clock-driven sign lifecycle', () => {
|
||||||
|
it('defines one explicit route marker and safe corridor for every eligible destination', () => {
|
||||||
|
expect(SIGN_ROUTE_ORDER.map((id) => signRouteFor(id))).toEqual([
|
||||||
|
{ id: 'start', label: 'Start', marker: '01', eligible: false, corridor: null, triggerMaximumVh: 0 },
|
||||||
|
{ id: 'profile', label: 'Profile', marker: '02', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||||
|
{ id: 'experience', label: 'Experience', marker: '03', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||||
|
{ id: 'infrastructure', label: 'Infrastructure', marker: '04', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||||
|
{ id: 'selected-builds', label: 'Selected builds', marker: '05', eligible: true, corridor: 'outer-right', triggerMaximumVh: 0.55 },
|
||||||
|
{ id: 'additional-work', label: 'Additional work', marker: '06', eligible: true, corridor: 'left', triggerMaximumVh: 0.55 },
|
||||||
|
{ id: 'toolkit', label: 'Toolkit', marker: '07', eligible: true, corridor: 'outer-left', triggerMaximumVh: 0.55 },
|
||||||
|
{ id: 'contact', label: 'Contact', marker: '08', eligible: true, corridor: 'right', triggerMaximumVh: 0.55 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
|
it('advances through exact anticipate, approach, dwell, depart, and cooldown timing', () => {
|
||||||
const controller = createSignController();
|
const controller = createSignController();
|
||||||
|
|
||||||
@@ -28,6 +43,7 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
phase: 'anticipate',
|
phase: 'anticipate',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
|
viewportProfile: 'wide',
|
||||||
});
|
});
|
||||||
expect(controller.needsFrame()).toBe(true);
|
expect(controller.needsFrame()).toBe(true);
|
||||||
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
|
expect(controller.sample(sample({ now: 299, direction: 0 }))).toMatchObject({
|
||||||
@@ -87,21 +103,34 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('holds a completed minimum dwell without scheduling idle frames until the target activates', () => {
|
it('keeps the latched viewport profile immutable across a responsive resize', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
expect(controller.sample(sample())).toMatchObject({ viewportProfile: 'wide' });
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 500,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
viewportWidth: 844,
|
||||||
|
viewportHeight: 390,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', viewportProfile: 'wide' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('departs after the minimum dwell even when scrolling stops before the target activates', () => {
|
||||||
const controller = createSignController();
|
const controller = createSignController();
|
||||||
controller.sample(sample());
|
controller.sample(sample());
|
||||||
controller.sample(sample({ now: 1_200, direction: 0 }));
|
controller.sample(sample({ now: 1_200, direction: 0 }));
|
||||||
|
|
||||||
expect(controller.sample(sample({ now: 2_600, direction: 0 }))).toMatchObject({ phase: 'dwell' });
|
expect(controller.sample(sample({ now: 2_599, direction: 0, deltaY: 0 }))).toMatchObject({ phase: 'dwell' });
|
||||||
expect(controller.needsFrame()).toBe(false);
|
expect(controller.needsFrame()).toBe(true);
|
||||||
expect(controller.sample(sample({ now: 4_000, activeSectionId: 'profile' }))).toMatchObject({
|
expect(controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }))).toMatchObject({
|
||||||
phase: 'depart',
|
phase: 'depart',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
});
|
});
|
||||||
expect(controller.needsFrame()).toBe(true);
|
expect(controller.needsFrame()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels a concealed sign on reverse and departs a visible approaching sign cleanly', () => {
|
it('cancels a concealed sign on reverse but preserves the full dwell for a visible approach', () => {
|
||||||
const early = createSignController();
|
const early = createSignController();
|
||||||
early.sample(sample());
|
early.sample(sample());
|
||||||
expect(early.sample(sample({ now: 310, direction: -1, deltaY: -20 }))).toBeNull();
|
expect(early.sample(sample({ now: 310, direction: -1, deltaY: -20 }))).toBeNull();
|
||||||
@@ -109,10 +138,14 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
|
|
||||||
const readable = createSignController();
|
const readable = createSignController();
|
||||||
readable.sample(sample());
|
readable.sample(sample());
|
||||||
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 }))).toMatchObject({
|
expect(readable.sample(sample({ now: 700, direction: -1, deltaY: -20 })))
|
||||||
phase: 'depart',
|
.toMatchObject({ phase: 'approach' });
|
||||||
progress: 0,
|
expect(readable.sample(sample({ now: 1_200, direction: 0, deltaY: 0 })))
|
||||||
});
|
.toMatchObject({ phase: 'dwell' });
|
||||||
|
expect(readable.sample(sample({ now: 2_599, direction: 0, deltaY: 0 })))
|
||||||
|
.toMatchObject({ phase: 'dwell' });
|
||||||
|
expect(readable.sample(sample({ now: 2_600, direction: 0, deltaY: 0 })))
|
||||||
|
.toMatchObject({ phase: 'depart', progress: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves the minimum readable dwell when reverse input requests departure', () => {
|
it('preserves the minimum readable dwell when reverse input requests departure', () => {
|
||||||
@@ -166,7 +199,7 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('departs when the authoritative section has already passed the latched target', () => {
|
it('preserves approach and minimum dwell when the authoritative section passes the target', () => {
|
||||||
const controller = createSignController();
|
const controller = createSignController();
|
||||||
controller.sample(sample());
|
controller.sample(sample());
|
||||||
|
|
||||||
@@ -176,9 +209,29 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
candidateId: 'infrastructure',
|
candidateId: 'infrastructure',
|
||||||
}))).toMatchObject({
|
}))).toMatchObject({
|
||||||
targetId: 'profile',
|
targetId: 'profile',
|
||||||
phase: 'depart',
|
phase: 'approach',
|
||||||
progress: 0,
|
|
||||||
});
|
});
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_200,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'experience',
|
||||||
|
candidateId: 'infrastructure',
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 2_599,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'experience',
|
||||||
|
candidateId: 'infrastructure',
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 2_600,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'experience',
|
||||||
|
candidateId: 'infrastructure',
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not truncate dwell when the authoritative section passes the target', () => {
|
it('does not truncate dwell when the authoritative section passes the target', () => {
|
||||||
@@ -236,7 +289,7 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
['reverse input', { direction: -1 as const, deltaY: -1 }],
|
['reverse input', { direction: -1 as const, deltaY: -1 }],
|
||||||
['stationary input', { direction: 0 as const, deltaY: 0 }],
|
['stationary input', { direction: 0 as const, deltaY: 0 }],
|
||||||
['candidate before the approach window', { candidateDistanceVh: 1.200_001 }],
|
['candidate before the approach window', { candidateDistanceVh: 0.550_001 }],
|
||||||
['candidate already passed', { candidateDistanceVh: 0 }],
|
['candidate already passed', { candidateDistanceVh: 0 }],
|
||||||
['candidate is already active', { activeSectionId: 'profile' as const }],
|
['candidate is already active', { activeSectionId: 'profile' as const }],
|
||||||
['missing candidate', { candidateId: null }],
|
['missing candidate', { candidateId: null }],
|
||||||
@@ -265,4 +318,144 @@ describe('clock-driven sign lifecycle', () => {
|
|||||||
controller.reset();
|
controller.reset();
|
||||||
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
|
expect(controller.sample(sample({ now: 100 }))).toMatchObject({ phase: 'anticipate' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves a rapid multi-section jump to the latest active destination', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 20,
|
||||||
|
deltaY: 2_400,
|
||||||
|
crossedBoundaries: 4,
|
||||||
|
activeSectionId: 'selected-builds',
|
||||||
|
candidateId: 'additional-work',
|
||||||
|
candidateDistanceVh: 0.4,
|
||||||
|
acceleration: 1,
|
||||||
|
}))).toMatchObject({
|
||||||
|
targetId: 'selected-builds',
|
||||||
|
phase: 'anticipate',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coalesces changing jump targets and reveals only the latest after a readable stale sign departs', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_300,
|
||||||
|
deltaY: 2_400,
|
||||||
|
crossedBoundaries: 4,
|
||||||
|
activeSectionId: 'selected-builds',
|
||||||
|
candidateId: 'additional-work',
|
||||||
|
candidateDistanceVh: 0.4,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 1_500,
|
||||||
|
deltaY: 1_200,
|
||||||
|
crossedBoundaries: 2,
|
||||||
|
activeSectionId: 'toolkit',
|
||||||
|
candidateId: 'contact',
|
||||||
|
candidateDistanceVh: 0.35,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 2_600,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
crossedBoundaries: 0,
|
||||||
|
activeSectionId: 'toolkit',
|
||||||
|
candidateId: 'contact',
|
||||||
|
candidateDistanceVh: 0.35,
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_300,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
crossedBoundaries: 0,
|
||||||
|
activeSectionId: 'toolkit',
|
||||||
|
candidateId: 'contact',
|
||||||
|
candidateDistanceVh: 0.35,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
crossedBoundaries: 0,
|
||||||
|
activeSectionId: 'toolkit',
|
||||||
|
candidateId: 'contact',
|
||||||
|
candidateDistanceVh: 0.35,
|
||||||
|
}))).toMatchObject({ targetId: 'toolkit', phase: 'anticipate' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains the latest jump target first observed on the departure-completion frame', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_300,
|
||||||
|
deltaY: 2_400,
|
||||||
|
crossedBoundaries: 4,
|
||||||
|
activeSectionId: 'selected-builds',
|
||||||
|
candidateId: 'additional-work',
|
||||||
|
candidateDistanceVh: 0.4,
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_650,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
crossedBoundaries: 0,
|
||||||
|
activeSectionId: 'selected-builds',
|
||||||
|
candidateId: 'additional-work',
|
||||||
|
candidateDistanceVh: 0.4,
|
||||||
|
}))).toMatchObject({ targetId: 'selected-builds', phase: 'anticipate' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts the readable dwell clock on the first rendered dwell frame after a delayed gap', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 5_000,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 6_399,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'dwell' });
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 6_400,
|
||||||
|
direction: 0,
|
||||||
|
deltaY: 0,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
}))).toMatchObject({ targetId: 'profile', phase: 'depart', progress: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retrigger a completed route sign during small boundary oscillations', () => {
|
||||||
|
const controller = createSignController();
|
||||||
|
controller.sample(sample());
|
||||||
|
controller.sample(sample({ now: 1_200, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 2_600, direction: 0, deltaY: 0 }));
|
||||||
|
controller.sample(sample({ now: 3_300, direction: 0, deltaY: 0 }));
|
||||||
|
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_700,
|
||||||
|
direction: -1,
|
||||||
|
deltaY: -16,
|
||||||
|
crossedBoundaries: 1,
|
||||||
|
activeSectionId: 'start',
|
||||||
|
}))).toBeNull();
|
||||||
|
expect(controller.sample(sample({
|
||||||
|
now: 3_800,
|
||||||
|
direction: 1,
|
||||||
|
deltaY: 16,
|
||||||
|
crossedBoundaries: 1,
|
||||||
|
activeSectionId: 'profile',
|
||||||
|
candidateId: 'experience',
|
||||||
|
}))).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -576,8 +576,15 @@ describe("procedural world models", () => {
|
|||||||
}
|
}
|
||||||
const face = sign.group.getObjectByName("sign-face") as {
|
const face = sign.group.getObjectByName("sign-face") as {
|
||||||
userData: { textureUpdateCount: number };
|
userData: { textureUpdateCount: number };
|
||||||
material: { opacity: number };
|
material: {
|
||||||
geometry: { getAttribute(name: string): { array: ArrayLike<number> } };
|
opacity: number;
|
||||||
|
map: { anisotropy: number; image: { width: number; height: number } };
|
||||||
|
};
|
||||||
|
geometry: {
|
||||||
|
boundingBox: { min: { x: number; y: number }; max: { x: number; y: number } } | null;
|
||||||
|
computeBoundingBox(): void;
|
||||||
|
getAttribute(name: string): { array: ArrayLike<number> };
|
||||||
|
};
|
||||||
position: { z: number };
|
position: { z: number };
|
||||||
};
|
};
|
||||||
const backplate = sign.group.getObjectByName("sign-backplate") as {
|
const backplate = sign.group.getObjectByName("sign-backplate") as {
|
||||||
@@ -593,6 +600,12 @@ describe("procedural world models", () => {
|
|||||||
const faceUvs = Array.from(face.geometry.getAttribute("uv").array);
|
const faceUvs = Array.from(face.geometry.getAttribute("uv").array);
|
||||||
expect(Math.min(...faceUvs)).toBeGreaterThanOrEqual(0);
|
expect(Math.min(...faceUvs)).toBeGreaterThanOrEqual(0);
|
||||||
expect(Math.max(...faceUvs)).toBeLessThanOrEqual(1);
|
expect(Math.max(...faceUvs)).toBeLessThanOrEqual(1);
|
||||||
|
face.geometry.computeBoundingBox();
|
||||||
|
const faceWidth = (face.geometry.boundingBox?.max.x ?? 0) - (face.geometry.boundingBox?.min.x ?? 0);
|
||||||
|
const faceHeight = (face.geometry.boundingBox?.max.y ?? 0) - (face.geometry.boundingBox?.min.y ?? 0);
|
||||||
|
expect(faceWidth / faceHeight).toBeCloseTo(4, 6);
|
||||||
|
expect(face.material.map.image).toMatchObject({ width: 2_048, height: 512 });
|
||||||
|
expect(face.material.map.anisotropy).toBeGreaterThanOrEqual(8);
|
||||||
const allocation = resources.snapshot();
|
const allocation = resources.snapshot();
|
||||||
|
|
||||||
sign.update(null);
|
sign.update(null);
|
||||||
@@ -667,6 +680,94 @@ describe("procedural world models", () => {
|
|||||||
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
|
expect(sign.group.scale.x).toBeCloseTo(approachScale, 6);
|
||||||
resources.dispose();
|
resources.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("defers responsive pose changes for the complete lifecycle, including concealed anticipation", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
const camera = createChaseCamera(16 / 9);
|
||||||
|
sign.setViewportSize(1_440, 900);
|
||||||
|
sign.update({ targetId: "profile", phase: "anticipate", progress: 0, opacity: 0 });
|
||||||
|
|
||||||
|
sign.setViewportSize(844, 390);
|
||||||
|
const concealed = sign.inspect(camera, 844, 390);
|
||||||
|
expect(concealed?.viewportProfile).toBe("wide");
|
||||||
|
sign.update({ targetId: "profile", phase: "approach", progress: 0.5, opacity: 0.7 });
|
||||||
|
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
||||||
|
|
||||||
|
sign.update(null);
|
||||||
|
sign.update({ targetId: "experience", phase: "anticipate", progress: 0, opacity: 0 });
|
||||||
|
expect(sign.inspect(camera, 844, 390)?.viewportProfile).toBe("short");
|
||||||
|
resources.dispose();
|
||||||
|
|
||||||
|
const rebuiltResources = new SceneResourceBag();
|
||||||
|
const rebuilt = createSign(rebuiltResources);
|
||||||
|
rebuilt.setViewportSize(844, 390);
|
||||||
|
rebuilt.update({
|
||||||
|
targetId: "profile",
|
||||||
|
phase: "dwell",
|
||||||
|
progress: 1,
|
||||||
|
opacity: 1,
|
||||||
|
viewportProfile: "wide",
|
||||||
|
});
|
||||||
|
expect(rebuilt.inspect(camera, 844, 390)?.viewportProfile).toBe("wide");
|
||||||
|
rebuiltResources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("departs laterally without crossing the chase camera plane or growing toward the car", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
sign.setViewportSize(1_440, 900);
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
const dwellX = sign.group.position.x;
|
||||||
|
const dwellScale = sign.group.scale.x;
|
||||||
|
|
||||||
|
sign.update({ targetId: "profile", phase: "depart", progress: 0.9, opacity: 0.1 });
|
||||||
|
|
||||||
|
expect(sign.group.position.x).toBeLessThan(dwellX);
|
||||||
|
expect(sign.group.position.z).toBeLessThanOrEqual(0);
|
||||||
|
expect(sign.group.scale.x).toBeLessThan(dwellScale);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[390, 844],
|
||||||
|
[844, 390],
|
||||||
|
[1_440, 900],
|
||||||
|
])("keeps the responsive sign footing planted at %ix%i", (width, height) => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
sign.setViewportSize(width, height);
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
const footing = sign.group.getObjectByName("sign-footing") as { position: { y: number } };
|
||||||
|
const footingWorldY = sign.group.position.y + footing.position.y * sign.group.scale.x;
|
||||||
|
|
||||||
|
expect(footingWorldY).toBeGreaterThanOrEqual(-0.5);
|
||||||
|
expect(footingWorldY).toBeLessThanOrEqual(0.5);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("places route signs in the corridor opposite their destination content", () => {
|
||||||
|
const resources = new SceneResourceBag();
|
||||||
|
const sign = createSign(resources);
|
||||||
|
sign.setViewportAspect(16 / 9);
|
||||||
|
|
||||||
|
sign.update({ targetId: "profile", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
expect(sign.group.position.x).toBeLessThan(0);
|
||||||
|
const profileFace = sign.group.getObjectByName("sign-face");
|
||||||
|
const camera = createChaseCamera(16 / 9);
|
||||||
|
camera.position.set(-1.35, 8.6, 22);
|
||||||
|
camera.lookAt(1.6, 4.8, -128);
|
||||||
|
sign.group.updateMatrixWorld(true);
|
||||||
|
const profileBounds = projectedBounds(profileFace, camera);
|
||||||
|
expect(profileBounds.maxX).toBeLessThan(0.08);
|
||||||
|
|
||||||
|
sign.update(null);
|
||||||
|
sign.update({ targetId: "experience", phase: "dwell", progress: 1, opacity: 1 });
|
||||||
|
sign.group.updateMatrixWorld(true);
|
||||||
|
const experienceBounds = projectedBounds(profileFace, camera);
|
||||||
|
expect(experienceBounds.minX).toBeGreaterThan(0);
|
||||||
|
resources.dispose();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function paletteSnapshot(
|
function paletteSnapshot(
|
||||||
|
|||||||