diff --git a/assets/blender/expressway/README.md b/assets/blender/expressway/README.md
new file mode 100644
index 0000000..386093c
--- /dev/null
+++ b/assets/blender/expressway/README.md
@@ -0,0 +1,12 @@
+# Expressway grand tourer
+
+Original portfolio coachwork developed from the approved `../evolution/nightdrive-evolution.blend` at `c9ac36e`. No third-party model, texture, or generated project evidence is used.
+
+- `nightdrive-expressway.blend`: isolated editable source. Open the **Midnight Expressway** scene. Body components, optical cells, intake vanes, wheel machining, panel seams, wing details, and look-development setup remain editable.
+- `author-expressway.py`: appends the approved source into a separate scene and authors this edition. Run once in a fresh Blender session with `__file__` set to the script path. It saves only the isolated expressway library; it does not overwrite the approved evolution source.
+- `export-expressway.py`: run with the expressway source open. It evaluates modifiers into a separate export scene, batches by material and wheel, exports the runtime GLB, and writes its provenance JSON. Four wheel parents remain independently rotatable. Cameras and look-development lights are excluded.
+- `nightdrive-expressway.asset.json`: export path, SHA-256, triangle count, and source lineage.
+
+Runtime: 31,288 triangles, 19 material batches, four independently rotating wheel groups. Geometry follows the existing Blender-to-glTF coordinate convention and runtime scale. Body suspension is applied separately from grounded wheels.
+
+The runtime asset is `src/assets/nightdrive-grand-tourer-expressway.glb`. The review contains a Blender look-development render and actual in-browser views. The Static-mode JPEGs are captures of this portfolio's live Three.js world; they contain no fabricated project media.
diff --git a/assets/blender/expressway/author-expressway.py b/assets/blender/expressway/author-expressway.py
new file mode 100644
index 0000000..14c99eb
--- /dev/null
+++ b/assets/blender/expressway/author-expressway.py
@@ -0,0 +1,124 @@
+"""Append the approved coachwork into its own scene; author only the new edition.
+Run inside Blender with __file__ pointing here. No open project is overwritten.
+"""
+import bpy, math
+from pathlib import Path
+from mathutils import Vector
+root_path = Path(__file__).resolve().parents[3]
+source = root_path / 'assets/blender/evolution/nightdrive-evolution.blend'
+with bpy.data.libraries.load(str(source), link=False) as (available, loaded):
+ loaded.scenes = ['Portfolio Evolution']
+scene = loaded.scenes[0]
+scene.name = 'Midnight Expressway'
+bpy.context.window.scene = scene
+car = next(o for o in scene.objects if o.type=='EMPTY' and o.name.startswith('chase-car'))
+materials = {m.name.split('.')[0]: m for o in scene.objects if o.type=='MESH' for m in o.data.materials}
+
+def finish(obj, name, material, bevel=0.007):
+ obj.name = 'expressway-' + name
+ obj.parent = car
+ obj.data.materials.append(materials[material])
+ if bevel:
+ mod = obj.modifiers.new('Machined radii', 'BEVEL'); mod.width=bevel; mod.segments=2
+ obj.modifiers.new('Surface normals', 'WEIGHTED_NORMAL')
+ return obj
+
+def box(name, loc, dims, mat, bevel=0.007):
+ bpy.ops.mesh.primitive_cube_add(size=1, location=loc)
+ o=bpy.context.object; o.dimensions=dims
+ bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
+ return finish(o,name,mat,bevel)
+
+def shell(name, vertices, faces, mat, bevel=0.005):
+ mesh=bpy.data.meshes.new(name); mesh.from_pydata(vertices,[],faces); mesh.update()
+ o=bpy.data.objects.new(name,mesh); scene.collection.objects.link(o)
+ return finish(o,name,mat,bevel)
+
+def line(name, coords, mat, radius=0.004):
+ curve=bpy.data.curves.new(name,'CURVE'); curve.dimensions='3D'
+ curve.resolution_u=1; curve.bevel_depth=radius; curve.bevel_resolution=2
+ spline=curve.splines.new('POLY'); spline.points.add(len(coords)-1)
+ for p,co in zip(spline.points,coords): p.co=(*co,1)
+ obj=bpy.data.objects.new(name,curve); scene.collection.objects.link(obj)
+ obj.parent=car; obj.data.materials.append(materials[mat]); obj.name='expressway-'+name
+ return obj
+
+# Material story: cold painted metal, brushed alloy, smoke and deep graphite.
+for name,color,metal,rough in [
+ ('M_Body_Gunmetal',(0.18,0.28,0.36,1),0.72,0.27),
+ ('M_Body_Haunch',(0.30,0.41,0.48,1),0.78,0.22),
+ ('M_Lower_Graphite',(0.018,0.028,0.039,1),0.6,0.38),
+ ('M_Wheel',(0.45,0.53,0.57,1),0.92,0.21),
+ ('M_Tire',(0.009,0.012,0.016,1),0.0,0.9),
+ ('M_Glass_Smoke',(0.014,0.045,0.065,1),0.36,0.14)]:
+ bsdf=materials[name].node_tree.nodes.get('Principled BSDF')
+ bsdf.inputs['Base Color'].default_value=color
+ bsdf.inputs['Metallic'].default_value=metal; bsdf.inputs['Roughness'].default_value=rough
+ if 'Body' in name:
+ bsdf.inputs['Coat Weight'].default_value=0.42; bsdf.inputs['Coat Roughness'].default_value=0.19
+
+# Replace the plain lamp with fourteen paired optical cells and lit outer returns.
+for obj in list(scene.objects):
+ if obj.name.startswith(('rear-light-bar','evolution-lamp-divider','signature-turbine-vane')):
+ bpy.data.objects.remove(obj,do_unlink=True)
+box('rear-optical-cassette',(0,2.684,0.700),(1.79,0.06,0.21),'M_Lower_Graphite',0.025)
+for side in [-1,1]:
+ for i in range(7):
+ x=side*(0.11+i*0.109)
+ for z in [0.664,0.746]:
+ box('rear-optic-cell',(x,2.720,z),(0.091,0.022,0.034),'M_Rear_Light',0.006)
+ box('lamp-return',(side*0.852,2.704,0.705),(0.025,0.04,0.139),'M_Rear_Light',0.009)
+ # Side intake recess, bevelled upper lip and three angled internal vanes.
+ x=side*1.073
+ box('side-intake',(x,0.16,0.53),(0.027,1.03,0.255),'M_Lower_Graphite',0.033)
+ box('intake-upper-lip',(side*1.093,0.13,0.670),(0.035,1.09,0.025),'M_Body_Haunch')
+ for i in range(4):
+ o=box('intake-vane',(side*1.095,-0.18+i*0.22,0.54),(0.025,0.022,0.21),'M_Wheel',0.003)
+ o.rotation_euler.x=-0.32
+ # Deep lower blade has a distinct kicked-up rear end.
+ poly=[(side*1.05,-1.03,0.24),(side*1.13,-1.03,0.20),(side*1.13,1.1,0.20),(side*1.11,1.30,0.36),(side*1.05,1.30,0.36)]
+ shell('sculpted-skirt',poly,[tuple(range(5))],'M_Body_Haunch')
+ line('skirt-channel',[(side*1.137,-0.8,0.224),(side*1.137,0.92,0.224),(side*1.117,1.17,0.31)],'M_Cyan_Accent',0.006)
+ # Rear deck channel and restrained panel separation follow the wedge surfaces.
+ line('rear-deck-seam',[(side*.72,1.12,.928),(side*.76,1.78,.876),(side*.85,2.25,.825)],'M_Lower_Graphite',0.006)
+ line('haunch-pinstripe',[(side*.91,1.2,.905),(side*1.005,1.67,.857),(side*.997,2.20,.759)],'M_Wheel',0.0035)
+ for i in range(4):
+ box('deck-extraction-slot',(side*.49,1.36+i*.116,.906-i*.012),(.31,.039,.018),'M_Lower_Graphite',0.009)
+ # Larger aerofoil endplates, kinked with a recessed coral slit.
+ box('wing-endplate-shell',(side*1.195,2.35,1.268),(.032,.43,.24),'M_Body_Haunch',.016)
+ box('wing-endplate-inlay',(side*1.213,2.39,1.28),(.009,.22,.025),'M_Rear_Light',.004)
+ # Exhausts inside the graphite recess, with dark cores instead of solid cylinders.
+ for x0 in [0.29,0.43]:
+ x=side*x0
+ box('rear-exhaust-surround',(x,2.7,.37),(.105,.115,.085),'M_Wheel',.018)
+ box('rear-exhaust-core',(x,2.762,.37),(.079,.01,.059),'M_Lower_Graphite',.014)
+ for i in range(5):
+ box('rear-cooling-slot',(side*(.53+i*.06),2.704,.497),(.028,.018,.064),'M_Lower_Graphite',.005)
+
+# Diffuser undertray: channels and an alloy trailing edge remain readable at speed.
+line('diffuser-frame',[(-.82,2.68,.29),(-.63,2.78,.24),(.63,2.78,.24),(.82,2.68,.29)],'M_Wheel',.009)
+for x in [-.66,-.33,0,.33,.66]:
+ shell('diffuser-strake',[(x,2.25,.22),(x,2.74,.26),(x,2.78,.105),(x,2.36,.105)],[(0,1,2,3)],'M_Lower_Graphite',0)
+
+# Turbine machining has a dished barrel, swept spokes and a shoulder ring.
+for side in [-1,1]:
+ for axle,y,z,radius in [('front',-1.62,.42,.42),('rear',1.62,.46,.46)]:
+ label=f'wheel-{axle}-{"left" if side<0 else "right"}'
+ for rad,xoff,thick,mat in [(radius-.016,1.153,.009,'M_Tire'),(.282 if axle=='rear' else .26,1.181,.008,'M_Wheel')]:
+ coords=[(side*xoff,y+math.sin(i*math.tau/64)*rad,z+math.cos(i*math.tau/64)*rad) for i in range(65)]
+ line(label+'-shoulder',coords,mat,thick)
+ for i in range(10):
+ angle=i*math.tau/10
+ verts=[]
+ for rad,a,xoff in [(.075,angle,1.17),(.255,angle+.30,1.193),(.263,angle+.48,1.193),(.088,angle+.35,1.17)]:
+ verts.append((side*xoff,y+math.sin(a)*rad,z+math.cos(a)*rad))
+ shell(label+'-turbine',verts,[(0,1,2,3)],'M_Wheel',.004)
+ bpy.ops.mesh.primitive_uv_sphere_add(segments=16,ring_count=8,radius=1,location=(side*1.20,y,z))
+ hub=bpy.context.object; hub.scale=(.025,.064,.064)
+ bpy.ops.object.transform_apply(location=False,rotation=False,scale=True)
+ finish(hub,label+'-hub','M_Lower_Graphite',0)
+
+# Write only this scene and its own dependencies, retaining individually editable objects.
+output=root_path/'assets/blender/expressway/nightdrive-expressway.blend'
+bpy.data.libraries.write(str(output),{scene},fake_user=True,compress=True)
+print('AUTHORED',scene.name,len(scene.objects),'objects',output)
diff --git a/assets/blender/expressway/export-expressway.py b/assets/blender/expressway/export-expressway.py
new file mode 100644
index 0000000..dac2de5
--- /dev/null
+++ b/assets/blender/expressway/export-expressway.py
@@ -0,0 +1,51 @@
+"""Export isolated authored source into material batches; leave lookdev editable."""
+import bpy, json, hashlib
+from mathutils import Matrix, Vector
+from pathlib import Path
+root_path=Path(__file__).resolve().parents[3]
+scene=bpy.data.scenes['Midnight Expressway']
+bpy.context.window.scene=scene
+export_scene=bpy.data.scenes.new('Expressway Export')
+root=bpy.data.objects.new('evolution-chase-car',None); export_scene.collection.objects.link(root)
+names={'M_Body_Gunmetal':'car-body','M_Body_Haunch':'body-haunches','M_Glass_Smoke':'rear-glass','M_Rear_Light':'rear-light-bar','M_Cyan_Accent':'car-underglow-source','M_Lower_Graphite':'body-trim','M_Wheel':'wheel-machining','M_Tire':'tires'}
+depsgraph=bpy.context.evaluated_depsgraph_get(); groups={}
+for obj in scene.objects:
+ if obj.type not in ['MESH','CURVE'] or obj.name.startswith('LOOKDEV'): continue
+ evaluated=obj.evaluated_get(depsgraph)
+ mesh=bpy.data.meshes.new_from_object(evaluated,depsgraph=depsgraph); mesh.transform(obj.matrix_world)
+ clone=bpy.data.objects.new('export-'+obj.name,mesh); export_scene.collection.objects.link(clone)
+ key=obj.data.materials[0].name.split('.')[0]
+ wheel=None
+ for axle in ['front','rear']:
+ for side in ['left','right']:
+ if any(token in obj.name for token in [f'{axle}-wheel-{side}', f'{axle}-rim-{side}', f'{axle}-rim-ring-{side}', f'wheel-{axle}-{side}']):
+ wheel=f'wheel-{axle}-{side}'
+ groups.setdefault((key,wheel),[]).append(clone)
+bpy.context.window.scene=export_scene
+wheel_roots={}
+for (key,wheel),objects in groups.items():
+ bpy.ops.object.select_all(action='DESELECT')
+ for obj in objects: obj.select_set(True)
+ bpy.context.view_layer.objects.active=objects[0]
+ if len(objects)>1: bpy.ops.object.join()
+ joined=bpy.context.object; joined.name='evolution-'+names[key]
+ if wheel:
+ if wheel not in wheel_roots:
+ axle='front' if 'front' in wheel else 'rear'; side=-1 if 'left' in wheel else 1
+ wheel_root=bpy.data.objects.new(wheel,None); export_scene.collection.objects.link(wheel_root)
+ wheel_root.parent=root; wheel_root.location=(side*1.01,-1.62 if axle=='front' else 1.62,.42 if axle=='front' else .46)
+ wheel_roots[wheel]=wheel_root
+ wheel_root=wheel_roots[wheel]
+ joined.data.transform(Matrix.Translation(-wheel_root.location))
+ joined.parent=wheel_root
+ else: joined.parent=root
+bpy.ops.object.select_all(action='SELECT')
+output=root_path/'src/assets/nightdrive-grand-tourer-expressway.glb'
+bpy.ops.export_scene.gltf(filepath=str(output),export_format='GLB',use_selection=True,use_active_scene=True,export_apply=True,export_animations=False,export_cameras=False,export_lights=False)
+triangles=0
+for obj in export_scene.objects:
+ if obj.type=='MESH': obj.data.calc_loop_triangles(); triangles+=len(obj.data.loop_triangles)
+provenance={'asset':'Nightdrive expressway grand tourer','source':'nightdrive-expressway.blend','base':'../evolution/nightdrive-evolution.blend at c9ac36e','authorship':'Original procedural modelling in Blender for this portfolio; no external assets or generated project evidence.','editable_scene':'Midnight Expressway','material_groups':len(groups),'independent_wheels':4,'triangles':triangles,'export':'../../../src/assets/nightdrive-grand-tourer-expressway.glb','sha256':hashlib.sha256(output.read_bytes()).hexdigest()}
+(root_path/'assets/blender/expressway/nightdrive-expressway.asset.json').write_text(json.dumps(provenance,indent=2)+'\n')
+bpy.context.window.scene=scene
+print(json.dumps(provenance))
diff --git a/assets/blender/expressway/nightdrive-expressway.asset.json b/assets/blender/expressway/nightdrive-expressway.asset.json
new file mode 100644
index 0000000..e74ca93
--- /dev/null
+++ b/assets/blender/expressway/nightdrive-expressway.asset.json
@@ -0,0 +1,12 @@
+{
+ "asset": "Nightdrive expressway grand tourer",
+ "source": "nightdrive-expressway.blend",
+ "base": "../evolution/nightdrive-evolution.blend at c9ac36e",
+ "authorship": "Original procedural modelling in Blender for this portfolio; no external assets or generated project evidence.",
+ "editable_scene": "Midnight Expressway",
+ "material_groups": 19,
+ "independent_wheels": 4,
+ "triangles": 31288,
+ "export": "../../../src/assets/nightdrive-grand-tourer-expressway.glb",
+ "sha256": "8418ff060b75c6fef200eaa526adae7fe51286b7fc30628671b9eb242641d1c3"
+}
diff --git a/assets/blender/expressway/nightdrive-expressway.blend b/assets/blender/expressway/nightdrive-expressway.blend
new file mode 100644
index 0000000..1799705
Binary files /dev/null and b/assets/blender/expressway/nightdrive-expressway.blend differ
diff --git a/docs/reviews/evidence/midnight-expressway/after-builds.png b/docs/reviews/evidence/midnight-expressway/after-builds.png
new file mode 100644
index 0000000..35dcd2e
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/after-builds.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/after-contact.png b/docs/reviews/evidence/midnight-expressway/after-contact.png
new file mode 100644
index 0000000..aad1331
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/after-contact.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/after-desktop.png b/docs/reviews/evidence/midnight-expressway/after-desktop.png
new file mode 100644
index 0000000..0b6e136
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/after-desktop.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/after-infrastructure.png b/docs/reviews/evidence/midnight-expressway/after-infrastructure.png
new file mode 100644
index 0000000..fd0961d
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/after-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/after-phone-sign.png b/docs/reviews/evidence/midnight-expressway/after-phone-sign.png
new file mode 100644
index 0000000..1e456ec
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/after-phone-sign.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/after-phone.png b/docs/reviews/evidence/midnight-expressway/after-phone.png
new file mode 100644
index 0000000..2db4e54
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/after-phone.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/baseline-checks.log b/docs/reviews/evidence/midnight-expressway/baseline-checks.log
new file mode 100644
index 0000000..c206701
--- /dev/null
+++ b/docs/reviews/evidence/midnight-expressway/baseline-checks.log
@@ -0,0 +1,150 @@
+
+> portfolio-phase-1@1.0.0 build:preview
+> npm run verify:approved-assets && npm run typecheck && npm run test:unit && npm run build:client && npm run build:ssr && npm run prerender -- --config config/site.preview.json && npm run verify:artifacts -- --config config/site.preview.json && npm run check:budgets:transfer
+
+
+> portfolio-phase-1@1.0.0 verify:approved-assets
+> node scripts/materialize-empty-asset-set.mjs
+
+
+> portfolio-phase-1@1.0.0 typecheck
+> vue-tsc --noEmit
+
+
+> portfolio-phase-1@1.0.0 test:unit
+> vitest run
+
+
+ RUN v4.1.11 /home/luca/Documents/Portfolio/.worktrees/midnight-expressway
+
+ ❯ tests/unit/world-models.test.ts (29 tests | 7 failed) 272ms
+ × disposes a composer and its passes exactly once 32ms
+ × keeps the grand tourer anatomy readable in desktop and portrait chase views 29ms
+ × reflows a visible portrait sign into its navigator-safe pose without a teleport 4ms
+ × keeps a short-landscape left sign inside the narrow desktop roadside gutter 3ms
+ × places a portrait navigator arrival below its full-width destination panel 3ms
+ × grows a medium portrait navigator sign without enlarging the phone pose 7ms
+ × places route signs in their authored transition-safe corridors 10ms
+ ❯ tests/unit/SceneCanvas.test.ts (16 tests | 2 failed) 134ms
+ × does not request or render the scene for reduced motion 36ms
+ × does not request or render the scene for saved Static mode 6ms
+
+⎯⎯⎯⎯⎯⎯⎯ Failed Tests 9 ⎯⎯⎯⎯⎯⎯⎯
+
+ FAIL tests/unit/SceneCanvas.test.ts > SceneCanvas > does not request or render the scene for reduced motion
+ FAIL tests/unit/SceneCanvas.test.ts > SceneCanvas > does not request or render the scene for saved Static mode
+AssertionError: expected [] to have a length of 7 but got +0
+
+- Expected
++ Received
+
+- 7
++ 0
+
+ ❯ tests/unit/SceneCanvas.test.ts:95:9
+ 93| expect(
+ 94| wrapper.findAll(".journey-poster-composition > span"),
+ 95| ).toHaveLength(7);
+ | ^
+ 96| expect(wrapper.find(".journey-poster__sun").exists()).toBe(true);
+ 97| expect(wrapper.find(".journey-poster__road").exists()).toBe(true…
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/9]⎯
+
+ FAIL tests/unit/world-models.test.ts > procedural world models > disposes a composer and its passes exactly once
+AssertionError: expected 0.39 to be greater than 0.54
+ ❯ tests/unit/world-models.test.ts:439:32
+ 437| expect(outputPass.dispose).toHaveBeenCalledOnce();
+ 438| expect(composer.dispose).toHaveBeenCalledOnce();
+ 439| expect(bloomPass.strength).toBeGreaterThan(0.54);
+ | ^
+ 440| });
+ 441|
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/9]⎯
+
+ FAIL tests/unit/world-models.test.ts > procedural world models > keeps the grand tourer anatomy readable in desktop and portrait chase views
+AssertionError: expected 56 to be 66 // Object.is equality
+
+- Expected
++ Received
+
+- 66
++ 56
+
+ ❯ tests/unit/world-models.test.ts:461:52
+ 459|
+ 460| const desktopCamera = createChaseCamera(16 / 9);
+ 461| expect((desktopCamera as { fov: number }).fov).toBe(66);
+ | ^
+ 462| const desktop = projectedBounds(car.group, desktopCamera);
+ 463| expect(desktop.maxX - desktop.minX).toBeGreaterThan(0.28);
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/9]⎯
+
+ FAIL tests/unit/world-models.test.ts > procedural world models > reflows a visible portrait sign into its navigator-safe pose without a teleport
+AssertionError: expected 595.8091348948393 to be greater than or equal to 626
+ ❯ tests/unit/world-models.test.ts:818:37
+ 816| if (presentation === null) throw new Error("Missing navigator-safe…
+ 817| expect(sign.group.position.distanceTo(midpoint)).toBeGreaterThan(0…
+ 818| expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
+ | ^
+ 819| expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
+ 820| resources.dispose();
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/9]⎯
+
+ FAIL tests/unit/world-models.test.ts > procedural world models > keeps a short-landscape left sign inside the narrow desktop roadside gutter
+AssertionError: expected -10.199278524338355 to be greater than or equal to 8
+ ❯ tests/unit/world-models.test.ts:884:38
+ 882| const presentation = sign.inspect(camera, 780, 493);
+ 883| if (presentation === null) throw new Error("Missing short-landscap…
+ 884| expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
+ | ^
+ 885| expect(presentation.bounds.right).toBeLessThanOrEqual(265);
+ 886| resources.dispose();
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[5/9]⎯
+
+ FAIL tests/unit/world-models.test.ts > procedural world models > places a portrait navigator arrival below its full-width destination panel
+AssertionError: expected 595.8091348948393 to be greater than or equal to 626
+ ❯ tests/unit/world-models.test.ts:921:37
+ 919| if (presentation === null) throw new Error("Missing portrait navig…
+ 920| expect(presentation.bounds.left).toBeGreaterThanOrEqual(8);
+ 921| expect(presentation.bounds.top).toBeGreaterThanOrEqual(626);
+ | ^
+ 922| expect(presentation.bounds.right).toBeLessThanOrEqual(382);
+ 923| expect(presentation.bounds.bottom).toBeLessThanOrEqual(690);
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[6/9]⎯
+
+ FAIL tests/unit/world-models.test.ts > procedural world models > grows a medium portrait navigator sign without enlarging the phone pose
+AssertionError: expected 122.59356940139988 to be less than or equal to 110
+ ❯ tests/unit/world-models.test.ts:958:32
+ 956|
+ 957| expect(phone.bounds.width).toBeGreaterThanOrEqual(85);
+ 958| expect(phone.bounds.width).toBeLessThanOrEqual(110);
+ | ^
+ 959| expect(medium.viewportProfile).toBe("tall");
+ 960| expect(medium.bounds.width).toBeGreaterThanOrEqual(145);
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[7/9]⎯
+
+ FAIL tests/unit/world-models.test.ts > procedural world models > places route signs in their authored transition-safe corridors
+AssertionError: expected 656.9555824147709 to be less than or equal to 630
+ ❯ tests/unit/world-models.test.ts:1013:51
+ 1011| expect(experiencePresentation?.corridor).toBe("right");
+ 1012| expect(experiencePresentation?.bounds.top).toBeGreaterThanOrEqual(…
+ 1013| expect(experiencePresentation?.bounds.bottom).toBeLessThanOrEqual(…
+ | ^
+ 1014|
+ 1015| sign.update(null);
+
+⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[8/9]⎯
+
+
+ Test Files 2 failed | 47 passed (49)
+ Tests 9 failed | 653 passed (662)
+ Start at 04:04:19
+ Duration 23.81s (transform 17.57s, setup 0ms, import 30.29s, tests 45.44s, environment 19.53s)
+
diff --git a/docs/reviews/evidence/midnight-expressway/before-desktop.png b/docs/reviews/evidence/midnight-expressway/before-desktop.png
new file mode 100644
index 0000000..0dfffca
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/before-desktop.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/before-infrastructure.png b/docs/reviews/evidence/midnight-expressway/before-infrastructure.png
new file mode 100644
index 0000000..92a7f17
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/before-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/before-phone.png b/docs/reviews/evidence/midnight-expressway/before-phone.png
new file mode 100644
index 0000000..eecca0d
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/before-phone.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/blender-coachwork.png b/docs/reviews/evidence/midnight-expressway/blender-coachwork.png
new file mode 100644
index 0000000..d1d62e6
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/blender-coachwork.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/chrome-hardware-performance.json b/docs/reviews/evidence/midnight-expressway/chrome-hardware-performance.json
new file mode 100644
index 0000000..85fe0ad
--- /dev/null
+++ b/docs/reviews/evidence/midnight-expressway/chrome-hardware-performance.json
@@ -0,0 +1,48 @@
+{
+ "browser": "152.0.7977.64",
+ "renderer": {
+ "renderer": "ANGLE (Intel, Mesa Intel(R) Graphics (ARL), OpenGL ES 3.2)",
+ "vendor": "Google Inc. (Intel)",
+ "canvas": {
+ "width": 1425,
+ "height": 900
+ },
+ "dpr": 1.0000000298023224
+ },
+ "samples": [
+ {
+ "label": "1440x900 opening high preference",
+ "frames": 379,
+ "seconds": 6.004099999999999,
+ "averageFps": 63.12353225296048,
+ "p50Ms": 16.600000000000364,
+ "p95Ms": 20.900000000001455
+ },
+ {
+ "label": "1440x900 infrastructure reading",
+ "frames": 377,
+ "seconds": 6.012599999999998,
+ "averageFps": 62.70165984765328,
+ "p50Ms": 16.600000000002183,
+ "p95Ms": 20.900000000001455
+ },
+ {
+ "label": "1440x900 native scrolling and coasting",
+ "frames": 393,
+ "seconds": 6.0125,
+ "averageFps": 65.36382536382537,
+ "p50Ms": 16.599999999998545,
+ "p95Ms": 20.899999999997817
+ },
+ {
+ "label": "390x844 opening high preference",
+ "frames": 1132,
+ "seconds": 6.000000000000001,
+ "averageFps": 188.66666666666663,
+ "p50Ms": 4.199999999999818,
+ "p95Ms": 8.400000000000546
+ }
+ ],
+ "errors": [],
+ "notes": "Headed host Google Chrome in an isolated temporary profile. Native devicePixelRatio 1. High maximum-quality preference; normal adaptive quality behavior preserved. requestAnimationFrame measurements; no profiler injection."
+}
diff --git a/docs/reviews/evidence/midnight-expressway/content-preservation.json b/docs/reviews/evidence/midnight-expressway/content-preservation.json
new file mode 100644
index 0000000..18fd1b6
--- /dev/null
+++ b/docs/reviews/evidence/midnight-expressway/content-preservation.json
@@ -0,0 +1,46 @@
+{
+ "baseline": {
+ "text": "8621cc0f9fab07e86edb0308cc7b7f9d7ce519b2a55b8a39bd1a01f6820b1520",
+ "rendered": "59a5678b6efe114292c5113ba7922c427d6911823a980ccbe43c284adabf80f5",
+ "head": "b5571c4ca03daff042ebc496a5c4e01ea70fee7ed2fab08d4b0b126440ab790d",
+ "links": "11e4e3627e0d5f1bf1140e7c73ab86a6d305ceaf8286c0649e4a4a3266e48977",
+ "sections": [
+ "start",
+ "profile",
+ "experience",
+ "infrastructure",
+ "selected-builds",
+ "additional-work",
+ "toolkit",
+ "contact"
+ ],
+ "scene": "static",
+ "canvas": 0
+ },
+ "implementation": {
+ "text": "8621cc0f9fab07e86edb0308cc7b7f9d7ce519b2a55b8a39bd1a01f6820b1520",
+ "rendered": "59a5678b6efe114292c5113ba7922c427d6911823a980ccbe43c284adabf80f5",
+ "head": "b5571c4ca03daff042ebc496a5c4e01ea70fee7ed2fab08d4b0b126440ab790d",
+ "links": "11e4e3627e0d5f1bf1140e7c73ab86a6d305ceaf8286c0649e4a4a3266e48977",
+ "sections": [
+ "start",
+ "profile",
+ "experience",
+ "infrastructure",
+ "selected-builds",
+ "additional-work",
+ "toolkit",
+ "contact"
+ ],
+ "scene": "static",
+ "canvas": 0
+ },
+ "sameText": true,
+ "sameRenderedText": true,
+ "sameMetadata": true,
+ "sameLinks": true,
+ "noJavaScript": {
+ "sections": 8,
+ "characters": 12333
+ }
+}
diff --git a/docs/reviews/evidence/midnight-expressway/desktop-additional-work.png b/docs/reviews/evidence/midnight-expressway/desktop-additional-work.png
new file mode 100644
index 0000000..7327b8d
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/desktop-additional-work.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/desktop-contact.png b/docs/reviews/evidence/midnight-expressway/desktop-contact.png
new file mode 100644
index 0000000..29e11bd
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/desktop-contact.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/desktop-experience.png b/docs/reviews/evidence/midnight-expressway/desktop-experience.png
new file mode 100644
index 0000000..4178d54
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/desktop-experience.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/desktop-infrastructure.png b/docs/reviews/evidence/midnight-expressway/desktop-infrastructure.png
new file mode 100644
index 0000000..1417f89
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/desktop-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/desktop-profile.png b/docs/reviews/evidence/midnight-expressway/desktop-profile.png
new file mode 100644
index 0000000..fd97a16
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/desktop-profile.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/desktop-selected-builds.png b/docs/reviews/evidence/midnight-expressway/desktop-selected-builds.png
new file mode 100644
index 0000000..7ff80e3
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/desktop-selected-builds.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/desktop-toolkit.png b/docs/reviews/evidence/midnight-expressway/desktop-toolkit.png
new file mode 100644
index 0000000..789e137
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/desktop-toolkit.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/hardware-desktop.png b/docs/reviews/evidence/midnight-expressway/hardware-desktop.png
new file mode 100644
index 0000000..0e04619
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/hardware-desktop.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/hardware-infrastructure.png b/docs/reviews/evidence/midnight-expressway/hardware-infrastructure.png
new file mode 100644
index 0000000..484f375
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/hardware-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/hardware-phone.png b/docs/reviews/evidence/midnight-expressway/hardware-phone.png
new file mode 100644
index 0000000..aec080c
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/hardware-phone.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/index.html b/docs/reviews/evidence/midnight-expressway/index.html
new file mode 100644
index 0000000..586efe8
--- /dev/null
+++ b/docs/reviews/evidence/midnight-expressway/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+Midnight expressway — visual review
+
+
+
+Portfolio / September 2026 / Local visual review
Midnight expressway. A more physical road, coachbuilt silver bodywork, illuminated route signs, and a layered coral horizon. The approved portfolio content and adaptive soundtrack remain intact.
+
+01 / The opening
The same identity, a deeper world. Drag the divider to compare the merged baseline with this implementation. These are actual browser captures; moving objects naturally occupy different positions.
BEFORE AFTER COMPARE
+02 / Motion study
Travel, settle, arrive. Actual hardware-backed Chrome capture: forward acceleration, coasting, reverse document scrolling, and navigation to Infrastructure, Selected builds, and Contact. Silent recording with the High maximum-quality preference and normal adaptive behavior.
+
+04 / Coachwork
A more memorable grand tourer. Editable Blender source retains the individual parts and look-development setup. The runtime export batches materials and preserves four independently rotating wheels.
+05 / Small screens
The night drive travels with you. Before. After.
+06 / Evidence and arrival
The work stays central. Unchanged project evidence, staged within the continuous world. Contact resolves into a warmer horizon and slower camera recovery.
+Verified, with clear limits. Approved wording, links, metadata, section order, and all 15 approved media/audio files match the baseline. Asset validation, typecheck, client/SSR build, prerender, artifact verification, and transfer checks pass. Static and reduced-motion views retain the full content.
Host Chrome uses the Intel hardware GPU: desktop samples averaged 63–65 fps across opening, reading, and native scrolling at 1440×900, with p95 frame time 20.9 ms. Physical phones and high-DPI displays remain unverified. The untouched baseline already had nine failing unit assertions; tests were preserved. Ready for visual review.
+ Local review artifact · codex/midnight-expressway · Based on c9ac36e · No deployment
+
diff --git a/docs/reviews/evidence/midnight-expressway/journey-motion.mp4 b/docs/reviews/evidence/midnight-expressway/journey-motion.mp4
new file mode 100644
index 0000000..5407a5c
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/journey-motion.mp4 differ
diff --git a/docs/reviews/evidence/midnight-expressway/landscape-contact.png b/docs/reviews/evidence/midnight-expressway/landscape-contact.png
new file mode 100644
index 0000000..1d01018
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/landscape-contact.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/landscape-infrastructure.png b/docs/reviews/evidence/midnight-expressway/landscape-infrastructure.png
new file mode 100644
index 0000000..0a30594
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/landscape-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/landscape-start.png b/docs/reviews/evidence/midnight-expressway/landscape-start.png
new file mode 100644
index 0000000..cf3a0cc
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/landscape-start.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/media-audio-preservation.json b/docs/reviews/evidence/midnight-expressway/media-audio-preservation.json
new file mode 100644
index 0000000..0f843a5
--- /dev/null
+++ b/docs/reviews/evidence/midnight-expressway/media-audio-preservation.json
@@ -0,0 +1,96 @@
+{
+ "assetCount": 15,
+ "allIdentical": true,
+ "assets": [
+ {
+ "asset": "nightdrive-afterglow.mp3",
+ "sha256": "685687ad131d61c34e42bcee29af16a4c8180d788c67d75f2331fa9082a3a44e",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "nightdrive-afterglow.opus",
+ "sha256": "693215397e8ae4c2c13a4a6e4cc74b0059fb26b396c7f35d99e0e2c3e738d4b1",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "nightdrive-drive-lift.mp3",
+ "sha256": "edcb802938d3d1e53392f9ccd2e153f72a24b494c205ce6f556d73de5fe709ec",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "nightdrive-drive-lift.opus",
+ "sha256": "d580e58039c9d700599cf100c96b50d4b5816d93a0af738a93a1e8646fbb7566",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "nightdrive-intro.mp3",
+ "sha256": "008794ebd7f545c32f19aab4ada6aba40d9fc3341830a66e77f21d87a41c009b",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "nightdrive-intro.opus",
+ "sha256": "561c05cea71d0920ec323375644039007b91b0a4c346dc663e233ad42d6ab490",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "nightdrive-main.mp3",
+ "sha256": "796de8247c4cb8012d7c6883b534112ac09a40255a2c33cd68acf0f87c066659",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "nightdrive-main.opus",
+ "sha256": "81933fc8e589428ef07fb265572a79975f183848b6a23616cb289e33fe3fb88c",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "project-currency-converter.webp",
+ "sha256": "57159ae72a075ba5317217233c3c0eb210a485c51b5f2c37b5ed156d5ea37efb",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "project-medication-data-settings.webp",
+ "sha256": "d951dd723047b737ab6a7da46b82f3f3f3aca7ec8853314f74ac57e1950ce1ba",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "project-medication-logs.webp",
+ "sha256": "d34b2d41855e6dca4762fb9fc7728b64a94fef60de36149e465ce50821bc1dc7",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "project-medication-notification-settings.webp",
+ "sha256": "d2fe0076d6581e76c361cb656350c58f6685f6fe91c5b6c5871e60977e8d5be7",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "project-opinion-final-distribution.webp",
+ "sha256": "2f2f6d64b8bb4f79ecbce1a8bc486cf7bbdf4087eb4f10062a6fe889246dbdf1",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "project-opinion-polarization.webp",
+ "sha256": "77f0d5076769e6e45b566c5f322d8f535686f9e9f928e18fe4c16b3dac989c43",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ },
+ {
+ "asset": "project-social-preview.webp",
+ "sha256": "ed470760643ef3d413b6ca4a0e1434771fcc9b61b6ac34011feae77a13f2daca",
+ "matchesApprovedManifest": true,
+ "samePreparedBaselineAndPreview": true
+ }
+ ]
+}
diff --git a/docs/reviews/evidence/midnight-expressway/narrow-contact.png b/docs/reviews/evidence/midnight-expressway/narrow-contact.png
new file mode 100644
index 0000000..5ee0d46
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/narrow-contact.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/narrow-infrastructure.png b/docs/reviews/evidence/midnight-expressway/narrow-infrastructure.png
new file mode 100644
index 0000000..11b5d73
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/narrow-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/narrow-start.png b/docs/reviews/evidence/midnight-expressway/narrow-start.png
new file mode 100644
index 0000000..968ce89
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/narrow-start.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/performance.json b/docs/reviews/evidence/midnight-expressway/performance.json
new file mode 100644
index 0000000..e87f9fa
--- /dev/null
+++ b/docs/reviews/evidence/midnight-expressway/performance.json
@@ -0,0 +1,39 @@
+{
+ "renderer": "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)",
+ "hardwareVerified": false,
+ "quality": "low",
+ "samples": [
+ {
+ "viewport": "390x844",
+ "seconds": 3.05,
+ "frames": 95,
+ "averageFps": 31.1,
+ "p50Ms": 33.3,
+ "p95Ms": 49.9
+ },
+ {
+ "viewport": "1440x900",
+ "seconds": 4.02,
+ "frames": 48,
+ "averageFps": 12,
+ "p50Ms": 83.3,
+ "p95Ms": 99.9
+ }
+ ],
+ "inactiveSignScene": {
+ "drawCalls": 45,
+ "triangles": 44572,
+ "points": 160,
+ "lines": 4586,
+ "rendererGeometries": 34,
+ "rendererTextures": 3,
+ "programs": 21
+ },
+ "afterStaticHighLow": {
+ "orphanedGeometries": 0,
+ "orphanedTextures": 0
+ },
+ "limitations": "Software rendering with instrumentation. Physical devices and hardware frame rates unverified. Static quality removes all live canvases and clipping.",
+ "scope": "Initial software renderer inspection only",
+ "hardwareReport": "chrome-hardware-performance.json"
+}
diff --git a/docs/reviews/evidence/midnight-expressway/phone-additional-work.png b/docs/reviews/evidence/midnight-expressway/phone-additional-work.png
new file mode 100644
index 0000000..833fd07
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/phone-additional-work.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/phone-contact.png b/docs/reviews/evidence/midnight-expressway/phone-contact.png
new file mode 100644
index 0000000..2a7aa16
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/phone-contact.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/phone-experience.png b/docs/reviews/evidence/midnight-expressway/phone-experience.png
new file mode 100644
index 0000000..df65109
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/phone-experience.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/phone-infrastructure.png b/docs/reviews/evidence/midnight-expressway/phone-infrastructure.png
new file mode 100644
index 0000000..82aa65b
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/phone-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/phone-profile.png b/docs/reviews/evidence/midnight-expressway/phone-profile.png
new file mode 100644
index 0000000..b34453a
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/phone-profile.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/phone-selected-builds.png b/docs/reviews/evidence/midnight-expressway/phone-selected-builds.png
new file mode 100644
index 0000000..6e35143
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/phone-selected-builds.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/phone-toolkit.png b/docs/reviews/evidence/midnight-expressway/phone-toolkit.png
new file mode 100644
index 0000000..90413c4
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/phone-toolkit.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/reduced-motion-phone.png b/docs/reviews/evidence/midnight-expressway/reduced-motion-phone.png
new file mode 100644
index 0000000..380975f
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/reduced-motion-phone.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/short-contact.png b/docs/reviews/evidence/midnight-expressway/short-contact.png
new file mode 100644
index 0000000..0d70375
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/short-contact.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/short-infrastructure.png b/docs/reviews/evidence/midnight-expressway/short-infrastructure.png
new file mode 100644
index 0000000..6948854
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/short-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/short-start.png b/docs/reviews/evidence/midnight-expressway/short-start.png
new file mode 100644
index 0000000..186cb35
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/short-start.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/static-phone.png b/docs/reviews/evidence/midnight-expressway/static-phone.png
new file mode 100644
index 0000000..03b1555
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/static-phone.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/tablet-contact.png b/docs/reviews/evidence/midnight-expressway/tablet-contact.png
new file mode 100644
index 0000000..a20c346
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/tablet-contact.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/tablet-infrastructure.png b/docs/reviews/evidence/midnight-expressway/tablet-infrastructure.png
new file mode 100644
index 0000000..4b61d1e
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/tablet-infrastructure.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/tablet-selected-builds.png b/docs/reviews/evidence/midnight-expressway/tablet-selected-builds.png
new file mode 100644
index 0000000..63f0175
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/tablet-selected-builds.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/tablet-start.png b/docs/reviews/evidence/midnight-expressway/tablet-start.png
new file mode 100644
index 0000000..015f0f6
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/tablet-start.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/world-desktop.png b/docs/reviews/evidence/midnight-expressway/world-desktop.png
new file mode 100644
index 0000000..ad98627
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/world-desktop.png differ
diff --git a/docs/reviews/evidence/midnight-expressway/world-phone.png b/docs/reviews/evidence/midnight-expressway/world-phone.png
new file mode 100644
index 0000000..0779685
Binary files /dev/null and b/docs/reviews/evidence/midnight-expressway/world-phone.png differ
diff --git a/docs/reviews/midnight-expressway-direction.md b/docs/reviews/midnight-expressway-direction.md
new file mode 100644
index 0000000..47e88ef
--- /dev/null
+++ b/docs/reviews/midnight-expressway-direction.md
@@ -0,0 +1,25 @@
+# Midnight expressway
+
+Base: local main c9ac36e. Branch: codex/midnight-expressway. All public wording, metadata, media, privacy decisions, section order, canonical journey state and audio are immutable.
+
+## Creative direction
+
+A silver coachbuilt wedge travelling a graphite causeway through cyan topographic waves toward a burning coral horizon. Architecture uses swept graphite uprights, machined silver edges, recessed cyan glass, and narrow coral indicators. The road has satin moisture, not a perfect mirror: directional broken reflections sit on its surface. Only finite objects are authored in Blender; the road and landscape remain continuous runtime geometry.
+
+Design read: a personal professional portfolio for technical hiring and collaboration, with expressive synthwave cinematography and stable, spacious reading surfaces. Native Vue/CSS/Three.js; no new dependencies. Variance 9, motion 8 during travel / 3 during reading, content density 4.
+
+## Scene ledger
+
+Start: low rear three-quarter coachwork, sun and expressway to the right of the open name. Profile: left world corridor and right reading plate. Experience: right world corridor; roadside fins frame the timeline. Infrastructure: taller architectural pylons and precise road sequencing. Selected builds: broad evidence stages with panoramic gaps. Additional work: quieter mid-distance rhythm. Toolkit: ordered vertical structures. Contact: low, warm, slower-feeling arrival and silver bodywork.
+
+Signs retain the approved route labels, semantic destinations and lifecycle. Replace brittle world-coordinate offsets with camera-aware projection into measured empty document corridors; retain physical depth, mounting and a real approach/departure. Housing catches light before the text face; label remains still in its dwell. At compact sizes reserve a scene strip below navigation by clipping the reading document there, using matching section scroll margins. Signs remain behind content, never on a higher stacking layer.
+
+## Materials and motion
+
+Road: charcoal aggregate, subtle wheel polishing, paired center markings, recessed cyan edge channels, shoulder ribs, reflectors, expansion joints, broken coral tail-light and sun response. No planar reflection pass. Landscape: dark opaque underlay beneath the cyan wave grid, layered silhouettes and sparse paired landmarks. Sun: variable-width separated bands, peach crown, coral body, vermilion lower limb, subtle refraction and fine chromosphere arc. Car: deeper aero channels, articulated rear lamps, side intakes, diffuser, machined wheels, edited materials. All new resources owned by the existing disposal bag; repeated details instanced and bounded.
+
+Render-only motion uses current journey energy; camera, road, car, sign and lighting updates never write back to audio or journey. Native scrolling, quality controls, Static and reduced-motion remain.
+
+## Practical verification
+
+Capture baseline before editing; run the established complete content validation and prerender flow. The untouched baseline unit suite currently has 9 failures (653 passed), in scene fallback expectations and old camera/sign/bloom assertions. Preserve tests per request and record this pre-existing result. Continue required client/SSR/prerender/artifact/transfer validation separately. Inspect all eight chapters at desktop, tablet, phone, narrow and short sizes; scroll, reversal, jumps, keyboard, resize, fallback and console. Identify the GPU, capture actual motion, make a second visual pass, preserve editable isolated Blender source, compare content and media hashes, then commit without merge/push/deploy.
diff --git a/docs/reviews/midnight-expressway-review.md b/docs/reviews/midnight-expressway-review.md
new file mode 100644
index 0000000..6f9bd72
--- /dev/null
+++ b/docs/reviews/midnight-expressway-review.md
@@ -0,0 +1,56 @@
+# Midnight expressway — ready for visual review
+
+Implementation branch: `codex/midnight-expressway`, based on local main `c9ac36e`. Worktree: `/home/luca/Documents/Portfolio/.worktrees/midnight-expressway`. No merge, push, or deployment.
+
+## Open the work
+
+- [Live validated preview](http://localhost:41821)
+- [Before/after gallery and motion](http://localhost:41822)
+- [Motion recording](evidence/midnight-expressway/journey-motion.mp4)
+- [Editable Blender source](../../assets/blender/expressway/nightdrive-expressway.blend)
+- [Blender provenance and export instructions](../../assets/blender/expressway/README.md)
+
+The gallery is also available locally as `evidence/midnight-expressway/index.html`. Preview serves this worktree's validated, prerendered `dist`; the baseline was separately built from the merged version and serves on port 41820. The previous port 41812 was not running.
+
+## What changed
+
+A satin graphite expressway now has directional grain, broken coral reflections, cyan recessed channels, shoulder ribs, reflectors, expansion joints, low rails, and swept signal pylons. Sparse ridge observatories create a slower outer rhythm. All repeated scenery uses bounded instancing and continuous wrap; no additional reflection pass is required.
+
+The cyan terrain waves now sit on opaque displaced ground. Dark surfaces occlude distant contours and give the landscape actual depth. Thin, separated atmospheric layers replace full-screen haze. The striped sun has a peach crown, a coral lower limb, luminous band edges, convection filaments, and restrained distortion. Stars sit behind its solid surface.
+
+The car has twin articulated rear lamp rails, deep side intakes, machined dished wheels, shoulder rings, lower blades, deck seams, larger wing endplates, exhaust surrounds, and a more defined diffuser. Four wheels rotate independently while suspension acts on the body. The isolated Blender source remains editable.
+
+Signs are beveled illuminated objects with fins, inset faces, twin supports, footings, hardware, and an approach sweep. Camera projection places readable dwell in measured empty screen space. A reserved scene strip protects that space without placing the signs above the content. Very short landscape layouts put reading on the left and the sign on the right. Reflow is damped, keyboard focus stays in the reading corridor, and Static mode releases the clipping entirely.
+
+Camera lag, forward anticipation, coasting, and warm Contact recovery are visual consumers of existing journey state. Typography, project staging, diagram sizing, and compact reading surfaces were refined around the unchanged copy.
+
+## Refinement after the first implementation
+
+Browser observation led to a second pass: removal of flat full-screen haze and distracting old anchors; opaque ground beneath the grid; a crisper sun limb with correct star occlusion; closer sign dwell to prevent foreground pylons crossing labels; corrected short-height composition; stable heading activation after navigation; and independent wheel rotation with grounded contact. Static posters were captured from the live world.
+
+## Preservation
+
+The baseline and new preview have identical normalized main text, rendered wording, links, title, metadata, canonical information, structured metadata, and ordered section IDs. The 15 approved media/audio files match the approved manifest and both built previews byte for byte. Content, audio, canonical journey state, motion preferences, route definitions, tests, and preview configuration have no source changes.
+
+Evidence: [content comparison](evidence/midnight-expressway/content-preservation.json), [all approved media/audio hashes](evidence/midnight-expressway/media-audio-preservation.json).
+
+The soundtrack reached its running state through the existing controls. Mute/unmute, volume, motion pause/resume, and quality controls were exercised. The capture is silent; it does not replace the soundtrack assets or certify acoustic playback through physical speakers.
+
+## Practical verification
+
+- Approved-asset validation, typecheck, client build, SSR build, validated prerender, artifact verification, and transfer checks pass. Validation was not disabled.
+- All eight chapters were traversed at 1440×900, 768×1024, 390×844, 320×568, 1024×600, and 844×390. Intermediate resizing also included 900×760. No horizontal page overflow was observed; narrow and short navigation settled on every requested section.
+- Ordinary scrolling, acceleration, coasting, reverse scrolling, long navigation jumps, Contact arrival, keyboard skip link, successive media/link focus, and resizing were exercised. The motion recording shows the road, sign transitions, reversal, Builds, and Contact.
+- Static mode: zero canvas, no live clipping. Reduced motion: static artwork and all eight sections. JavaScript disabled: all eight sections and the approved content remain available.
+- Direct-preview console inspection found no application exceptions or WebGL shader errors. An inherited `/favicon.ico` 404 remains. The instrumentation proxy emitted its own WebGL warning.
+- Static → High → Low transitions were exercised. Scene disposal inspection found zero orphaned geometries or textures. Repeated roadside objects are bounded; the final Low scene has 45 draw calls and approximately 44,572 triangles while the sign is inactive. No new full-resolution post-processing pass was added.
+
+## Performance and limits
+
+The initial inspection renderer was **ANGLE / Vulkan / SwiftShader Device (Subzero)**. At Low quality the instrumented 1440×900 sample averaged 12 fps (4 seconds, p95 99.9 ms); 390×844 averaged 31.1 fps (3 seconds, p95 49.9 ms). The Chrome app connector returned `Transport closed`, including its reset call. A direct isolated host Chrome window provided the working hardware-backed alternative.
+
+**Host Chrome 152.0.7977.64 uses ANGLE / Mesa Intel Graphics (ARL), OpenGL ES 3.2.** With the High maximum-quality preference and normal adaptive behavior intact, six-second requestAnimationFrame samples averaged 63.1 fps at the opening, 62.7 fps while reading Infrastructure, and 65.4 fps during native scrolling/coasting at 1440×900. Desktop p95 was 20.9 ms. The 390×844 host-browser viewport averaged 188.7 fps (p95 8.4 ms). These samples use the host GPU at devicePixelRatio 1; they do not certify a physical phone, high-DPI screen, or every browser. No application exceptions occurred. [Raw hardware results](evidence/midnight-expressway/chrome-hardware-performance.json) include the renderer and measurement method. A second motion capture uses this hardware-backed Chrome window.
+
+Transfer report: initial application JS 90,264 bytes; first live scene code plus car 299,457 bytes; both responsive posters 136,680 bytes, measured by the existing Brotli/raw accounting. No transfer violations. The raw car export is 1,185,588 bytes. Vite retains its large scene-chunk warning.
+
+The untouched baseline's established `build:preview` stopped at **9 failing / 653 passing unit tests**: fallback counts and old bloom/camera/sign assertions. [The original log](evidence/midnight-expressway/baseline-checks.log) was captured before editing. Tests were not written, changed, or repeatedly run, as requested. The complete validated rendering and artifact stages were run separately and pass; the unit suite is not claimed green.
diff --git a/src/assets/hero-expressway-mobile.jpg b/src/assets/hero-expressway-mobile.jpg
new file mode 100644
index 0000000..de9569b
Binary files /dev/null and b/src/assets/hero-expressway-mobile.jpg differ
diff --git a/src/assets/hero-expressway.jpg b/src/assets/hero-expressway.jpg
new file mode 100644
index 0000000..1b3686d
Binary files /dev/null and b/src/assets/hero-expressway.jpg differ
diff --git a/src/assets/nightdrive-grand-tourer-expressway.glb b/src/assets/nightdrive-grand-tourer-expressway.glb
new file mode 100644
index 0000000..61088b9
Binary files /dev/null and b/src/assets/nightdrive-grand-tourer-expressway.glb differ
diff --git a/src/components/SceneCanvas.vue b/src/components/SceneCanvas.vue
index 080ec44..02b3149 100644
--- a/src/components/SceneCanvas.vue
+++ b/src/components/SceneCanvas.vue
@@ -4,8 +4,8 @@ import { useSceneController, type SceneHostState } from '@/composables/useSceneC
import type { JourneySnapshot } from '@/journey/types';
import type { MotionPolicy, QualityTier } from '@/journey/motion-policy';
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
-import posterUrl from '@/assets/hero-evolution.jpg';
-import mobilePosterUrl from '@/assets/hero-evolution-mobile.jpg';
+import posterUrl from '@/assets/hero-expressway.jpg';
+import mobilePosterUrl from '@/assets/hero-expressway-mobile.jpg';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
@@ -57,7 +57,7 @@ if (posterFailed.value) recordTestFaultEvent('poster-failure');
>
-
+
diff --git a/src/scene/world/atmosphere.ts b/src/scene/world/atmosphere.ts
index a01f916..0117998 100644
--- a/src/scene/world/atmosphere.ts
+++ b/src/scene/world/atmosphere.ts
@@ -1,7 +1,10 @@
import * as THREE from 'three';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
-import type { MutableChapterPalette, MutableColor } from '@/scene/world/palette';
+import type {
+ MutableChapterPalette,
+ MutableColor,
+} from '@/scene/world/palette';
import { CHAPTER_PALETTES } from '@/scene/world/palette';
import {
HORIZON_FOG_END,
@@ -36,22 +39,24 @@ export function createAtmosphere(
const night = CHAPTER_PALETTES['deep-night'];
const skyGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2));
- const skyMaterial = resources.own('material', new THREE.ShaderMaterial({
- uniforms: {
- uSkyColor: { value: new THREE.Color(night.sky) },
- uFogColor: { value: new THREE.Color(night.fog) },
- uHazeColor: { value: new THREE.Color(night.haze) },
- uWarmth: { value: night.warmth },
- uAspect: { value: 1 },
- },
- vertexShader: `
+ const skyMaterial = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms: {
+ uSkyColor: { value: new THREE.Color(night.sky) },
+ uFogColor: { value: new THREE.Color(night.fog) },
+ uHazeColor: { value: new THREE.Color(night.haze) },
+ uWarmth: { value: night.warmth },
+ uAspect: { value: 1 },
+ },
+ vertexShader: `
varying vec2 vSkyUv;
void main() {
vSkyUv = uv;
gl_Position = vec4(position.xy, 1.0, 1.0);
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uSkyColor;
uniform vec3 uFogColor;
uniform vec3 uHazeColor;
@@ -78,11 +83,12 @@ export function createAtmosphere(
#include
}
`,
- depthWrite: false,
- depthTest: false,
- fog: false,
- toneMapped: true,
- }));
+ depthWrite: false,
+ depthTest: false,
+ fog: false,
+ toneMapped: true,
+ }),
+ );
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
sky.name = 'atmosphere-sky';
sky.frustumCulled = false;
@@ -93,14 +99,20 @@ export function createAtmosphere(
silhouettes.name = 'distant-silhouettes';
const silhouetteMaterials: Array<{ color: MutableColor }> = [];
for (let band = 0; band < 3; band += 1) {
- const material = resources.own('material', new THREE.MeshBasicMaterial({
- color: new THREE.Color(night.fog).lerp(new THREE.Color(night.terrainLow), 0.46 + band * 0.12),
- transparent: true,
- opacity: 0.58 - band * 0.1,
- depthWrite: true,
- depthTest: true,
- toneMapped: true,
- }));
+ const material = resources.own(
+ 'material',
+ new THREE.MeshBasicMaterial({
+ color: new THREE.Color(night.fog).lerp(
+ new THREE.Color(night.terrainLow),
+ 0.46 + band * 0.12,
+ ),
+ transparent: true,
+ opacity: 0.58 - band * 0.1,
+ depthWrite: true,
+ depthTest: true,
+ toneMapped: true,
+ }),
+ );
silhouetteMaterials.push(material);
const ridge = new THREE.Mesh(
resources.own('geometry', createSilhouetteGeometry(band)),
@@ -112,16 +124,21 @@ export function createAtmosphere(
}
group.add(silhouettes);
- const starGeometry = resources.own('geometry', createStarGeometry(quality.starCount));
- const starMaterial = resources.own('material', new THREE.ShaderMaterial({
- uniforms: {
- uVisibility: { value: night.starVisibility },
- uDistance: { value: 0 },
- uPointScale: { value: 18 },
- uMaxPointSize: { value: 4.5 },
- uColor: { value: new THREE.Color(0xb9ecf2) },
- },
- vertexShader: `
+ const starGeometry = resources.own(
+ 'geometry',
+ createStarGeometry(quality.starCount),
+ );
+ const starMaterial = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms: {
+ uVisibility: { value: night.starVisibility },
+ uDistance: { value: 0 },
+ uPointScale: { value: 18 },
+ uMaxPointSize: { value: 4.5 },
+ uColor: { value: new THREE.Color(0xb9ecf2) },
+ },
+ vertexShader: `
attribute float starSize;
attribute float brightness;
uniform float uDistance;
@@ -141,7 +158,7 @@ export function createAtmosphere(
gl_Position = projectionMatrix * viewPosition;
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uColor;
uniform float uVisibility;
varying float vBrightness;
@@ -153,46 +170,32 @@ export function createAtmosphere(
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ );
const stars = new THREE.Points(starGeometry, starMaterial);
stars.name = 'atmosphere-stars';
stars.frustumCulled = false;
group.add(stars);
- const hazeGroup = new THREE.Group();
- hazeGroup.name = 'horizon-haze';
- const hazeGeometry = resources.own('geometry', new THREE.PlaneGeometry(2, 2));
- const nearHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.15, 0.47, 8.4));
- const farHazeMaterial = resources.own('material', createHazeMaterial(night.haze, 0.1, 0.5, 5.8));
- const farHaze = new THREE.Mesh(hazeGeometry, farHazeMaterial);
- farHaze.name = 'horizon-haze-far';
- farHaze.frustumCulled = false;
- farHaze.renderOrder = 60;
- hazeGroup.add(farHaze);
- const nearHaze = new THREE.Mesh(hazeGeometry, nearHazeMaterial);
- nearHaze.name = 'horizon-haze-near';
- nearHaze.frustumCulled = false;
- nearHaze.renderOrder = 61;
- hazeGroup.add(nearHaze);
- group.add(hazeGroup);
-
- const anchors = createRoadsideAnchors(resources, quality);
- group.add(anchors.mesh);
-
- const streakGeometry = resources.own('geometry', createStreakGeometry(quality.streakCount));
- const streakMaterial = resources.own('material', new THREE.ShaderMaterial({
- uniforms: {
- uDistance: { value: 0 },
- uAcceleration: { value: 0 },
- uVisibility: { value: 0 },
- uColor: { value: new THREE.Color(0x79dae5) },
- },
- vertexShader: `
+ const streakGeometry = resources.own(
+ 'geometry',
+ createStreakGeometry(quality.streakCount),
+ );
+ const streakMaterial = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms: {
+ uDistance: { value: 0 },
+ uAcceleration: { value: 0 },
+ uVisibility: { value: 0 },
+ uColor: { value: new THREE.Color(0x79dae5) },
+ },
+ vertexShader: `
attribute float endFactor;
uniform float uDistance;
uniform float uAcceleration;
@@ -208,7 +211,7 @@ export function createAtmosphere(
gl_Position = projectionMatrix * modelViewMatrix * vec4(streak, 1.0);
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uColor;
uniform float uVisibility;
varying float vTail;
@@ -218,12 +221,13 @@ export function createAtmosphere(
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ );
const streaks = new THREE.LineSegments(streakGeometry, streakMaterial);
streaks.name = 'speed-streaks';
streaks.frustumCulled = false;
@@ -232,17 +236,18 @@ export function createAtmosphere(
return {
group,
setViewportAspect(aspect) {
- const safeAspect = Number.isFinite(aspect) ? THREE.MathUtils.clamp(aspect, 0.35, 4) : 1;
+ const safeAspect = Number.isFinite(aspect)
+ ? THREE.MathUtils.clamp(aspect, 0.35, 4)
+ : 1;
skyMaterial.uniforms.uAspect.value = safeAspect;
- nearHazeMaterial.uniforms.uAspect.value = safeAspect;
- farHazeMaterial.uniforms.uAspect.value = safeAspect;
},
update(distance, speed, acceleration, palette) {
const safeAcceleration = clamp01(acceleration);
- const visibility = safeAcceleration
- * (0.62 + Math.sqrt(safeAcceleration) * 0.38)
- * palette.streakEnergy
- * 0.72;
+ const visibility =
+ safeAcceleration *
+ (0.62 + Math.sqrt(safeAcceleration) * 0.38) *
+ palette.streakEnergy *
+ 0.72;
starMaterial.uniforms.uDistance.value = finiteOrZero(distance);
starMaterial.uniforms.uVisibility.value = palette.starVisibility;
streakMaterial.uniforms.uDistance.value = finiteOrZero(distance);
@@ -252,18 +257,11 @@ export function createAtmosphere(
skyMaterial.uniforms.uFogColor.value.copy(palette.fog);
skyMaterial.uniforms.uHazeColor.value.copy(palette.haze);
skyMaterial.uniforms.uWarmth.value = palette.warmth;
- nearHazeMaterial.uniforms.uColor.value.copy(palette.haze);
- farHazeMaterial.uniforms.uColor.value.copy(palette.haze);
- nearHazeMaterial.uniforms.uEnergy.value = 0.07 + palette.warmth * 0.05;
- farHazeMaterial.uniforms.uEnergy.value = 0.045 + palette.warmth * 0.04;
for (let index = 0; index < silhouetteMaterials.length; index += 1) {
silhouetteMaterials[index]?.color
.copy(palette.fog)
.lerp(palette.terrainLow, 0.46 + index * 0.12);
}
- anchors.uniforms.uDistance.value = finiteOrZero(distance);
- anchors.uniforms.uEnergy.value = 0.38 + palette.terrainEnergy * 0.22;
- anchors.uniforms.uColor.value.copy(palette.terrainHigh);
},
};
}
@@ -274,9 +272,9 @@ function createStarGeometry(count: number) {
const brightness = new Float32Array(count);
for (let index = 0; index < count; index += 1) {
const offset = index * 3;
- positions[offset] = seeded(index, 11) * 680 - 340;
- positions[offset + 1] = 16 + seeded(index, 23) * 230;
- positions[offset + 2] = -48 - seeded(index, 37) * 540;
+ positions[offset] = seeded(index, 11) * 1800 - 900;
+ positions[offset + 1] = 85 + seeded(index, 23) * 640;
+ positions[offset + 2] = -780 - seeded(index, 37) * 260;
sizes[index] = 2.4 + seeded(index, 53) * 4.8;
brightness[index] = 0.28 + seeded(index, 71) * 0.72;
}
@@ -291,7 +289,8 @@ function createStreakGeometry(count: number) {
const positions = new Float32Array(count * 2 * 3);
const ends = new Float32Array(count * 2);
for (let index = 0; index < count; index += 1) {
- const x = (seeded(index, 101) > 0.5 ? 1 : -1) * (14 + seeded(index, 103) * 102);
+ const x =
+ (seeded(index, 101) > 0.5 ? 1 : -1) * (14 + seeded(index, 103) * 102);
const y = 0.8 + seeded(index, 107) * 32;
const z = -8 - seeded(index, 109) * 512;
const offset = index * 6;
@@ -310,52 +309,6 @@ function createStreakGeometry(count: number) {
return geometry;
}
-function createHazeMaterial(
- color: number,
- energy: number,
- center: number,
- spread: number,
-) {
- return new THREE.ShaderMaterial({
- uniforms: {
- uColor: { value: new THREE.Color(color) },
- uEnergy: { value: energy },
- uCenter: { value: center },
- uSpread: { value: spread },
- uAspect: { value: 1 },
- },
- vertexShader: `
- varying vec2 vHazeUv;
- void main() {
- vHazeUv = uv;
- gl_Position = vec4(position.xy, 1.0, 1.0);
- }
- `,
- fragmentShader: `
- uniform vec3 uColor;
- uniform float uEnergy;
- uniform float uCenter;
- uniform float uSpread;
- uniform float uAspect;
- varying vec2 vHazeUv;
- void main() {
- float vertical = exp(-pow(abs(vHazeUv.y - uCenter) * uSpread, 2.0));
- float normalizedX = abs(vHazeUv.x - 0.5) * 2.0 * min(uAspect, 2.35) / 2.35;
- float centerLift = 1.0 - smoothstep(0.12, 1.2, normalizedX);
- float horizontal = mix(0.64, 1.0, centerLift);
- gl_FragColor = vec4(uColor * 1.08, vertical * horizontal * uEnergy);
- #include
- #include
- }
- `,
- transparent: true,
- depthWrite: false,
- depthTest: false,
- blending: THREE.NormalBlending,
- toneMapped: true,
- });
-}
-
function createSilhouetteGeometry(band: number) {
const segments = 18;
const positions = new Float32Array((segments + 1) * 2 * 3);
@@ -366,7 +319,9 @@ function createSilhouetteGeometry(band: number) {
for (let index = 0; index <= segments; index += 1) {
const progress = index / segments;
const x = (progress - 0.5) * width;
- const lowWave = Math.sin(progress * Math.PI * (2.2 + band * 0.35) + band * 1.7);
+ const lowWave = Math.sin(
+ progress * Math.PI * (2.2 + band * 0.35) + band * 1.7,
+ );
const broadWave = Math.sin(progress * Math.PI * 5.2 + band * 2.4);
const topY = 4 + band * 2.6 + lowWave * (8 + band * 1.8) + broadWave * 3.2;
const offset = index * 6;
@@ -388,68 +343,6 @@ function createSilhouetteGeometry(band: number) {
return geometry;
}
-function createRoadsideAnchors(
- resources: SceneResourceBag,
- quality: RenderQualitySettings,
-) {
- const count = Math.max(12, Math.min(32, Math.round(quality.terrainSegments / 4)));
- const geometry = resources.own('geometry', new THREE.BoxGeometry(0.15, 0.72, 0.15));
- const uniforms = {
- uDistance: { value: 0 },
- uEnergy: { value: 0.5 },
- uColor: { value: new THREE.Color(CHAPTER_PALETTES['deep-night'].terrainHigh) },
- };
- const material = resources.own('material', new THREE.ShaderMaterial({
- uniforms,
- vertexShader: `
- uniform float uDistance;
- varying float vDepthFade;
- void main() {
- vec4 anchor = instanceMatrix * vec4(position, 1.0);
- anchor.z = mod(
- anchor.z + uDistance - ${WORLD_FAR_Z.toFixed(1)},
- ${WORLD_DEPTH.toFixed(1)}
- ) + ${WORLD_FAR_Z.toFixed(1)};
- vec4 viewPosition = modelViewMatrix * anchor;
- vDepthFade = (1.0 - smoothstep(540.0, ${HORIZON_FOG_END.toFixed(1)}, -viewPosition.z))
- * smoothstep(12.0, 42.0, -viewPosition.z);
- gl_Position = projectionMatrix * viewPosition;
- }
- `,
- fragmentShader: `
- uniform vec3 uColor;
- uniform float uEnergy;
- varying float vDepthFade;
- void main() {
- gl_FragColor = vec4(uColor * (0.7 + uEnergy * 0.55), vDepthFade * uEnergy);
- #include
- #include
- }
- `,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
- const mesh = new THREE.InstancedMesh(geometry, material, count);
- mesh.name = 'roadside-anchors';
- mesh.frustumCulled = false;
- const matrix = new THREE.Matrix4();
- for (let index = 0; index < count; index += 1) {
- const side = index % 2 === 0 ? -1 : 1;
- const lane = Math.floor(index / 2);
- const laneProgress = (lane + seeded(index, 191) * 0.82)
- / Math.max(1, Math.ceil(count / 2));
- const z = WORLD_NEAR_Z - 24 - laneProgress * (WORLD_DEPTH - 52);
- const x = side * (10.8 + seeded(index, 181) * 1.4);
- matrix.makeTranslation(x, 0.38, z);
- mesh.setMatrixAt(index, matrix);
- }
- mesh.instanceMatrix.needsUpdate = true;
- return { mesh, uniforms };
-}
-
function seeded(index: number, salt: number): number {
const result = Math.sin((index + 1) * 91.713 + salt * 17.17) * 43_758.5453;
return result - Math.floor(result);
diff --git a/src/scene/world/car.ts b/src/scene/world/car.ts
index 4a1119b..388590e 100644
--- a/src/scene/world/car.ts
+++ b/src/scene/world/car.ts
@@ -1,19 +1,29 @@
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
-import grandTourerUrl from '@/assets/nightdrive-grand-tourer-evolution.glb?url';
+import grandTourerUrl from '@/assets/nightdrive-grand-tourer-expressway.glb?url';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneDetailMode } from '@/scene/types';
export interface CarWorld {
group: CarGroup;
setDetail(detail: SceneDetailMode): void;
- update(speed: number, acceleration: number, elapsedSeconds: number): void;
+ update(
+ speed: number,
+ acceleration: number,
+ elapsedSeconds: number,
+ distance?: number,
+ ): void;
}
interface CarGroup {
name: string;
children: unknown[];
- position: { x: number; y: number; z: number; set(x: number, y: number, z: number): void };
+ position: {
+ x: number;
+ y: number;
+ z: number;
+ set(x: number, y: number, z: number): void;
+ };
rotation: { x: number; y: number; z: number };
scale: { set(x: number, y: number, z: number): void };
visible: boolean;
@@ -35,6 +45,9 @@ interface AuthoredObject {
isMesh?: boolean;
geometry?: { dispose(): void };
material?: AuthoredMaterial | AuthoredMaterial[];
+ parent?: { name: string };
+ position: { y: number };
+ rotation: { x: number; z: number };
}
interface AuthoredCarGroup extends CarGroup {
@@ -46,16 +59,23 @@ const FULL_BASE_Y = 0.58;
const REDUCED_BASE_Y = 0.42;
const GRAND_TOURER_URL = grandTourerUrl;
-export async function loadGrandTourer(signal: AbortSignal): Promise {
+export async function loadGrandTourer(
+ signal: AbortSignal,
+): Promise {
const response = await fetch(GRAND_TOURER_URL, { signal });
if (!response.ok) {
throw new Error(`Grand tourer asset failed to load (${response.status}).`);
}
const bytes = await response.arrayBuffer();
- if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
+ if (signal.aborted)
+ throw new DOMException('Scene build aborted', 'AbortError');
const gltf = await new GLTFLoader().parseAsync(bytes, GRAND_TOURER_URL);
- if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
- const authored = gltf.scene.getObjectByName('evolution-chase-car') ?? gltf.scene.getObjectByName('chase-car') ?? gltf.scene;
+ if (signal.aborted)
+ throw new DOMException('Scene build aborted', 'AbortError');
+ const authored =
+ gltf.scene.getObjectByName('evolution-chase-car') ??
+ gltf.scene.getObjectByName('chase-car') ??
+ gltf.scene;
authored.removeFromParent();
return authored as AuthoredCarGroup;
}
@@ -71,85 +91,118 @@ export function createCar(
group.position.set(0, baseY, 0.72);
group.scale.set(1.1, 0.82, 0.98);
- const bodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x304e68,
- emissive: 0x091725,
- emissiveIntensity: 0.42,
- roughness: 0.36,
- metalness: 0.68,
- }));
- const shoulderMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x365b77,
- emissive: 0x0a1b2a,
- emissiveIntensity: 0.36,
- roughness: 0.3,
- metalness: 0.74,
- }));
- const lowerBodyMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x111c29,
- roughness: 0.48,
- metalness: 0.54,
- }));
- const glassMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x071522,
- emissive: 0x061522,
- emissiveIntensity: 0.34,
- roughness: 0.12,
- metalness: 0.42,
- transparent: true,
- opacity: 0.92,
- }));
- const fasciaMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x080d15,
- roughness: 0.52,
- metalness: 0.42,
- }));
- const tailMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x8f0b2d,
- emissive: 0xff174f,
- emissiveIntensity: 1.25,
- roughness: 0.26,
- metalness: 0.18,
- toneMapped: true,
- }));
- const cyanMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x2b9bab,
- emissive: 0x44dceb,
- emissiveIntensity: 0.7,
- roughness: 0.34,
- metalness: 0.32,
- toneMapped: true,
- }));
- const tireMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x020407,
- roughness: 0.94,
- metalness: 0.04,
- }));
- const hubMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x354451,
- roughness: 0.42,
- metalness: 0.78,
- }));
+ const bodyMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x304e68,
+ emissive: 0x091725,
+ emissiveIntensity: 0.42,
+ roughness: 0.36,
+ metalness: 0.68,
+ }),
+ );
+ const shoulderMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x365b77,
+ emissive: 0x0a1b2a,
+ emissiveIntensity: 0.36,
+ roughness: 0.3,
+ metalness: 0.74,
+ }),
+ );
+ const lowerBodyMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x111c29,
+ roughness: 0.48,
+ metalness: 0.54,
+ }),
+ );
+ const glassMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x071522,
+ emissive: 0x061522,
+ emissiveIntensity: 0.34,
+ roughness: 0.12,
+ metalness: 0.42,
+ transparent: true,
+ opacity: 0.92,
+ }),
+ );
+ const fasciaMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x080d15,
+ roughness: 0.52,
+ metalness: 0.42,
+ }),
+ );
+ const tailMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x8f0b2d,
+ emissive: 0xff174f,
+ emissiveIntensity: 1.25,
+ roughness: 0.26,
+ metalness: 0.18,
+ toneMapped: true,
+ }),
+ );
+ const cyanMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x2b9bab,
+ emissive: 0x44dceb,
+ emissiveIntensity: 0.7,
+ roughness: 0.34,
+ metalness: 0.32,
+ toneMapped: true,
+ }),
+ );
+ const tireMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x020407,
+ roughness: 0.94,
+ metalness: 0.04,
+ }),
+ );
+ const hubMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x354451,
+ roughness: 0.42,
+ metalness: 0.78,
+ }),
+ );
const rearProfile = createRearProfile();
- const silhouetteGeometry = resources.own('geometry', new THREE.ExtrudeGeometry(rearProfile, {
- depth: 5.8,
- steps: 1,
- bevelEnabled: true,
- bevelSegments: 1,
- bevelSize: 0.11,
- bevelThickness: 0.1,
- }));
+ const silhouetteGeometry = resources.own(
+ 'geometry',
+ new THREE.ExtrudeGeometry(rearProfile, {
+ depth: 5.8,
+ steps: 1,
+ bevelEnabled: true,
+ bevelSegments: 1,
+ bevelSize: 0.11,
+ bevelThickness: 0.1,
+ }),
+ );
silhouetteGeometry.translate(0, 0, -2.9);
silhouetteGeometry.computeVertexNormals();
const silhouette = new THREE.Mesh(silhouetteGeometry, bodyMaterial);
silhouette.name = 'rear-silhouette';
group.add(silhouette);
- const rearFaceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
- color: 0x1b3246,
- toneMapped: true,
- }));
+ const rearFaceMaterial = resources.own(
+ 'material',
+ new THREE.MeshBasicMaterial({
+ color: 0x1b3246,
+ toneMapped: true,
+ }),
+ );
const rearFace = new THREE.Mesh(
resources.own('geometry', new THREE.ShapeGeometry(rearProfile)),
rearFaceMaterial,
@@ -184,12 +237,15 @@ export function createCar(
rearGlassProfile.lineTo(1.15, 1.41);
rearGlassProfile.lineTo(1.58, 0.8);
rearGlassProfile.closePath();
- const rearGlassMaterial = resources.own('material', new THREE.MeshBasicMaterial({
- color: 0x0b2537,
- transparent: true,
- opacity: 0.94,
- toneMapped: true,
- }));
+ const rearGlassMaterial = resources.own(
+ 'material',
+ new THREE.MeshBasicMaterial({
+ color: 0x0b2537,
+ transparent: true,
+ opacity: 0.94,
+ toneMapped: true,
+ }),
+ );
const rearGlass = new THREE.Mesh(
resources.own('geometry', new THREE.ShapeGeometry(rearGlassProfile)),
rearGlassMaterial,
@@ -198,13 +254,19 @@ export function createCar(
rearGlass.position.z = 1.44;
group.add(rearGlass);
- const fasciaGeometry = resources.own('geometry', new THREE.BoxGeometry(4.84, 0.56, 0.16));
+ const fasciaGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(4.84, 0.56, 0.16),
+ );
const fascia = new THREE.Mesh(fasciaGeometry, fasciaMaterial);
fascia.name = 'rear-fascia';
fascia.position.set(0, 0.02, 3.07);
group.add(fascia);
- const lightGeometry = resources.own('geometry', new THREE.BoxGeometry(4.55, 0.13, 0.09));
+ const lightGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(4.55, 0.13, 0.09),
+ );
const lightBar = new THREE.Mesh(lightGeometry, tailMaterial);
lightBar.name = 'rear-light-bar';
lightBar.position.set(0, 0.46, 3.17);
@@ -216,9 +278,18 @@ export function createCar(
diffuser.position.z = 3.08;
group.add(diffuser);
- const wheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.66, 0.66, 0.5, 24));
- const hubGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.31, 0.31, 0.515, 16));
- for (const [x, side] of [[-2.38, 'left'], [2.38, 'right']] as const) {
+ const wheelGeometry = resources.own(
+ 'geometry',
+ new THREE.CylinderGeometry(0.66, 0.66, 0.5, 24),
+ );
+ const hubGeometry = resources.own(
+ 'geometry',
+ new THREE.CylinderGeometry(0.31, 0.31, 0.515, 16),
+ );
+ for (const [x, side] of [
+ [-2.38, 'left'],
+ [2.38, 'right'],
+ ] as const) {
const wheel = new THREE.Mesh(wheelGeometry, tireMaterial);
wheel.name = `rear-wheel-${side}`;
wheel.rotation.z = Math.PI / 2;
@@ -235,17 +306,22 @@ export function createCar(
uColor: { value: new THREE.Color(0x49d7e5) },
uOpacity: { value: 0.12 },
};
- const underglowGeometry = resources.own('geometry', new THREE.PlaneGeometry(3.9, 4.2));
- const underglowMaterial = resources.own('material', new THREE.ShaderMaterial({
- uniforms: underglowUniforms,
- vertexShader: `
+ const underglowGeometry = resources.own(
+ 'geometry',
+ new THREE.PlaneGeometry(3.9, 4.2),
+ );
+ const underglowMaterial = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms: underglowUniforms,
+ vertexShader: `
varying vec2 vGlowUv;
void main() {
vGlowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vGlowUv;
@@ -257,12 +333,13 @@ export function createCar(
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ );
const underglow = new THREE.Mesh(underglowGeometry, underglowMaterial);
underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2;
@@ -274,18 +351,27 @@ export function createCar(
const secondary = new THREE.Group();
secondary.name = 'secondary-detail';
- const trimGeometry = resources.own('geometry', new THREE.BoxGeometry(0.38, 0.075, 0.07));
+ const trimGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(0.38, 0.075, 0.07),
+ );
for (const x of [-1.98, 1.98]) {
const trim = new THREE.Mesh(trimGeometry, cyanMaterial);
trim.position.set(x, -0.15, 3.13);
secondary.add(trim);
}
- const deckGeometry = resources.own('geometry', new THREE.BoxGeometry(3.25, 0.075, 0.62));
+ const deckGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(3.25, 0.075, 0.62),
+ );
const rearDeck = new THREE.Mesh(deckGeometry, shoulderMaterial);
rearDeck.position.set(0, 0.86, 2.15);
rearDeck.rotation.x = -0.08;
secondary.add(rearDeck);
- const frontWheelGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.58, 0.58, 0.42, 18));
+ const frontWheelGeometry = resources.own(
+ 'geometry',
+ new THREE.CylinderGeometry(0.58, 0.58, 0.42, 18),
+ );
for (const x of [-2.18, 2.18]) {
const wheel = new THREE.Mesh(frontWheelGeometry, tireMaterial);
wheel.rotation.z = Math.PI / 2;
@@ -321,24 +407,30 @@ export function createCar(
update(speed, acceleration, elapsedSeconds) {
const safeSpeed = clamp01(speed);
const response = clamp01(acceleration);
- const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
- const deltaSeconds = previousElapsed === null
- ? 1 / 60
- : THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
+ const elapsed = Number.isFinite(elapsedSeconds)
+ ? Math.max(0, elapsedSeconds)
+ : 0;
+ const deltaSeconds =
+ previousElapsed === null
+ ? 1 / 60
+ : THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
previousElapsed = elapsed;
const targetSquat = response * 0.088;
- const springAcceleration = (targetSquat - squat) * 34 - squatVelocity * 10;
+ const springAcceleration =
+ (targetSquat - squat) * 34 - squatVelocity * 10;
squatVelocity += springAcceleration * deltaSeconds;
squat += squatVelocity * deltaSeconds;
- const vibration = Math.sin(elapsed * (8.2 + safeSpeed * 2.4)) * (0.002 + safeSpeed * 0.003);
+ const vibration =
+ Math.sin(elapsed * (8.2 + safeSpeed * 2.4)) *
+ (0.002 + safeSpeed * 0.003);
group.position.y = baseY - squat + vibration;
group.rotation.x = -squat * 0.045;
- group.rotation.z = (
- Math.sin(elapsed * 0.78) * safeSpeed * 0.0024
- + Math.sin(elapsed * 1.31) * response * 0.0014
- );
+ group.rotation.z =
+ Math.sin(elapsed * 0.78) * safeSpeed * 0.0024 +
+ Math.sin(elapsed * 1.31) * response * 0.0014;
tailMaterial.emissiveIntensity = 1.25 + response * 1.35;
- underglowUniforms.uOpacity.value = 0.1 + safeSpeed * 0.055 + response * 0.07;
+ underglowUniforms.uOpacity.value =
+ 0.1 + safeSpeed * 0.055 + response * 0.07;
contact.uniforms.uOpacity.value = 0.2 + response * 0.06;
},
};
@@ -354,21 +446,34 @@ function createAuthoredCar(
authoredModel.rotation.y = Math.PI;
group.add(authoredModel);
+ const wheels: Array<{ object: AuthoredObject; radius: number }> = [];
+ const bodyParts: Array<{ object: AuthoredObject; y: number }> = [];
const tailMaterials: AuthoredMaterial[] = [];
const cyanMaterials: AuthoredMaterial[] = [];
authoredModel.traverse((object) => {
- if (object.name.startsWith('evolution-')) object.name = object.name.slice('evolution-'.length);
+ if (object.name.startsWith('evolution-'))
+ object.name = object.name.slice('evolution-'.length);
// Three removes Blender's suffix dot when sanitizing imported node names.
object.name = object.name.replace(/\.?\d{3}$/u, '');
+ if (/^wheel-(front|rear)-(left|right)$/u.test(object.name)) {
+ wheels.push({
+ object,
+ radius: object.name.includes('front') ? 0.42 : 0.46,
+ });
+ }
if (!object.isMesh || !object.geometry || !object.material) return;
+ if (!object.parent?.name.startsWith('wheel-'))
+ bodyParts.push({ object, y: object.position.y });
resources.own('geometry', object.geometry);
- const materials = Array.isArray(object.material) ? object.material : [object.material];
+ const materials = Array.isArray(object.material)
+ ? object.material
+ : [object.material];
for (const material of materials) {
resources.own('material', material);
ownMaterialTextures(resources, material);
if (
- material.isMeshStandardMaterial
- && (object.name === 'car-body' || object.name === 'body-haunches')
+ material.isMeshStandardMaterial &&
+ (object.name === 'car-body' || object.name === 'body-haunches')
) {
material.emissive?.setHex(0x0b2738);
material.emissiveIntensity = 0.26;
@@ -380,7 +485,10 @@ function createAuthoredCar(
if (object.name === 'rear-light-bar' && material.isMeshStandardMaterial) {
tailMaterials.push(material);
}
- if (object.name === 'car-underglow-source' && material.isMeshStandardMaterial) {
+ if (
+ object.name === 'car-underglow-source' &&
+ material.isMeshStandardMaterial
+ ) {
cyanMaterials.push(material);
}
}
@@ -392,16 +500,18 @@ function createAuthoredCar(
};
const underglow = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(1.72, 3.82)),
- resources.own('material', new THREE.ShaderMaterial({
- uniforms: underglowUniforms,
- vertexShader: `
+ resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms: underglowUniforms,
+ vertexShader: `
varying vec2 vGlowUv;
void main() {
vGlowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uColor;
uniform float uOpacity;
varying vec2 vGlowUv;
@@ -413,12 +523,13 @@ function createAuthoredCar(
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- })),
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ ),
);
underglow.name = 'car-underglow';
underglow.rotation.x = -Math.PI / 2;
@@ -454,25 +565,45 @@ function createAuthoredCar(
detail = nextDetail;
setScale(detail);
},
- update(speed, acceleration, elapsedSeconds) {
+ update(speed, acceleration, elapsedSeconds, distance) {
const safeSpeed = clamp01(speed);
const response = clamp01(acceleration);
- const elapsed = Number.isFinite(elapsedSeconds) ? Math.max(0, elapsedSeconds) : 0;
- const deltaSeconds = previousElapsed === null
- ? 1 / 60
- : THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
+ const elapsed = Number.isFinite(elapsedSeconds)
+ ? Math.max(0, elapsedSeconds)
+ : 0;
+ const deltaSeconds =
+ previousElapsed === null
+ ? 1 / 60
+ : THREE.MathUtils.clamp(elapsed - previousElapsed, 0, 0.05);
previousElapsed = elapsed;
const targetSquat = response * 0.04;
- const springAcceleration = (targetSquat - squat) * 34 - squatVelocity * 10;
+ const springAcceleration =
+ (targetSquat - squat) * 34 - squatVelocity * 10;
squatVelocity += springAcceleration * deltaSeconds;
squat += squatVelocity * deltaSeconds;
- const vibration = Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) * (0.0014 + safeSpeed * 0.0022);
- group.position.y = baseY - squat + vibration;
- group.rotation.x = -squat * 0.035;
+ const vibration =
+ Math.sin(elapsed * (7.4 + safeSpeed * 2.2)) *
+ (0.0014 + safeSpeed * 0.0022);
+ group.position.y = baseY + vibration * 0.2;
+ for (const part of bodyParts) {
+ part.object.position.y = part.y - squat * 0.42;
+ part.object.rotation.x = -squat * 0.11;
+ part.object.rotation.z = Math.sin(elapsed * 0.65) * safeSpeed * 0.0015;
+ }
+ if (distance !== undefined && Number.isFinite(distance)) {
+ const scale = detail === 'full' ? 2.78 : 2.32;
+ for (const wheel of wheels)
+ wheel.object.rotation.x =
+ (distance / (wheel.radius * scale)) % (Math.PI * 2);
+ }
+ group.rotation.x = 0;
group.rotation.z = Math.sin(elapsed * 0.8) * safeSpeed * 0.0018;
- for (const material of tailMaterials) material.emissiveIntensity = 2.2 + response * 1.2;
- for (const material of cyanMaterials) material.emissiveIntensity = 2.2 + response * 1.6;
- underglowUniforms.uOpacity.value = 0.09 + safeSpeed * 0.04 + response * 0.075;
+ for (const material of tailMaterials)
+ material.emissiveIntensity = 2.2 + response * 1.2;
+ for (const material of cyanMaterials)
+ material.emissiveIntensity = 2.2 + response * 1.6;
+ underglowUniforms.uOpacity.value =
+ 0.09 + safeSpeed * 0.04 + response * 0.075;
contact.uniforms.uOpacity.value = 0.21 + response * 0.055;
},
};
@@ -487,16 +618,18 @@ function createContactShadow(
const uniforms = {
uOpacity: { value: opacity },
};
- const material = resources.own('material', new THREE.ShaderMaterial({
- uniforms,
- vertexShader: `
+ const material = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: `
varying vec2 vShadowUv;
void main() {
vShadowUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform float uOpacity;
varying vec2 vShadowUv;
void main() {
@@ -507,12 +640,13 @@ function createContactShadow(
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.NormalBlending,
- toneMapped: true,
- }));
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.NormalBlending,
+ toneMapped: true,
+ }),
+ );
const mesh = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(width, depth)),
material,
@@ -535,7 +669,8 @@ function ownMaterialTextures(
'metalnessMap',
'aoMap',
]) {
- const texture = material[key] as { isTexture?: boolean; dispose(): void } | null | undefined;
+ const texture = material[key] as
+ { isTexture?: boolean; dispose(): void } | null | undefined;
if (texture?.isTexture) resources.own('texture', texture);
}
}
@@ -559,24 +694,36 @@ function createHaunchGeometry(side: -1 | 1) {
const outer = 2.62 * side;
const frontOuter = 2.25 * side;
const positions = new Float32Array([
- inner, 0.48, -1.75,
- frontOuter, 0.38, -1.75,
- inner, 0.92, -1.75,
- outer, 0.68, -1.75,
- inner, 0.48, 2.62,
- outer, 0.33, 2.62,
- inner, 0.92, 2.62,
- outer, 0.73, 2.62,
+ inner,
+ 0.48,
+ -1.75,
+ frontOuter,
+ 0.38,
+ -1.75,
+ inner,
+ 0.92,
+ -1.75,
+ outer,
+ 0.68,
+ -1.75,
+ inner,
+ 0.48,
+ 2.62,
+ outer,
+ 0.33,
+ 2.62,
+ inner,
+ 0.92,
+ 2.62,
+ outer,
+ 0.73,
+ 2.62,
]);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex([
- 0, 1, 2, 2, 1, 3,
- 4, 6, 5, 6, 7, 5,
- 0, 4, 1, 1, 4, 5,
- 2, 3, 6, 3, 7, 6,
- 1, 5, 3, 3, 5, 7,
- 0, 2, 4, 2, 6, 4,
+ 0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 4, 1, 1, 4, 5, 2, 3, 6, 3, 7, 6, 1,
+ 5, 3, 3, 5, 7, 0, 2, 4, 2, 6, 4,
]);
geometry.computeVertexNormals();
return geometry;
@@ -584,24 +731,14 @@ function createHaunchGeometry(side: -1 | 1) {
function createCanopyGeometry() {
const positions = new Float32Array([
- -1.18, 0.78, -1.9,
- 1.18, 0.78, -1.9,
- -0.86, 1.82, -1.65,
- 0.86, 1.82, -1.65,
- -1.62, 0.78, 1.42,
- 1.62, 0.78, 1.42,
- -1.18, 1.42, 1.06,
- 1.18, 1.42, 1.06,
+ -1.18, 0.78, -1.9, 1.18, 0.78, -1.9, -0.86, 1.82, -1.65, 0.86, 1.82, -1.65,
+ -1.62, 0.78, 1.42, 1.62, 0.78, 1.42, -1.18, 1.42, 1.06, 1.18, 1.42, 1.06,
]);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setIndex([
- 0, 1, 2, 2, 1, 3,
- 4, 6, 5, 6, 7, 5,
- 0, 2, 4, 2, 6, 4,
- 1, 5, 3, 3, 5, 7,
- 2, 3, 6, 3, 7, 6,
- 0, 4, 1, 1, 4, 5,
+ 0, 1, 2, 2, 1, 3, 4, 6, 5, 6, 7, 5, 0, 2, 4, 2, 6, 4, 1, 5, 3, 3, 5, 7, 2,
+ 3, 6, 3, 7, 6, 0, 4, 1, 1, 4, 5,
]);
geometry.computeVertexNormals();
return geometry;
diff --git a/src/scene/world/composition.ts b/src/scene/world/composition.ts
index 9888598..089caa9 100644
--- a/src/scene/world/composition.ts
+++ b/src/scene/world/composition.ts
@@ -11,13 +11,79 @@ export interface ChapterComposition {
shift: number;
}
-export const CHAPTER_COMPOSITIONS: Readonly> = {
- start: { x: -2.6, y: 6.4, z: 21, targetY: 3.9, targetZ: -90, fov: 56, shift: 0.2 },
- profile: { x: -3.6, y: 7.4, z: 23, targetY: 4.3, targetZ: -108, fov: 59, shift: -0.23 },
- experience: { x: 3.2, y: 8.1, z: 24, targetY: 4.8, targetZ: -120, fov: 61, shift: 0.25 },
- infrastructure: { x: -1.8, y: 9.5, z: 26, targetY: 5.2, targetZ: -128, fov: 64, shift: 0 },
- 'selected-builds': { x: 2.5, y: 7, z: 23, targetY: 4.2, targetZ: -105, fov: 59, shift: 0.04 },
- 'additional-work': { x: -3.2, y: 7.8, z: 24, targetY: 4.7, targetZ: -110, fov: 61, shift: -0.12 },
- toolkit: { x: 0, y: 8.6, z: 25, targetY: 5, targetZ: -120, fov: 62, shift: 0 },
- contact: { x: -2.8, y: 5.8, z: 23, targetY: 3.4, targetZ: -95, fov: 55, shift: 0.24 },
+export const CHAPTER_COMPOSITIONS: Readonly<
+ Record
+> = {
+ start: {
+ x: -5.6,
+ y: 5.6,
+ z: 24,
+ targetY: 3.5,
+ targetZ: -88,
+ fov: 54,
+ shift: 0.14,
+ },
+ profile: {
+ x: 4.5,
+ y: 6.2,
+ z: 25,
+ targetY: 4.3,
+ targetZ: -108,
+ fov: 59,
+ shift: -0.23,
+ },
+ experience: {
+ x: -4.0,
+ y: 6.8,
+ z: 25,
+ targetY: 4.8,
+ targetZ: -120,
+ fov: 61,
+ shift: 0.25,
+ },
+ infrastructure: {
+ x: -1.8,
+ y: 9.5,
+ z: 26,
+ targetY: 5.2,
+ targetZ: -128,
+ fov: 64,
+ shift: 0,
+ },
+ 'selected-builds': {
+ x: 2.5,
+ y: 7,
+ z: 23,
+ targetY: 4.2,
+ targetZ: -105,
+ fov: 59,
+ shift: 0.04,
+ },
+ 'additional-work': {
+ x: -3.2,
+ y: 7.8,
+ z: 24,
+ targetY: 4.7,
+ targetZ: -110,
+ fov: 61,
+ shift: -0.12,
+ },
+ toolkit: {
+ x: 0,
+ y: 8.6,
+ z: 25,
+ targetY: 5,
+ targetZ: -120,
+ fov: 62,
+ shift: 0,
+ },
+ contact: {
+ x: -4.8,
+ y: 4.8,
+ z: 26,
+ targetY: 3.4,
+ targetZ: -95,
+ fov: 55,
+ shift: 0.24,
+ },
};
diff --git a/src/scene/world/environment.ts b/src/scene/world/environment.ts
index a19b3ef..a40dea9 100644
--- a/src/scene/world/environment.ts
+++ b/src/scene/world/environment.ts
@@ -3,6 +3,9 @@ import type { JourneySnapshot } from '@/journey/types';
import type { SceneResourceBag } from '@/scene/resources';
import type { SceneBuildContext, SceneGeneration } from '@/scene/types';
import { renderQualityFor } from '@/scene/render-quality';
+import { createHorizonAir } from '@/scene/world/horizon-air';
+import { createExpressway } from '@/scene/world/expressway';
+import { createSignStage } from '@/scene/world/sign-stage';
import { createRoad } from '@/scene/world/road';
import { createTerrain } from '@/scene/world/terrain';
import { createSun } from '@/scene/world/sun';
@@ -11,10 +14,7 @@ import { createSign, prepareSignTypography } from '@/scene/world/sign';
import { createAtmosphere } from '@/scene/world/atmosphere';
import { createPostProcessor } from '@/scene/post-processing';
import { createDriveDynamics } from '@/scene/drive-dynamics';
-import {
- HORIZON_FOG_END,
- HORIZON_FOG_START,
-} from '@/scene/world/metrics';
+import { HORIZON_FOG_END, HORIZON_FOG_START } from '@/scene/world/metrics';
import { CHAPTER_COMPOSITIONS } from '@/scene/world/composition';
import { createCoachworkEnvironment } from '@/scene/world/coachwork-light';
import { createTravelDistance } from '@/scene/world/travel-distance';
@@ -26,12 +26,14 @@ import {
import { isTestFault, recordTestFaultEvent } from '@/testing/fault-hooks';
declare const __PORTFOLIO_TEST_BUILD__: boolean;
-const TEST_BUILD = typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
+const TEST_BUILD =
+ typeof __PORTFOLIO_TEST_BUILD__ !== 'undefined' && __PORTFOLIO_TEST_BUILD__;
export function createChaseCamera(aspect = 1) {
- const camera = new THREE.PerspectiveCamera(56, aspect, 0.1, 1_200);
- camera.position.set(-2.6, 6.4, 21);
- camera.lookAt(0, 3.9, -90);
+ const c = CHAPTER_COMPOSITIONS.start;
+ const camera = new THREE.PerspectiveCamera(c.fov, aspect, 0.1, 1_200);
+ camera.position.set(c.x, c.y, c.z);
+ camera.lookAt(0, c.targetY, c.targetZ);
return camera;
}
@@ -54,17 +56,27 @@ export async function createProceduralSceneGeneration(
recordTestFaultEvent('car-rejected');
throw new Error('Injected car failure');
}
- if (context.signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
+ if (context.signal.aborted)
+ throw new DOMException('Scene build aborted', 'AbortError');
const canvas = context.canvas as HTMLCanvasElement;
- if (typeof canvas.getContext !== 'function') throw new Error('A canvas element is required');
- const mobileOrTablet = Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
- const quality = renderQualityFor(context.quality, globalThis.devicePixelRatio ?? 1, mobileOrTablet);
- const renderer = context.resources.own('renderer', new THREE.WebGLRenderer({
- canvas,
- alpha: false,
- antialias: context.quality !== 'low',
- powerPreference: 'high-performance',
- }));
+ if (typeof canvas.getContext !== 'function')
+ throw new Error('A canvas element is required');
+ const mobileOrTablet =
+ Math.max(canvas.clientWidth || 0, canvas.clientHeight || 0) < 1280;
+ const quality = renderQualityFor(
+ context.quality,
+ globalThis.devicePixelRatio ?? 1,
+ mobileOrTablet,
+ );
+ const renderer = context.resources.own(
+ 'renderer',
+ new THREE.WebGLRenderer({
+ canvas,
+ alpha: false,
+ antialias: context.quality !== 'low',
+ powerPreference: 'high-performance',
+ }),
+ );
renderer.setPixelRatio(quality.pixelRatio);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
@@ -80,6 +92,8 @@ export async function createProceduralSceneGeneration(
scene.background = background;
scene.fog = createWorldFog(currentPalette.fog.getHex());
const camera = createChaseCamera();
+ camera.name = 'journey-camera';
+ scene.add(camera);
const ambient = new THREE.HemisphereLight(0x6aa9c5, 0x100812, 1.25);
scene.add(ambient);
@@ -97,7 +111,10 @@ export async function createProceduralSceneGeneration(
const road = createRoad(context.resources, quality);
const terrain = createTerrain(context.resources, quality);
+ const expressway = createExpressway(context.resources, quality);
+ const signStage = createSignStage(context.resources);
const sun = createSun(context.resources);
+ const horizonAir = createHorizonAir(context.resources);
const authoredCar = await loadGrandTourer(context.signal);
const car = createCar(context.resources, authoredCar);
car.setDetail(mobileOrTablet ? 'reduced' : 'full');
@@ -110,8 +127,10 @@ export async function createProceduralSceneGeneration(
scene.add(
atmosphere.group,
road.group,
+ expressway.group,
terrain.group,
sun.group,
+ horizonAir.group,
car.group,
sign.group,
);
@@ -127,7 +146,7 @@ export async function createProceduralSceneGeneration(
targetX: 0,
targetY: 3.9,
targetZ: -90,
- shift: 0.2,
+ shift: CHAPTER_COMPOSITIONS.start.shift,
verticalShift: 0,
fov: camera.fov,
roll: 0,
@@ -138,6 +157,7 @@ export async function createProceduralSceneGeneration(
let lastNow: number | null = null;
let disposed = false;
let renderedFrames = 0;
+ let atmosphericTime = 0;
let firstFrameSuppressionRecorded = false;
const resize = (): void => {
@@ -151,9 +171,11 @@ export async function createProceduralSceneGeneration(
camera.updateProjectionMatrix();
if (lastNow === null) {
const wideComposition = camera.aspect > 1.18;
- cameraState.x = wideComposition ? -2.6 : 0;
+ cameraState.x = wideComposition ? CHAPTER_COMPOSITIONS.start.x : 0;
cameraState.targetX = 0;
- cameraState.shift = wideComposition ? 0.2 : 0;
+ cameraState.shift = wideComposition
+ ? CHAPTER_COMPOSITIONS.start.shift
+ : 0;
cameraState.verticalShift = wideComposition ? 0 : 0.16;
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
camera.lookAt(
@@ -166,18 +188,24 @@ export async function createProceduralSceneGeneration(
car.group.visible = height >= 340;
sign.setViewportSize(width, height);
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') {
- window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(context.resources.gpuBudgetSnapshot({
- tier: context.quality,
- defaultFramebuffer: {
- width: Math.max(1, canvas.width),
- height: Math.max(1, canvas.height),
- bytesPerPixel: 4,
- attachments: 2,
- samples: context.quality === 'low' ? 1 : 4,
- },
- }));
+ window.__PORTFOLIO_PERFORMANCE__?.recordRuntimeAllocation(
+ context.resources.gpuBudgetSnapshot({
+ tier: context.quality,
+ defaultFramebuffer: {
+ width: Math.max(1, canvas.width),
+ height: Math.max(1, canvas.height),
+ bytesPerPixel: 4,
+ attachments: 2,
+ samples: context.quality === 'low' ? 1 : 4,
+ },
+ }),
+ );
}
};
@@ -197,15 +225,18 @@ export async function createProceduralSceneGeneration(
return false;
}
if (
- TEST_BUILD
- && isTestFault('F3-post-first-frame-exception')
- && renderedFrames > 0
+ TEST_BUILD &&
+ isTestFault('F3-post-first-frame-exception') &&
+ renderedFrames > 0
) {
recordTestFaultEvent('post-first-frame-exception');
throw new Error('Injected post-first-frame exception');
}
resize();
- const deltaSeconds = lastNow === null ? 0 : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
+ const deltaSeconds =
+ lastNow === null
+ ? 0
+ : Math.min(0.1, Math.max(0, (now - lastNow) / 1000));
lastNow = now;
dynamics.update(snapshot.acceleration, snapshot.direction, deltaSeconds);
const response = dynamics.snapshot();
@@ -221,7 +252,19 @@ export async function createProceduralSceneGeneration(
0.18,
1,
);
- road.update(driveDistance, visualSpeed, response.roadEnergy, currentPalette);
+ road.update(
+ driveDistance,
+ visualSpeed,
+ response.roadEnergy,
+ currentPalette,
+ );
+ expressway.update(
+ driveDistance,
+ response.roadEnergy,
+ currentPalette.warmth,
+ snapshot.activeSectionId,
+ deltaSeconds,
+ );
terrain.update(
driveDistance,
visualSpeed,
@@ -229,14 +272,21 @@ export async function createProceduralSceneGeneration(
snapshot.routeProgress,
currentPalette,
);
- const elapsedSeconds = now / 1000;
+ atmosphericTime += deltaSeconds;
+ const elapsedSeconds = atmosphericTime;
+ horizonAir.update(currentPalette, elapsedSeconds);
sun.update(
currentPalette,
snapshot.routeProgress,
elapsedSeconds,
response.bloomEnergy,
);
- car.update(visualSpeed, response.suspensionEnergy, elapsedSeconds);
+ car.update(
+ visualSpeed,
+ response.suspensionEnergy,
+ elapsedSeconds,
+ driveDistance,
+ );
sign.update(snapshot.sign, now);
atmosphere.update(
driveDistance,
@@ -246,7 +296,9 @@ export async function createProceduralSceneGeneration(
);
background.copy(currentPalette.sky);
scene.fog.color.copy(currentPalette.fog);
- ambient.color.copy(currentPalette.haze).lerp(currentPalette.terrainHigh, 0.26);
+ ambient.color
+ .copy(currentPalette.haze)
+ .lerp(currentPalette.terrainHigh, 0.26);
ambient.groundColor.copy(currentPalette.fog);
ambient.intensity = 1.05 + currentPalette.warmth * 0.18;
carKeyLight.color.setHex(0xb9e3ef);
@@ -255,16 +307,63 @@ export async function createProceduralSceneGeneration(
carFillLight.intensity = 12.5 + currentPalette.warmth * 2.8;
rearLight.intensity = 4.2 + response.suspensionEnergy * 1.4;
const wideComposition = camera.aspect > 1.18;
- const composition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
+ const baseComposition = CHAPTER_COMPOSITIONS[snapshot.activeSectionId];
+ const upcoming =
+ snapshot.sign && snapshot.sign.phase !== 'depart'
+ ? CHAPTER_COMPOSITIONS[snapshot.sign.targetId]
+ : baseComposition;
+ const anticipation =
+ snapshot.sign?.phase === 'approach'
+ ? Math.min(0.55, snapshot.sign.progress * 0.55)
+ : snapshot.sign?.phase === 'dwell'
+ ? 0.55
+ : 0;
+ const composition = {
+ x: THREE.MathUtils.lerp(baseComposition.x, upcoming.x, anticipation),
+ y: THREE.MathUtils.lerp(baseComposition.y, upcoming.y, anticipation),
+ z: THREE.MathUtils.lerp(baseComposition.z, upcoming.z, anticipation),
+ targetY: THREE.MathUtils.lerp(
+ baseComposition.targetY,
+ upcoming.targetY,
+ anticipation,
+ ),
+ targetZ: THREE.MathUtils.lerp(
+ baseComposition.targetZ,
+ upcoming.targetZ,
+ anticipation,
+ ),
+ fov: THREE.MathUtils.lerp(
+ baseComposition.fov,
+ upcoming.fov,
+ anticipation,
+ ),
+ shift: THREE.MathUtils.lerp(
+ baseComposition.shift,
+ upcoming.shift,
+ anticipation,
+ ),
+ };
// The document stays native. Only the rendered lens eases to the chapter's safe zone.
const cameraX = wideComposition ? composition.x : 0;
const targetX = 0;
- const targetShift = wideComposition ? composition.shift : 0;
- const targetVerticalShift = wideComposition ? 0 : (snapshot.activeSectionId === 'start' ? 0.16 : 0.07);
- const roadVibration = Math.sin(driveDistance * 0.105) * (
- 0.012 + response.roadEnergy * 0.024
- );
- const cameraMix = 1 - Math.exp(-deltaSeconds / 0.85);
+ const targetShift =
+ height < 560 && width >= 768
+ ? 0.22
+ : wideComposition
+ ? composition.shift
+ : 0;
+ const targetVerticalShift = wideComposition
+ ? 0
+ : snapshot.activeSectionId === 'start'
+ ? 0.16
+ : 0.07;
+ const roadVibration =
+ Math.sin(driveDistance * 0.105) * (0.012 + response.roadEnergy * 0.024);
+ const cameraMix =
+ 1 -
+ Math.exp(
+ -deltaSeconds / (snapshot.activeSectionId === 'contact' ? 1.5 : 1.05),
+ );
cameraState.x = THREE.MathUtils.lerp(
cameraState.x,
cameraX - response.directionBias * 0.16,
@@ -277,7 +376,7 @@ export async function createProceduralSceneGeneration(
);
cameraState.z = THREE.MathUtils.lerp(
cameraState.z,
- (wideComposition ? composition.z : 29) - response.cameraEnergy * 1.2,
+ (wideComposition ? composition.z : 31) + response.cameraEnergy * 0.9,
cameraMix,
);
cameraState.targetX = THREE.MathUtils.lerp(
@@ -297,7 +396,7 @@ export async function createProceduralSceneGeneration(
);
cameraState.fov = THREE.MathUtils.lerp(
cameraState.fov,
- (wideComposition ? composition.fov : 62) + response.cameraEnergy * 7,
+ (wideComposition ? composition.fov : 61) + response.cameraEnergy * 5.5,
cameraMix,
);
cameraState.roll = THREE.MathUtils.lerp(
@@ -305,30 +404,53 @@ export async function createProceduralSceneGeneration(
-response.directionBias * 0.003 + roadVibration * 0.018,
cameraMix,
);
- cameraState.shift = THREE.MathUtils.lerp(cameraState.shift, targetShift, cameraMix);
- cameraState.verticalShift = THREE.MathUtils.lerp(cameraState.verticalShift, targetVerticalShift, cameraMix);
- camera.setViewOffset(width, height, -width * cameraState.shift, -height * cameraState.verticalShift, width, height);
+ cameraState.shift = THREE.MathUtils.lerp(
+ cameraState.shift,
+ targetShift,
+ cameraMix,
+ );
+ cameraState.verticalShift = THREE.MathUtils.lerp(
+ cameraState.verticalShift,
+ targetVerticalShift,
+ cameraMix,
+ );
+ camera.setViewOffset(
+ width,
+ height,
+ -width * cameraState.shift,
+ -height * cameraState.verticalShift,
+ width,
+ height,
+ );
camera.position.set(cameraState.x, cameraState.y, cameraState.z);
- camera.lookAt(cameraState.targetX, cameraState.targetY, cameraState.targetZ);
+ camera.lookAt(
+ cameraState.targetX,
+ cameraState.targetY,
+ cameraState.targetZ,
+ );
camera.rotation.z += cameraState.roll;
if (Math.abs(camera.fov - cameraState.fov) > 0.001) {
camera.fov = cameraState.fov;
camera.updateProjectionMatrix();
}
+ const stage = signStage.update(width, height);
+ sign.compose(camera, width, height, stage.navigationBottom, now);
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(),
- viewport: { width, height },
- },
- }));
+ window.dispatchEvent(
+ new CustomEvent('portfolio-sign-frame', {
+ detail: {
+ now,
+ ...snapshot.sign,
+ activeSectionId: snapshot.activeSectionId,
+ direction: snapshot.direction,
+ ...presentation,
+ rendererPixelRatio: renderer.getPixelRatio(),
+ viewport: { width, height },
+ },
+ }),
+ );
}
}
if (postProcessor) {
@@ -365,7 +487,19 @@ function dampPalette(
current.sun.lerp(target.sun, mix);
current.haze.lerp(target.haze, mix);
current.warmth = THREE.MathUtils.lerp(current.warmth, target.warmth, mix);
- current.terrainEnergy = THREE.MathUtils.lerp(current.terrainEnergy, target.terrainEnergy, mix);
- current.starVisibility = THREE.MathUtils.lerp(current.starVisibility, target.starVisibility, mix);
- current.streakEnergy = THREE.MathUtils.lerp(current.streakEnergy, target.streakEnergy, mix);
+ current.terrainEnergy = THREE.MathUtils.lerp(
+ current.terrainEnergy,
+ target.terrainEnergy,
+ mix,
+ );
+ current.starVisibility = THREE.MathUtils.lerp(
+ current.starVisibility,
+ target.starVisibility,
+ mix,
+ );
+ current.streakEnergy = THREE.MathUtils.lerp(
+ current.streakEnergy,
+ target.streakEnergy,
+ mix,
+ );
}
diff --git a/src/scene/world/expressway.ts b/src/scene/world/expressway.ts
new file mode 100644
index 0000000..625fb32
--- /dev/null
+++ b/src/scene/world/expressway.ts
@@ -0,0 +1,212 @@
+import * as THREE from 'three';
+import type { SceneResourceBag } from '@/scene/resources';
+import type { RenderQualitySettings } from '@/scene/render-quality';
+import type { SectionId } from '@/content/types';
+import { WORLD_DEPTH, WORLD_FAR_Z } from './metrics';
+
+/** A bounded kit of physical roadside objects. Instance origins wrap behind the
+ * camera; the entire object moves together, so long beams never tear at a seam. */
+export function createExpressway(
+ resources: SceneResourceBag,
+ quality: RenderQualitySettings,
+) {
+ const group = new THREE.Group();
+ group.name = 'expressway-architecture';
+ const uniforms = {
+ uDistance: { value: 0 },
+ uEnergy: { value: 0 },
+ uArchitecture: { value: 0 },
+ uWarmth: { value: 0 },
+ };
+ const vertexShader = `
+ uniform float uDistance;
+ uniform float uArchitecture;
+ varying vec3 vNormal;
+ varying vec3 vWorld;
+ varying float vDepth;
+ void main() {
+ vec4 origin = instanceMatrix * vec4(0.0, 0.0, 0.0, 1.0);
+ vec4 p = instanceMatrix * vec4(position, 1.0);
+ p.z += mod(origin.z + uDistance - ${WORLD_FAR_Z.toFixed(1)}, ${WORLD_DEPTH.toFixed(1)}) + ${WORLD_FAR_Z.toFixed(1)} - origin.z;
+ p.y *= 1.0 + uArchitecture * 0.22;
+ vNormal = normalize(mat3(instanceMatrix) * normal);
+ vWorld = p.xyz;
+ vec4 view = modelViewMatrix * p;
+ vDepth = -view.z;
+ gl_Position = projectionMatrix * view;
+ }
+ `;
+ function material(luminous: boolean, coral = false) {
+ return resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader,
+ fragmentShader: `
+ uniform float uEnergy;
+ uniform float uWarmth;
+ varying vec3 vNormal;
+ varying vec3 vWorld;
+ varying float vDepth;
+ void main() {
+ float farFade = 1.0 - smoothstep(380.0, 660.0, vDepth);
+ float nearFade = smoothstep(0.0, 9.0, vDepth);
+ vec3 cyan = vec3(0.15, 0.69, 0.76);
+ vec3 coral = vec3(1.0, 0.18, 0.095);
+ float facing = max(0.0, dot(normalize(vNormal), normalize(vec3(-0.5, 0.9, 0.6))));
+ vec3 color = ${luminous ? `mix(${coral ? 'coral' : 'cyan'}, coral, uWarmth * 0.12) * (1.3 + uEnergy * 0.5)` : 'vec3(0.015, 0.027, 0.044) + vec3(0.055, 0.10, 0.135) * pow(facing, 2.0)'};
+ ${!luminous ? 'color += cyan * pow(max(0.0, vNormal.x), 2.0) * 0.028;' : ''}
+ if(farFade * nearFade < 0.02) discard;
+ gl_FragColor = vec4(color, farFade * nearFade);
+ #include
+ #include
+ }
+ `,
+ transparent: true,
+ depthWrite: !luminous,
+ depthTest: true,
+ blending: luminous ? THREE.AdditiveBlending : THREE.NormalBlending,
+ }),
+ );
+ }
+ const graphite = material(false);
+ const cyan = material(true);
+ const coral = material(true, true);
+ const box = resources.own('geometry', new THREE.BoxGeometry(1, 1, 1));
+ const dummy = new THREE.Object3D();
+ type Part = {
+ x: number;
+ y: number;
+ z: number;
+ sx: number;
+ sy: number;
+ sz: number;
+ rz?: number;
+ };
+ function batch(
+ name: string,
+ parts: Part[],
+ surface: ReturnType,
+ ) {
+ const mesh = resources.own(
+ 'other',
+ new THREE.InstancedMesh(box, surface, parts.length),
+ );
+ mesh.name = name;
+ mesh.frustumCulled = false;
+ parts.forEach((part, i) => {
+ dummy.position.set(part.x, part.y, part.z);
+ dummy.scale.set(part.sx, part.sy, part.sz);
+ dummy.rotation.set(0, 0, part.rz ?? 0);
+ dummy.updateMatrix();
+ mesh.setMatrixAt(i, dummy.matrix);
+ });
+ mesh.instanceMatrix.needsUpdate = true;
+ group.add(mesh);
+ }
+ const posts: Part[] = [],
+ reflectors: Part[] = [],
+ rails: Part[] = [];
+ // Full density retains a recognisable rhythm even at the lowest quality.
+ const pairs = quality.postEffects ? 56 : 40;
+ for (let i = 0; i < pairs; i++)
+ for (const side of [-1, 1]) {
+ const z = 30 - (i * WORLD_DEPTH) / pairs;
+ posts.push({ x: side * 9.5, y: 0.63, z, sx: 0.22, sy: 1.26, sz: 0.32 });
+ reflectors.push({
+ x: side * 9.48,
+ y: 1.1,
+ z: z + 0.18,
+ sx: 0.14,
+ sy: 0.22,
+ sz: 0.03,
+ });
+ rails.push({
+ x: side * 9.5,
+ y: 0.56,
+ z,
+ sx: 0.16,
+ sy: 0.18,
+ sz: WORLD_DEPTH / pairs + 0.1,
+ });
+ }
+ batch('barrier-posts', posts, graphite);
+ batch('barrier-reflectors', reflectors, coral);
+ batch('continuous-low-rails', rails, graphite);
+ const fins: Part[] = [],
+ lights: Part[] = [],
+ feet: Part[] = [];
+ for (let i = 0; i < 8; i++)
+ for (const side of [-1, 1]) {
+ const z = -24 - (i * WORLD_DEPTH) / 8;
+ // The canted cap is the repeated silhouette: road furniture, not a tunnel.
+ fins.push({ x: side * 12.6, y: 5.0, z, sx: 0.38, sy: 10, sz: 0.64 });
+ fins.push({
+ x: side * 10.45,
+ y: 10.55,
+ z,
+ sx: 0.32,
+ sy: 4.6,
+ sz: 0.64,
+ rz: side * 1.31,
+ });
+ lights.push({
+ x: side * 12.36,
+ y: 5.9,
+ z: z + 0.34,
+ sx: 0.075,
+ sy: 7.5,
+ sz: 0.035,
+ });
+ lights.push({
+ x: side * 10.4,
+ y: 10.4,
+ z: z + 0.34,
+ sx: 0.07,
+ sy: 4.35,
+ sz: 0.04,
+ rz: side * 1.31,
+ });
+ feet.push({ x: side * 12.6, y: 0.24, z, sx: 1.1, sy: 0.48, sz: 1.5 });
+ }
+ batch('swept-signal-pylons', fins, graphite);
+ batch('recessed-pylon-lights', lights, cyan);
+ batch('pylon-foundations', feet, graphite);
+ // Far roadside monolith pairs give the waves an occasional architectural edge.
+ const landmarks: Part[] = [];
+ for (let i = 0; i < 6; i++)
+ for (const side of [-1, 1]) {
+ const z = -110 - (i * WORLD_DEPTH) / 6;
+ for (let j = 0; j < 3; j++) {
+ landmarks.push({
+ x: side * (34 + j * 4.5),
+ y: (15 + j * 6) / 2,
+ z: z - j * 3,
+ sx: 1.2,
+ sy: 15 + j * 6,
+ sz: 2.6,
+ rz: side * 0.1,
+ });
+ }
+ }
+ batch('ridge-observatories', landmarks, graphite);
+ let architecture = 0;
+ return {
+ group,
+ update(
+ distance: number,
+ energy: number,
+ warmth: number,
+ section: SectionId,
+ dt: number,
+ ) {
+ const target =
+ section === 'infrastructure' ? 1 : section === 'toolkit' ? 0.65 : 0;
+ architecture += (target - architecture) * (1 - Math.exp(-dt * 0.7));
+ uniforms.uDistance.value = distance;
+ uniforms.uEnergy.value = energy;
+ uniforms.uArchitecture.value = architecture;
+ uniforms.uWarmth.value = warmth;
+ },
+ };
+}
diff --git a/src/scene/world/horizon-air.ts b/src/scene/world/horizon-air.ts
new file mode 100644
index 0000000..62a0007
--- /dev/null
+++ b/src/scene/world/horizon-air.ts
@@ -0,0 +1,60 @@
+import * as THREE from 'three';
+import type { SceneResourceBag } from '@/scene/resources';
+import type { MutableChapterPalette } from './palette';
+
+/** Thin, spatially separated air masses. No screen-sized render targets. */
+export function createHorizonAir(resources: SceneResourceBag) {
+ const group = new THREE.Group();
+ group.name = 'horizon-air-masses';
+ const uniforms = {
+ uTime: { value: 0 },
+ uWarmth: { value: 0 },
+ uColor: { value: new THREE.Color(0xc5646b) },
+ };
+ const material = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: `
+ varying vec2 vUv;
+ void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}
+ `,
+ fragmentShader: `
+ uniform float uTime;
+ uniform float uWarmth;
+ uniform vec3 uColor;
+ varying vec2 vUv;
+ void main(){
+ float wave=sin(vUv.x*12.0+uTime*0.018)*0.055+sin(vUv.x*31.0-uTime*0.026)*0.018;
+ float ribbon=exp(-pow((vUv.y-0.45-wave)*5.6,2.0));
+ float thin=exp(-pow((vUv.y-0.51-wave)*37.0,2.0));
+ float horizontal=pow(max(0.0,sin(vUv.x*3.14159)),2.0);
+ vec3 color=mix(uColor,vec3(0.21,0.49,0.59),vUv.y*0.25);
+ gl_FragColor=vec4(color,(ribbon*0.12+thin*0.026)*horizontal*(0.6+uWarmth*0.4));
+ #include
+ #include
+ }
+ `,
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ }),
+ );
+ const geometry = resources.own('geometry', new THREE.PlaneGeometry(1450, 95));
+ for (let i = 0; i < 3; i++) {
+ const mesh = new THREE.Mesh(geometry, material);
+ mesh.name = `stratified-air-${i + 1}`;
+ mesh.position.set((i - 1) * 85, 15 + i * 11, -500 - i * 70);
+ mesh.scale.set(1 - i * 0.12, 1 - i * 0.18, 1);
+ group.add(mesh);
+ }
+ return {
+ group,
+ update(palette: Readonly, time: number) {
+ uniforms.uTime.value = time;
+ uniforms.uWarmth.value = palette.warmth;
+ uniforms.uColor.value.copy(palette.sun);
+ },
+ };
+}
diff --git a/src/scene/world/road.ts b/src/scene/world/road.ts
index e238b71..fe8b08b 100644
--- a/src/scene/world/road.ts
+++ b/src/scene/world/road.ts
@@ -48,15 +48,17 @@ export function createRoad(
uSpeed: { value: 0 },
uAcceleration: { value: 0 },
uAsphalt: { value: new THREE.Color(0x03070f) },
- uAsphaltLift: { value: new THREE.Color(0x0b1927) },
+ uAsphaltLift: { value: new THREE.Color(0x162535) },
uEdgeColor: { value: new THREE.Color(0x64d9e7) },
uLaneColor: { value: new THREE.Color(0xf1a35e) },
uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
};
- const material = resources.own('material', new THREE.ShaderMaterial({
- uniforms,
- vertexShader: `
+ const material = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: `
varying vec2 vRoadUv;
varying float vRoadX;
varying float vViewDistance;
@@ -69,11 +71,11 @@ export function createRoad(
vWorldZ = position.z;
vec4 viewPosition = modelViewMatrix * vec4(position, 1.0);
vViewDistance = -viewPosition.z;
- vNearFade = smoothstep(4.0, 18.0, vViewDistance);
+ vNearFade = 0.7 + 0.3 * smoothstep(0.0, 12.0, vViewDistance);
gl_Position = projectionMatrix * viewPosition;
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform float uDistance;
uniform float uSpeed;
uniform float uAcceleration;
@@ -115,7 +117,7 @@ export function createRoad(
* (1.0 - smoothstep(7.46, 7.62, roadX));
float outerEdge = smoothstep(7.86, 7.98, roadX)
* (1.0 - smoothstep(8.06, 8.18, roadX));
- float laneWidth = 1.0 - smoothstep(0.07, 0.15, roadX);
+ float laneWidth = 1.0 - smoothstep(0.035, 0.07, abs(roadX - 0.17));
float laneCycle = fract(travelZ / 18.0);
float laneDash = smoothstep(0.05, 0.1, laneCycle)
* (1.0 - smoothstep(0.48, 0.56, laneCycle));
@@ -140,6 +142,37 @@ export function createRoad(
color += uHazeColor * shoulderSeam * (0.035 + aggregate * 0.025);
color += uLaneColor * centerLane * (0.86 + uSpeed * 0.18);
+ // Satin moisture breaks light into longitudinal streaks. All masks live
+ // on the asphalt plane and follow the same world-space travel as marks.
+ float fine = valueNoise(vec2(vRoadX * 52.0, travelZ * 4.0));
+ float streak = valueNoise(vec2(vRoadX * 16.0, travelZ * 0.19));
+ float film = mix(0.36, 1.0, streak) * (0.74 + 0.26 * fine);
+ float grazing = smoothstep(8.0, 120.0, vViewDistance);
+ color += vec3(0.027, 0.047, 0.063) * film * asphaltMask * (0.35 + grazing * 0.65);
+ float channel = exp(-pow((roadX - 7.98) * 2.4, 2.0));
+ color += uEdgeColor * channel * (0.06 + 0.10 * film);
+ float shoulderRibs = step(0.77, fract(travelZ / 1.4))
+ * smoothstep(8.28, 8.36, roadX) * (1.0 - smoothstep(8.85, 9.02, roadX));
+ color += vec3(0.065, 0.10, 0.12) * shoulderRibs;
+ float joint = 1.0 - smoothstep(0.02, 0.075, abs(mod(travelZ + 36.0, 72.0) - 36.0));
+ color *= 1.0 - joint * 0.5;
+ float studs = (1.0 - smoothstep(0.07, 0.14, abs(roadX - 7.53)))
+ * (1.0 - smoothstep(0.10, 0.23, abs(mod(travelZ, 12.0) - 6.0)));
+ color += uLaneColor * studs * 0.7;
+
+ // Rear lamp footprint: grows towards the camera from the actual rear axle.
+ float behindCar = smoothstep(7.0, 8.8, vWorldZ);
+ float tailLength = exp(-max(0.0, vWorldZ - 8.2) * 0.13);
+ float tailWidth = exp(-pow(vRoadX / (2.25 + max(0.0, vWorldZ-8.0)*0.035), 6.0));
+ float lampSegments = 0.60 + 0.40 * pow(sin(vRoadX * 8.7), 2.0);
+ color += vec3(1.0, 0.035, 0.055) * behindCar * tailLength * tailWidth
+ * film * lampSegments * (0.34 + uAcceleration * 0.14);
+ float cyanReflection = exp(-pow((roadX-2.7) * 6.0, 2.0));
+ color += uEdgeColor * behindCar * tailLength * cyanReflection * film * 0.4;
+ float sunReflection = exp(-pow(vRoadX / (0.55 + vViewDistance * 0.011), 2.0))
+ * smoothstep(36.0, 180.0, vViewDistance);
+ color += vec3(0.8, 0.15, 0.08) * sunReflection * film * 0.17;
+
float horizonPickup = smoothstep(280.0, 620.0, vViewDistance)
* (1.0 - smoothstep(610.0, ${HORIZON_FOG_END.toFixed(1)}, vViewDistance))
* exp(-roadX * 0.28);
@@ -153,10 +186,11 @@ export function createRoad(
#include
}
`,
- depthWrite: true,
- depthTest: true,
- toneMapped: true,
- }));
+ depthWrite: true,
+ depthTest: true,
+ toneMapped: true,
+ }),
+ );
const surface = new THREE.Mesh(geometry, material);
surface.name = 'road-surface';
@@ -166,7 +200,8 @@ export function createRoad(
return {
group,
update(distance, speed, acceleration, palette) {
- uniforms.uDistance.value = ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
+ uniforms.uDistance.value =
+ ((finiteOrZero(distance) % FLOW_PERIOD) + FLOW_PERIOD) % FLOW_PERIOD;
uniforms.uSpeed.value = clamp01(speed);
uniforms.uAcceleration.value = clamp01(acceleration);
if (palette) {
diff --git a/src/scene/world/sign-stage.ts b/src/scene/world/sign-stage.ts
new file mode 100644
index 0000000..1e2aa15
--- /dev/null
+++ b/src/scene/world/sign-stage.ts
@@ -0,0 +1,82 @@
+import type { SceneResourceBag } from '@/scene/resources';
+
+const stageOwners = new WeakMap();
+
+/** Reserve real negative space in the document, below the navigation. The
+ * canvas keeps its original stacking order. Content scrolls through this edge
+ * exactly as it does through the top of the viewport; no content is removed. */
+export function createSignStage(resources: SceneResourceBag) {
+ const main = document.querySelector('.portfolio-main');
+ const nav = document.querySelector('.site-navigation');
+ const root = document.documentElement;
+ let dirty = true;
+ let cutoff = 0;
+ let stageBottom = 176;
+ let navigationBottom = 66;
+ const token = {};
+ const mark = () => {
+ dirty = true;
+ };
+ resources.listen(window, 'scroll', mark, { passive: true });
+ resources.listen(window, 'resize', mark, { passive: true });
+ resources.listen(document, 'focusin', (event) => {
+ if (!(event.target instanceof HTMLElement) || !main?.contains(event.target))
+ return;
+ if (event.target.matches('h1,h2,main')) return;
+ const rect = event.target.getBoundingClientRect();
+ const top = window.innerHeight < 560 ? navigationBottom + 10 : stageBottom;
+ const bottom =
+ window.innerHeight < 560 && window.innerWidth < 768
+ ? window.innerHeight - 164
+ : window.innerHeight - 74;
+ if (rect.top < top)
+ window.scrollBy({ top: rect.top - top - 12, behavior: 'instant' });
+ else if (rect.bottom > bottom)
+ window.scrollBy({ top: rect.bottom - bottom + 12, behavior: 'instant' });
+ });
+ const observer = resources.observe(new ResizeObserver(mark));
+ if (main) observer.observe(main);
+ if (nav) observer.observe(nav);
+ resources.own('other', {
+ dispose() {
+ if (!main || stageOwners.get(main) !== token) return;
+ stageOwners.delete(main);
+ main?.style.removeProperty('clip-path');
+ root.style.removeProperty('--sign-stage-bottom');
+ delete root.dataset.expressway;
+ },
+ });
+ return {
+ update(width: number, height: number) {
+ if (dirty && main) {
+ const rect = main.getBoundingClientRect();
+ navigationBottom = nav?.getBoundingClientRect().bottom ?? 66;
+ const faceWidth = Math.min(
+ width - 44,
+ width < 900 ? 288 : 356,
+ height < 650 ? 250 : 356,
+ );
+ stageBottom = navigationBottom + 24 + faceWidth / 3.18;
+ // Gradually establish the strip while the opening leaves the viewport.
+ const reveal = Math.min(
+ 1,
+ Math.max(0, window.scrollY / Math.max(240, height * 0.58)),
+ );
+ const short = height < 560;
+ const topEdge = short ? navigationBottom + 10 : stageBottom;
+ cutoff = Math.max(0, topEdge * reveal - rect.top);
+ const bottomCutoff =
+ short && width < 768 ? Math.max(0, rect.bottom - (height - 164)) : 0;
+ main.style.clipPath = `inset(${cutoff.toFixed(2)}px 0 ${bottomCutoff.toFixed(2)}px 0)`;
+ root.style.setProperty(
+ '--sign-stage-bottom',
+ `${topEdge.toFixed(2)}px`,
+ );
+ root.dataset.expressway = 'live';
+ stageOwners.set(main, token);
+ dirty = false;
+ }
+ return { navigationBottom, stageBottom };
+ },
+ };
+}
diff --git a/src/scene/world/sign.ts b/src/scene/world/sign.ts
index 90bfca6..7031007 100644
--- a/src/scene/world/sign.ts
+++ b/src/scene/world/sign.ts
@@ -2,7 +2,6 @@ import * as THREE from 'three';
import type { SectionId } from '@/content/types';
import type { SignSnapshot } from '@/journey/types';
import {
- SIGN_LIFECYCLE,
signRouteFor,
signViewportProfileFor,
type SignCorridor,
@@ -11,106 +10,15 @@ import {
} from '@/journey/sign-routes';
import type { SceneResourceBag } from '@/scene/resources';
-interface SignPose {
- x: number;
- y: number;
- z: number;
- scale: number;
-}
-
-interface SignPoseSet {
- horizon: SignPose;
- dwell: SignPose;
-}
-
interface ProjectingCamera {
+ position: { x: number; y: number; z: number };
+ rotation: { y: number };
updateMatrixWorld(force: boolean): void;
}
-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 SHORT_CORRIDOR_REFERENCE_ASPECT = 1.58;
-const SHORT_CORRIDOR_BASE_RELIEF = 20;
-const SHORT_CORRIDOR_RELIEF_PER_ASPECT = 20;
-const SHORT_CORRIDOR_MINIMUM_RELIEF = 12;
-const SHORT_CORRIDOR_MAXIMUM_RELIEF = 34;
-const RESPONSIVE_REFLOW_MS = 320;
-const PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH = 480;
-const PORTRAIT_NAVIGATION_MEDIUM_WIDTH = 720;
-const PORTRAIT_NAVIGATION_MEDIUM_SCALE = 0.44;
-const PORTRAIT_NAVIGATION_MEDIUM_Y = 4.5;
-
-const SIGN_POSES: Readonly>>> = {
- wide: {
- left: {
- horizon: { x: -9.6, y: 7.2, z: -156, scale: 0.2 },
- dwell: { x: -11.9, y: 7.95, z: -16, scale: 1.36 },
- },
- right: {
- horizon: { x: 10.2, y: 7.2, z: -156, scale: 0.2 },
- dwell: { x: 12.8, y: 7.95, z: -16, scale: 1.36 },
- },
- 'outer-left': {
- horizon: { x: -21.5, y: 7.4, z: -168, scale: 0.2 },
- dwell: { x: -30.4, y: 8.65, z: -24, scale: 1.46 },
- },
- 'outer-right': {
- horizon: { x: 26.5, y: 7.4, z: -168, scale: 0.2 },
- dwell: { x: 41.8, y: 8.65, z: -24, scale: 1.46 },
- },
- },
- tall: {
- left: {
- horizon: { x: -4.2, y: 6.4, z: -136, scale: 0.16 },
- dwell: { x: -5.4, y: 8.15, z: -24, scale: 1.4 },
- },
- right: {
- horizon: { x: 4.2, y: 6.4, z: -136, scale: 0.16 },
- dwell: { x: 5.4, y: 8.15, z: -24, scale: 1.4 },
- },
- 'outer-left': {
- horizon: { x: -5.8, y: 6.7, z: -148, scale: 0.16 },
- dwell: { x: -7.6, y: 8.8, z: -30, scale: 1.48 },
- },
- 'outer-right': {
- horizon: { x: 5.8, y: 6.7, z: -148, scale: 0.16 },
- dwell: { x: 7.6, y: 8.8, z: -30, scale: 1.48 },
- },
- },
- 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.62 },
- },
- right: {
- horizon: { x: 7.8, y: 6.6, z: -144, scale: 0.18 },
- dwell: { x: 8.8, y: 5.45, z: -26, scale: 2.62 },
- },
- 'outer-left': {
- horizon: { x: -14.2, y: 6.8, z: -154, scale: 0.18 },
- dwell: { x: -26.5, y: 5.8, z: -32, scale: 2.3 },
- },
- 'outer-right': {
- horizon: { x: 14.2, y: 6.8, z: -154, scale: 0.18 },
- dwell: { x: 26.5, y: 5.8, z: -32, scale: 2.3 },
- },
- },
-};
-
-const PORTRAIT_NAVIGATION_DWELL: Readonly> = {
- left: { x: -2.9, y: 3, z: 8, scale: 0.25 },
- right: { x: 2.9, y: 3, z: 8, scale: 0.25 },
- 'outer-left': { x: -2.9, y: 3, z: 8, scale: 0.25 },
- 'outer-right': { x: 2.9, y: 3, z: 8, scale: 0.25 },
-};
-
-const EXPERIENCE_PASSIVE_DWELL: Readonly>> = {
- wide: { x: 12.8, y: 3.7, z: 0, scale: 0.9 },
- // The near short pose counterbalances the shared aspect relief while keeping
- // the projected face in the right-hand gap between both visible panels.
- short: { x: -7.7, y: 3.3, z: -4, scale: 1.4 },
-};
-
+const SIGN_MARKER_FONT =
+ '560 46px "Geist Mono Variable", ui-monospace, monospace';
+const SIGN_LABEL_FONT = '650 180px "Geist Variable", sans-serif';
export interface SignWorld {
group: SignGroup;
inspect(
@@ -118,6 +26,13 @@ export interface SignWorld {
width: number,
height: number,
): SignPresentation | null;
+ compose(
+ camera: ProjectingCamera,
+ width: number,
+ height: number,
+ navigationBottom: number,
+ now: number,
+ ): void;
setViewportAspect(aspect: number): void;
setViewportSize(width: number, height: number): void;
update(sign: SignSnapshot | null, now?: number): void;
@@ -151,6 +66,7 @@ export interface SignRenderOptions {
interface SignGroup {
visible: boolean;
+ userData: Record;
position: {
x: number;
y: number;
@@ -170,7 +86,9 @@ export function routeLabelFor(id: SectionId): string {
return signRouteFor(id).label;
}
-export async function prepareSignTypography(signal: AbortSignal): Promise {
+export async function prepareSignTypography(
+ signal: AbortSignal,
+): Promise {
const fonts = document.fonts;
if (fonts === undefined) return;
await Promise.all([
@@ -178,7 +96,8 @@ export async function prepareSignTypography(signal: AbortSignal): Promise
fonts.load(SIGN_LABEL_FONT),
fonts.ready,
]);
- if (signal.aborted) throw new DOMException('Scene build aborted', 'AbortError');
+ if (signal.aborted)
+ throw new DOMException('Scene build aborted', 'AbortError');
}
export function createSign(
@@ -189,52 +108,80 @@ export function createSign(
group.name = 'roadside-sign';
group.visible = false;
- const housingMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x08131c,
- roughness: 0.5,
- metalness: 0.7,
- transparent: true,
- opacity: 0,
- }));
- const backplateMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x172731,
- roughness: 0.72,
- metalness: 0.38,
- transparent: true,
- opacity: 0,
- }));
- const facePanelMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x061018,
- roughness: 0.9,
- metalness: 0.12,
- transparent: true,
- opacity: 0,
- }));
- const supportMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x21343e,
- roughness: 0.56,
- metalness: 0.68,
- transparent: true,
- opacity: 0,
- }));
- const edgeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x39747a,
- emissive: 0x55b9c0,
- emissiveIntensity: 0.42,
- roughness: 0.42,
- metalness: 0.5,
- transparent: true,
- opacity: 0,
- }));
- const routeMaterial = resources.own('material', new THREE.MeshStandardMaterial({
- color: 0x8e6446,
- emissive: 0xb9723f,
- emissiveIntensity: 0.28,
- roughness: 0.48,
- metalness: 0.42,
- transparent: true,
- opacity: 0,
- }));
+ const housingMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x334956,
+ emissive: 0x23404e,
+ emissiveIntensity: 0.6,
+ roughness: 0.28,
+ metalness: 0.7,
+ transparent: true,
+ opacity: 0,
+ alphaTest: 0.02,
+ }),
+ );
+ const backplateMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x172e3c,
+ roughness: 0.72,
+ metalness: 0.38,
+ transparent: true,
+ opacity: 0,
+ alphaTest: 0.02,
+ }),
+ );
+ const facePanelMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x061018,
+ roughness: 0.9,
+ metalness: 0.12,
+ transparent: true,
+ opacity: 0,
+ alphaTest: 0.02,
+ }),
+ );
+ const supportMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x21343e,
+ emissive: 0x163845,
+ emissiveIntensity: 0.4,
+ roughness: 0.56,
+ metalness: 0.68,
+ transparent: true,
+ opacity: 0,
+ alphaTest: 0.02,
+ }),
+ );
+ const edgeMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x39747a,
+ emissive: 0x55b9c0,
+ emissiveIntensity: 1.1,
+ roughness: 0.42,
+ metalness: 0.5,
+ transparent: true,
+ opacity: 0,
+ alphaTest: 0.02,
+ }),
+ );
+ const routeMaterial = resources.own(
+ 'material',
+ new THREE.MeshStandardMaterial({
+ color: 0x8e6446,
+ emissive: 0xb9723f,
+ emissiveIntensity: 0.8,
+ roughness: 0.48,
+ metalness: 0.42,
+ transparent: true,
+ opacity: 0,
+ alphaTest: 0.02,
+ }),
+ );
const housing = new THREE.Mesh(
resources.own('geometry', createSignBody(8.64, 2.72, 0.5, 0.24)),
@@ -275,18 +222,22 @@ export function createSign(
8,
Math.max(1, Math.floor(options.maximumAnisotropy ?? 8)),
);
- const faceMaterial = resources.own('material', new THREE.MeshBasicMaterial({
- color: 0xffffff,
- transparent: true,
- opacity: 0,
- side: THREE.FrontSide,
- map: texture,
- polygonOffset: true,
- polygonOffsetFactor: -1,
- polygonOffsetUnits: -1,
- depthWrite: false,
- toneMapped: false,
- }));
+ const faceMaterial = resources.own(
+ 'material',
+ new THREE.MeshBasicMaterial({
+ color: 0xffffff,
+ transparent: true,
+ opacity: 0,
+ alphaTest: 0.02,
+ side: THREE.FrontSide,
+ map: texture,
+ polygonOffset: true,
+ polygonOffsetFactor: -1,
+ polygonOffsetUnits: -1,
+ depthWrite: false,
+ toneMapped: false,
+ }),
+ );
const face = new THREE.Mesh(
resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
faceMaterial,
@@ -299,14 +250,33 @@ export function createSign(
const edgeLight = new THREE.Group();
edgeLight.name = 'sign-edge-light';
- const edgeGeometry = resources.own('geometry', new THREE.BoxGeometry(0.045, 1.56, 0.045));
- const upperEdgeGeometry = resources.own('geometry', new THREE.BoxGeometry(1.18, 0.04, 0.045));
+ const edgeGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(0.045, 1.56, 0.045),
+ );
+ const upperEdgeGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(7.6, 0.035, 0.045),
+ );
const leftEdge = new THREE.Mesh(edgeGeometry, edgeMaterial);
leftEdge.position.set(-3.9, 0, 0.57);
edgeLight.add(leftEdge);
const upperEdge = new THREE.Mesh(upperEdgeGeometry, edgeMaterial);
- upperEdge.position.set(-3.33, 1, 0.57);
+ upperEdge.position.set(0, 1.14, 0.57);
edgeLight.add(upperEdge);
+ const lowerEdge = new THREE.Mesh(upperEdgeGeometry, routeMaterial);
+ lowerEdge.position.set(0, -1.14, 0.57);
+ edgeLight.add(lowerEdge);
+ const finGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(0.055, 1.86, 0.48),
+ );
+ for (const side of [-1, 1])
+ for (let index = 0; index < 3; index++) {
+ const fin = new THREE.Mesh(finGeometry, supportMaterial);
+ fin.position.set(side * (4.26 + index * 0.12), 0, -0.04);
+ edgeLight.add(fin);
+ }
group.add(edgeLight);
const routeAccent = new THREE.Mesh(
@@ -319,7 +289,10 @@ export function createSign(
const backRails = new THREE.Group();
backRails.name = 'sign-back-rails';
- const backRailGeometry = resources.own('geometry', new THREE.BoxGeometry(7.15, 0.13, 0.16));
+ const backRailGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(7.15, 0.13, 0.16),
+ );
for (const y of [-0.68, 0.68]) {
const rail = new THREE.Mesh(backRailGeometry, supportMaterial);
rail.position.set(0, y, -0.38);
@@ -327,11 +300,11 @@ export function createSign(
}
group.add(backRails);
- const postGeometry = resources.own('geometry', new THREE.BoxGeometry(0.22, 5.05, 0.26));
- const post = new THREE.Mesh(
- postGeometry,
- supportMaterial,
+ const postGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(0.22, 5.05, 0.26),
);
+ const post = new THREE.Mesh(postGeometry, supportMaterial);
post.name = 'sign-post';
post.position.set(2.62, -3.58, -0.38);
group.add(post);
@@ -350,7 +323,10 @@ export function createSign(
);
arm.position.set(0, -1.24, -0.38);
bracket.add(arm);
- const braceGeometry = resources.own('geometry', new THREE.BoxGeometry(0.12, 1.18, 0.14));
+ const braceGeometry = resources.own(
+ 'geometry',
+ new THREE.BoxGeometry(0.12, 1.18, 0.14),
+ );
for (const side of [-1, 1]) {
const brace = new THREE.Mesh(braceGeometry, supportMaterial);
brace.position.set(side * 2.18, -1.68, -0.38);
@@ -361,7 +337,10 @@ export function createSign(
const fasteners = new THREE.Group();
fasteners.name = 'sign-fasteners';
- const fastenerGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8));
+ const fastenerGeometry = resources.own(
+ 'geometry',
+ new THREE.CylinderGeometry(0.055, 0.055, 0.045, 8),
+ );
for (const x of [-3.72, 3.72]) {
for (const y of [-0.78, 0.78]) {
const fastener = new THREE.Mesh(fastenerGeometry, supportMaterial);
@@ -374,7 +353,10 @@ export function createSign(
const footing = new THREE.Group();
footing.name = 'sign-footing';
- const footingGeometry = resources.own('geometry', new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8));
+ const footingGeometry = resources.own(
+ 'geometry',
+ new THREE.CylinderGeometry(0.3, 0.38, 0.32, 8),
+ );
for (const x of [-2.62, 2.62]) {
const base = new THREE.Mesh(footingGeometry, supportMaterial);
base.position.x = x;
@@ -383,6 +365,28 @@ export function createSign(
footing.position.set(0, -6.18, -0.38);
group.add(footing);
+ const sweepMaterial = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms: { uSweep: { value: -1 }, uOpacity: { value: 0 } },
+ vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`,
+ fragmentShader: `uniform float uSweep;uniform float uOpacity;varying vec2 vUv;
+ void main(){float beam=exp(-pow((vUv.x+vUv.y*0.14-uSweep)*12.0,2.0));
+ gl_FragColor=vec4(0.36,0.82,0.9,beam*uOpacity);}`,
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ }),
+ );
+ const sweep = new THREE.Mesh(
+ resources.own('geometry', new THREE.PlaneGeometry(8, 2)),
+ sweepMaterial,
+ );
+ sweep.name = 'sign-illumination-sweep';
+ sweep.position.z = 0.56;
+ group.add(sweep);
+
const fadeMaterials = [
[housingMaterial, 0.97],
[backplateMaterial, 1],
@@ -392,166 +396,76 @@ export function createSign(
[routeMaterial, 0.82],
[faceMaterial, 1],
] as const;
- const departureOrigin = new THREE.Vector3();
- const poseTransitionOrigin = new THREE.Vector3();
- const poseTransitionDestination = new THREE.Vector3();
- let departureScale = 1;
- let poseTransitionOriginScale = 1;
- let poseTransitionOriginRotationY = 0;
- let poseTransitionPending = false;
- let poseTransitionStartedAt: number | null = null;
let target: SectionId | null = null;
- let pendingViewport: SignViewportProfile = 'wide';
- let activeViewport: SignViewportProfile = 'wide';
- let pendingAspect = 16 / 9;
- let activeAspect = 16 / 9;
- let pendingViewportWidth = 1_440;
- let activeViewportWidth = 1_440;
- let activeNavigationArrival = false;
+ let snapshot: SignSnapshot | null = null;
+ let viewport: SignViewportProfile = 'wide';
let activeCorridor: SignCorridor = 'right';
- let previousPhase: SignSnapshot['phase'] | null = null;
- let renderedOpacity = 0;
- 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 => {
- if (target === id) return;
- target = id;
- face.userData.textureUpdateCount += 1;
- face.userData.fontReadyWhenDrawn = document.fonts?.check(SIGN_LABEL_FONT) ?? true;
- if (context) drawLabel(context, signRouteFor(id), canvas.width, canvas.height);
- texture.needsUpdate = true;
- };
-
- const applyOpacity = (opacity: number): void => {
- for (const [material, weight] of fadeMaterials) material.opacity = opacity * weight;
- };
-
- const shortCorridorRelief = (): number => activeViewport === 'short'
- ? THREE.MathUtils.clamp(
- SHORT_CORRIDOR_BASE_RELIEF
- + (activeAspect - SHORT_CORRIDOR_REFERENCE_ASPECT)
- * SHORT_CORRIDOR_RELIEF_PER_ASPECT,
- SHORT_CORRIDOR_MINIMUM_RELIEF,
- SHORT_CORRIDOR_MAXIMUM_RELIEF,
- )
- : 0;
-
- const resolvedPoseX = (
- pose: SignPose,
- side: number,
- weight: number,
- routeOffsetX = 0,
- ): number => pose.x + side * shortCorridorRelief() * weight + routeOffsetX * weight;
-
- const applyPose = (
- pose: SignPose,
- side: number,
- lateralWeight: number,
- routeOffsetX = 0,
- scale = pose.scale,
- y = pose.y,
- ): void => {
- group.position.set(
- resolvedPoseX(pose, side, lateralWeight, routeOffsetX),
- y,
- pose.z,
- );
- group.scale.setScalar(scale);
- plantMounting(y, scale);
- };
-
- const portraitNavigationWidthProgress = (): number => smoothstep(clamp01(
- (activeViewportWidth - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH)
- / (PORTRAIT_NAVIGATION_MEDIUM_WIDTH - PORTRAIT_NAVIGATION_PHONE_MAX_WIDTH),
- ));
-
- const portraitNavigationScale = (phoneScale: number): number => THREE.MathUtils.lerp(
- phoneScale,
- PORTRAIT_NAVIGATION_MEDIUM_SCALE,
- portraitNavigationWidthProgress(),
- );
-
- const portraitNavigationY = (phoneY: number): number => THREE.MathUtils.lerp(
- phoneY,
- PORTRAIT_NAVIGATION_MEDIUM_Y,
- portraitNavigationWidthProgress(),
- );
-
- 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);
- for (const mountedPost of mountedPosts) {
- mountedPost.position.y = (postTopY + footingY) * 0.5;
- mountedPost.scale.set(1, postHeight / 5.05, 1);
+ let viewportWidth = 1440;
+ let viewportHeight = 900;
+ let previousNow = 0;
+ let screenX = 0.5;
+ let screenY = 120;
+ let screenWidth = 340;
+ let initialized = false;
+ const point = new THREE.Vector3();
+ const ray = new THREE.Vector3();
+ const forward = new THREE.Vector3();
+ const worldRight = new THREE.Vector3();
+ const worldLeft = new THREE.Vector3();
+ const corner = new THREE.Vector3();
+ const corners = [
+ [-4, -1],
+ [4, -1],
+ [4, 1],
+ [-4, 1],
+ ];
+ function plantMounting() {
+ const footY = -group.position.y / Math.max(0.001, group.scale.x);
+ const height = Math.max(0.36, -1.36 - footY);
+ for (const post of mountedPosts) {
+ post.position.y = (-1.36 + footY) * 0.5;
+ post.scale.set(1, height / 5.05, 1);
}
- footing.position.y = footingY;
- footing.scale.setScalar(1);
- };
-
- const stagePoseTransition = (): void => {
- if (previousPhase === null || previousPhase === 'depart') return;
- poseTransitionOrigin.copy(group.position);
- poseTransitionOriginScale = group.scale.x;
- poseTransitionOriginRotationY = group.rotation.y;
- poseTransitionPending = true;
- poseTransitionStartedAt = null;
- };
-
- const stageResponsiveViewport = (
- viewport: SignViewportProfile,
- aspect: number,
- width: number,
- ): void => {
- const changed = viewport !== pendingViewport
- || Math.abs(aspect - pendingAspect) > 0.001
- || Math.abs(width - pendingViewportWidth) > 0.5;
- if (changed) stagePoseTransition();
- pendingViewport = viewport;
- pendingAspect = aspect;
- pendingViewportWidth = width;
- if (previousPhase === null) {
- activeViewport = pendingViewport;
- activeAspect = pendingAspect;
- activeViewportWidth = pendingViewportWidth;
- poseTransitionPending = false;
- poseTransitionStartedAt = null;
- }
- };
-
+ footing.position.y = footY;
+ }
+ function worldPoint(
+ out: typeof point,
+ x: number,
+ y: number,
+ depth: number,
+ camera: ProjectingCamera,
+ ) {
+ out
+ .set((x / viewportWidth) * 2 - 1, 1 - (y / viewportHeight) * 2, 0.5)
+ .unproject(camera);
+ ray.copy(out).sub(camera.position).normalize();
+ out
+ .copy(camera.position)
+ .addScaledVector(ray, depth / Math.max(0.05, ray.dot(forward)));
+ }
return {
group,
inspect(camera, width, height) {
- if (target === null || width <= 0 || height <= 0) return null;
+ if (target === null) 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);
+ let left = Infinity,
+ top = Infinity,
+ right = -Infinity,
+ bottom = -Infinity;
+ for (const [x, y] of corners) {
+ corner.set(x, y, 0).applyMatrix4(face.matrixWorld).project(camera);
+ const px = ((corner.x + 1) * width) / 2,
+ py = ((1 - corner.y) * height) / 2;
+ left = Math.min(left, px);
+ right = Math.max(right, px);
+ top = Math.min(top, py);
+ bottom = Math.max(bottom, py);
}
return {
visible: group.visible,
corridor: activeCorridor,
- viewportProfile: activeViewport,
+ viewportProfile: viewport,
bounds: {
left,
top,
@@ -570,177 +484,139 @@ export function createSign(
};
},
setViewportAspect(aspect) {
- const nextAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
- stageResponsiveViewport(
- nextAspect < 0.85 ? 'tall' : 'wide',
- nextAspect,
- pendingViewportWidth,
- );
+ viewport = aspect < 1.05 ? 'tall' : 'wide';
},
setViewportSize(width, height) {
- const nextAspect = Number.isFinite(width) && Number.isFinite(height) && height > 0
- ? Math.max(1, width) / height
- : 1;
- stageResponsiveViewport(
- signViewportProfileFor(width, height),
- nextAspect,
- Number.isFinite(width) ? Math.max(1, width) : 1,
- );
+ viewportWidth = Math.max(1, width);
+ viewportHeight = Math.max(1, height);
+ viewport = signViewportProfileFor(width, height);
},
- update(sign, now) {
+ update(sign) {
+ snapshot = sign;
+ group.userData.phase = sign?.phase ?? 'inactive';
+ group.userData.target = sign?.targetId ?? null;
if (!sign) {
group.visible = false;
- applyOpacity(0);
- renderedOpacity = 0;
- previousPhase = null;
- activeViewport = pendingViewport;
- activeAspect = pendingAspect;
- activeViewportWidth = pendingViewportWidth;
- activeNavigationArrival = false;
- poseTransitionPending = false;
- poseTransitionStartedAt = null;
+ initialized = false;
return;
}
-
- if (
- target !== null
- && target !== sign.targetId
- && renderedOpacity > SIGN_LIFECYCLE.concealedOpacity
- ) return;
- const navigationArrival = sign.navigationArrival === true;
if (target !== sign.targetId) {
- // The renderer owns projection. A quality-tier change can rebuild this world
- // while a lifecycle is already visible, so its latched profile may describe
- // the generation that was just disposed rather than this canvas.
- activeViewport = pendingViewport;
- activeAspect = pendingAspect;
- activeViewportWidth = pendingViewportWidth;
- activeNavigationArrival = navigationArrival;
- poseTransitionPending = false;
- poseTransitionStartedAt = null;
- updateTexture(sign.targetId);
- } else if (navigationArrival !== activeNavigationArrival) {
- stagePoseTransition();
- activeNavigationArrival = navigationArrival;
+ target = sign.targetId;
+ face.userData.textureUpdateCount += 1;
+ face.userData.fontReadyWhenDrawn =
+ document.fonts?.check(SIGN_LABEL_FONT) ?? true;
+ if (context)
+ drawLabel(
+ context,
+ signRouteFor(sign.targetId),
+ canvas.width,
+ canvas.height,
+ );
+ texture.needsUpdate = true;
+ initialized = false;
}
-
- if (poseTransitionPending && sign.phase === 'depart') {
- poseTransitionPending = false;
- poseTransitionStartedAt = null;
- } else if (poseTransitionPending) {
- activeViewport = pendingViewport;
- activeAspect = pendingAspect;
- activeViewportWidth = pendingViewportWidth;
- if (poseTransitionStartedAt === null) {
- poseTransitionStartedAt = Number.isFinite(now) ? now! : 0;
- }
- }
-
- const opacity = clamp01(sign.opacity);
- const progress = smoothstep(clamp01(sign.progress));
- const route = signRouteFor(sign.targetId);
- const corridor = route.corridor ?? 'right';
- activeCorridor = corridor;
- const poseSet = SIGN_POSES[activeViewport][corridor];
- const horizon = poseSet.horizon;
- let dwell = activeViewport === 'tall' && activeNavigationArrival
- ? PORTRAIT_NAVIGATION_DWELL[corridor]
- : poseSet.dwell;
- if (!activeNavigationArrival && sign.targetId === 'experience') {
- dwell = EXPERIENCE_PASSIVE_DWELL[activeViewport] ?? dwell;
- }
- const dwellScale = activeViewport === 'tall' && activeNavigationArrival
- ? portraitNavigationScale(dwell.scale)
- : dwell.scale;
- const dwellY = activeViewport === 'tall' && activeNavigationArrival
- ? portraitNavigationY(dwell.y)
- : dwell.y;
- const side = corridor.includes('left') ? -1 : 1;
- const routeOffsetX = route.dwellOffsetX ?? 0;
-
- if (sign.phase === 'anticipate') {
- applyPose(horizon, side, 0.35);
- } else if (sign.phase === 'approach') {
- group.position.set(
- THREE.MathUtils.lerp(
- resolvedPoseX(horizon, side, 0.35),
- resolvedPoseX(dwell, side, 1, routeOffsetX),
- progress,
- ),
- THREE.MathUtils.lerp(horizon.y, dwellY, progress),
- THREE.MathUtils.lerp(horizon.z, dwell.z, progress),
+ },
+ compose(camera, width, height, navigationBottom, now) {
+ if (!snapshot) return;
+ viewportWidth = width;
+ viewportHeight = height;
+ camera.updateMatrixWorld(true);
+ forward
+ .set(0, 0, -1)
+ .transformDirection(
+ (camera as ProjectingCamera & { matrixWorld: unknown }).matrixWorld,
);
- group.scale.setScalar(THREE.MathUtils.lerp(horizon.scale, dwellScale, progress));
- plantMounting(group.position.y, group.scale.x);
- } else if (sign.phase === 'dwell') {
- applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
- } else {
- if (previousPhase !== 'depart') {
- if (previousPhase === null) applyPose(dwell, side, 1, routeOffsetX, dwellScale, dwellY);
- departureOrigin.copy(group.position);
- departureScale = group.scale.x;
- }
- const outerCorridor = corridor.startsWith('outer');
- const lateralExit = activeViewport === 'wide'
- ? outerCorridor ? 2.6 : 3.4
- : activeViewport === 'short' ? 2.2 : 2.4;
- group.position.set(
- departureOrigin.x + progress * side * lateralExit,
- departureOrigin.y,
- THREE.MathUtils.lerp(departureOrigin.z, 18, progress),
- );
- group.scale.setScalar(departureScale);
- plantMounting(group.position.y, group.scale.x);
+ const route = signRouteFor(snapshot.targetId);
+ activeCorridor = route.corridor ?? 'right';
+ const side = activeCorridor.includes('left') ? -1 : 1;
+ const compact = width < 900;
+ const shortSide = height < 560 && width >= 768;
+ const lowerStrip = height < 560 && !shortSide;
+ const targetWidth = Math.min(
+ width - 44,
+ compact ? 288 : 356,
+ height < 650 ? 250 : 356,
+ );
+ const targetX = shortSide ? 0.82 : compact ? 0.5 : side < 0 ? 0.23 : 0.77;
+ const targetY = shortSide
+ ? navigationBottom + 32 + (height - navigationBottom - 96) * 0.25
+ : lowerStrip
+ ? height - 112
+ : navigationBottom + 12 + targetWidth / 3.18 / 2;
+ const dt = Math.min(0.05, Math.max(0, (now - previousNow) / 1000));
+ previousNow = now;
+ const mix = initialized ? 1 - Math.exp(-dt / 0.18) : 1;
+ screenX += (targetX - screenX) * mix;
+ screenY += (targetY - screenY) * mix;
+ screenWidth += (targetWidth - screenWidth) * mix;
+ initialized = true;
+ const p = smoothstep(clamp01(snapshot.progress));
+ let arrival =
+ snapshot.phase === 'anticipate'
+ ? 0
+ : snapshot.phase === 'approach'
+ ? p
+ : 1;
+ const closeDepth = lowerStrip ? 12 : 18;
+ let depth = THREE.MathUtils.lerp(190, closeDepth, arrival);
+ let cx = THREE.MathUtils.lerp(0.5 + side * 0.1, screenX, arrival) * width;
+ let cy = THREE.MathUtils.lerp(
+ height * 0.43,
+ screenY,
+ lowerStrip ? arrival * arrival * arrival : arrival,
+ );
+ let projectedWidth = screenWidth * THREE.MathUtils.lerp(0.1, 1, arrival);
+ if (snapshot.phase === 'depart') {
+ depth = THREE.MathUtils.lerp(closeDepth, lowerStrip ? 4 : 12, p);
+ cx += side * p * width * 0.3;
+ projectedWidth *= 1 + p * 0.3;
}
-
- const desiredRotationY = -side * (activeViewport === 'wide' ? 0.14 : 0.1);
- if (poseTransitionPending) {
- const elapsed = Number.isFinite(now)
- ? Math.max(0, now! - (poseTransitionStartedAt ?? now!))
- : RESPONSIVE_REFLOW_MS;
- const mix = smoothstep(clamp01(elapsed / RESPONSIVE_REFLOW_MS));
- poseTransitionDestination.copy(group.position);
- const poseTransitionDestinationScale = group.scale.x;
- group.position.set(
- THREE.MathUtils.lerp(poseTransitionOrigin.x, poseTransitionDestination.x, mix),
- THREE.MathUtils.lerp(poseTransitionOrigin.y, poseTransitionDestination.y, mix),
- THREE.MathUtils.lerp(poseTransitionOrigin.z, poseTransitionDestination.z, mix),
+ if (snapshot.phase !== 'depart') {
+ projectedWidth = Math.min(width - 44, projectedWidth);
+ cx = THREE.MathUtils.clamp(
+ cx,
+ projectedWidth / 2 + 14,
+ width - projectedWidth / 2 - 14,
);
- group.scale.setScalar(THREE.MathUtils.lerp(
- poseTransitionOriginScale,
- poseTransitionDestinationScale,
- mix,
- ));
- group.rotation.y = THREE.MathUtils.lerp(
- poseTransitionOriginRotationY,
- desiredRotationY,
- mix,
- );
- plantMounting(group.position.y, group.scale.x);
- if (mix >= 1) {
- poseTransitionPending = false;
- poseTransitionStartedAt = null;
- }
- } else {
- group.rotation.y = desiredRotationY;
}
- applyOpacity(opacity);
+ worldPoint(point, cx, cy, depth, camera);
+ worldPoint(worldLeft, cx - projectedWidth / 2, cy, depth, camera);
+ worldPoint(worldRight, cx + projectedWidth / 2, cy, depth, camera);
+ if (lowerStrip) point.y = Math.max(1.4, point.y);
+ group.position.set(point.x, point.y, point.z);
+ group.scale.setScalar(worldRight.distanceTo(worldLeft) / 8.64);
+ group.rotation.y = camera.rotation.y - side * 0.065;
+ plantMounting();
+ const opacity = clamp01(snapshot.opacity);
+ for (const [mat, weight] of fadeMaterials) mat.opacity = opacity * weight;
+ // The frame catches light first; one moving highlight resolves into a quiet face.
+ faceMaterial.opacity = opacity * smoothstep(Math.min(1, arrival * 1.8));
+ edgeMaterial.emissiveIntensity = 0.8 + Math.sin(arrival * Math.PI) * 1.4;
+ sweepMaterial.uniforms.uSweep.value = arrival * 1.5 - 0.2;
+ sweepMaterial.uniforms.uOpacity.value =
+ snapshot.phase === 'approach' ? opacity * 0.14 : 0;
group.visible = opacity > 0;
- renderedOpacity = opacity;
- previousPhase = sign.phase;
},
};
}
-function createSignBody(width: number, height: number, depth: number, cut: number) {
- const geometry = new THREE.ExtrudeGeometry(createClippedShape(width, height, cut), {
- depth,
- bevelEnabled: true,
- bevelSegments: 1,
- bevelSize: 0.045,
- bevelThickness: 0.045,
- curveSegments: 1,
- });
+function createSignBody(
+ width: number,
+ height: number,
+ depth: number,
+ cut: number,
+) {
+ const geometry = new THREE.ExtrudeGeometry(
+ createClippedShape(width, height, cut),
+ {
+ depth,
+ bevelEnabled: true,
+ bevelSegments: 1,
+ bevelSize: 0.045,
+ bevelThickness: 0.045,
+ curveSegments: 1,
+ },
+ );
geometry.translate(0, 0, -depth * 0.5);
geometry.computeVertexNormals();
return geometry;
@@ -799,9 +675,9 @@ function drawLabel(
context.fillRect(142, 174, 1_610, 2);
context.globalAlpha = 1;
- context.fillStyle = '#d7e4e2';
+ context.fillStyle = '#e2f7f4';
const labelSize = fittedLabelSize(context, route.label, 1_520);
- context.font = `660 ${labelSize}px "Geist Mono Variable", ui-monospace, monospace`;
+ context.font = `650 ${labelSize}px "Geist Variable", sans-serif`;
context.fillText(route.label, 142, 326);
context.strokeStyle = '#cb9160';
@@ -823,8 +699,8 @@ function fittedLabelSize(
label: string,
maximumWidth: number,
): number {
- for (let size = 164; size >= 112; size -= 4) {
- context.font = `660 ${size}px "Geist Mono Variable", ui-monospace, monospace`;
+ for (let size = 180; size >= 128; size -= 4) {
+ context.font = `650 ${size}px "Geist Variable", sans-serif`;
if (context.measureText(label).width <= maximumWidth) return size;
}
return 112;
diff --git a/src/scene/world/sun.ts b/src/scene/world/sun.ts
index d3511ef..07663e5 100644
--- a/src/scene/world/sun.ts
+++ b/src/scene/world/sun.ts
@@ -33,10 +33,15 @@ export function createSun(resources: SceneResourceBag): SunWorld {
uTime: { value: 0 },
uDriveEnergy: { value: 0 },
};
- const geometry = resources.own('geometry', new THREE.CircleGeometry(112, 128));
- const material = resources.own('material', new THREE.ShaderMaterial({
- uniforms,
- vertexShader: `
+ const geometry = resources.own(
+ 'geometry',
+ new THREE.CircleGeometry(126, 160),
+ );
+ const material = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: `
varying vec2 vSunUv;
void main() {
@@ -44,7 +49,7 @@ export function createSun(resources: SceneResourceBag): SunWorld {
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uColor;
uniform vec3 uHazeColor;
uniform float uScanBands;
@@ -55,97 +60,102 @@ export function createSun(resources: SceneResourceBag): SunWorld {
varying vec2 vSunUv;
void main() {
- vec2 centered = vSunUv - 0.5;
- float radius = length(centered);
- float softEdge = 1.0 - smoothstep(0.465, 0.5, radius);
- float limb = smoothstep(0.31, 0.485, radius);
- float broadField = 0.5 + 0.5 * sin(
- vSunUv.x * 9.0
- + sin(vSunUv.y * 11.0 + uTime * 0.028) * 1.35
- );
- float fineField = 0.5 + 0.5 * sin(
- vSunUv.x * 23.0 - vSunUv.y * 16.0 + sin(uTime * 0.021) * 0.7
- );
- float internalField = broadField * 0.7 + fineField * 0.3;
- float verticalHeat = mix(0.68, 1.3, smoothstep(0.06, 0.92, vSunUv.y));
- float lowerDisc = 1.0 - smoothstep(0.28, 0.67, vSunUv.y);
- float scanPhase = fract(vSunUv.y * uScanBands + sin(uTime * 0.08) * 0.008);
- float scanBand = smoothstep(0.09, 0.18, scanPhase)
- * (1.0 - smoothstep(0.72, 0.82, scanPhase));
- float bandCut = mix(1.0, 0.06 + scanBand * 0.94, lowerDisc);
- float horizonFade = smoothstep(0.015, 0.13, vSunUv.y);
- float radialCore = 1.0 - smoothstep(0.02, 0.48, radius);
- float luminance = verticalHeat
- * (1.08 + uWarmth * 0.16 + uRouteProgress * 0.045 + uDriveEnergy * 0.045);
- vec3 warmCore = mix(uColor, vec3(1.0, 0.56, 0.32), smoothstep(0.25, 0.92, vSunUv.y) * 0.84);
- vec3 core = mix(uColor * 0.82, warmCore * 0.83, smoothstep(0.05, 0.86, vSunUv.y));
- core = mix(core, core * mix(0.82, 1.13, internalField), 0.54);
- core = mix(core, uHazeColor * 0.72, limb * 0.22 + lowerDisc * 0.055);
- gl_FragColor = vec4(core * luminance, softEdge * bandCut * horizonFade);
+ vec2 solar = vSunUv;
+ float low = 1.0 - solar.y;
+ solar.x += sin(solar.y * 132.0 + uTime * 0.42) * 0.0015 * low * low;
+ float radius = length(solar - 0.5);
+ float softEdge = 1.0 - smoothstep(0.496, 0.5, radius);
+ float stripes = (0.65 - solar.y) * 17.0;
+ float phase = fract(stripes);
+ float gap = mix(0.12, 0.61, clamp(stripes / 11.0, 0.0, 1.0));
+ float separated = smoothstep(gap, gap + 0.024, phase);
+ float bandMask = solar.y > 0.65 ? 1.0 : separated;
+ float lip = exp(-abs(phase - gap - 0.025) * 90.0) * step(solar.y, 0.65);
+ float convection = sin(solar.x * 19.0 + sin(solar.y * 23.0 + uTime * 0.055))
+ * sin(solar.y * 31.0 - uTime * 0.032);
+ vec3 vermilion = vec3(0.91, 0.045, 0.075);
+ vec3 coral = mix(uColor, vec3(1.0, 0.19, 0.10), 0.6);
+ vec3 crown = vec3(1.0, 0.54, 0.25);
+ vec3 core = mix(vermilion, coral, smoothstep(0.0, 0.58, solar.y));
+ core = mix(core, crown, smoothstep(0.47, 1.0, solar.y));
+ float filament = pow(abs(sin(solar.x * 39.0 + solar.y * 25.0 + convection * 2.4)), 16.0);
+ core *= 0.87 + convection * 0.055 + filament * 0.07;
+ float limb = pow(smoothstep(0.38, 0.498, radius), 4.0);
+ core += vec3(1.0, 0.24, 0.095) * (limb * 0.22 + lip * 0.20);
+ float extinction = smoothstep(0.025, 0.19, solar.y);
+ if(softEdge * bandMask * extinction < 0.02) discard;
+ gl_FragColor = vec4(core * (1.0 + uWarmth * 0.10), softEdge * bandMask * extinction);
#include
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ transparent: true,
+ depthWrite: true,
+ depthTest: true,
+ blending: THREE.NormalBlending,
+ toneMapped: true,
+ }),
+ );
const disc = new THREE.Mesh(geometry, material);
disc.name = 'horizon-sun';
- disc.position.set(0, 60, SUN_DISTANCE_Z);
+ disc.position.set(0, 79, SUN_DISTANCE_Z);
group.add(disc);
- const limbMaterial = resources.own('material', new THREE.ShaderMaterial({
- uniforms: {
- uColor: { value: sunColor.clone() },
- uEnergy: { value: 0 },
- },
- vertexShader: `
+ const limbMaterial = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms: {
+ uColor: { value: sunColor.clone() },
+ uEnergy: { value: 0 },
+ },
+ vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uColor;
uniform float uEnergy;
varying vec2 vUv;
void main() {
float radius = distance(vUv, vec2(0.5));
- float ring = smoothstep(0.405, 0.475, radius)
- * (1.0 - smoothstep(0.475, 0.515, radius));
+ float ring = smoothstep(0.477, 0.488, radius)
+ * (1.0 - smoothstep(0.491, 0.499, radius));
gl_FragColor = vec4(uColor * (0.72 + uEnergy * 0.07), ring * (0.22 + uEnergy * 0.025));
#include
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ );
const limb = new THREE.Mesh(geometry, limbMaterial);
limb.name = 'sun-limb-glow';
limb.position.copy(disc.position);
limb.position.z -= 0.3;
- limb.scale.setScalar(1.075);
+ limb.scale.setScalar(1.034);
group.add(limb);
const haloTexture = resources.own('texture', createHaloTexture());
- const haloMaterial = resources.own('material', new THREE.SpriteMaterial({
- map: haloTexture,
- color: sunColor.clone(),
- transparent: true,
- opacity: 0.13,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ const haloMaterial = resources.own(
+ 'material',
+ new THREE.SpriteMaterial({
+ map: haloTexture,
+ color: sunColor.clone(),
+ transparent: true,
+ opacity: 0.13,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ );
const halo = new THREE.Sprite(haloMaterial);
halo.name = 'horizon-halo';
halo.position.copy(disc.position);
@@ -153,16 +163,19 @@ export function createSun(resources: SceneResourceBag): SunWorld {
halo.scale.set(330, 232, 1);
group.add(halo);
- const bleedMaterial = resources.own('material', new THREE.SpriteMaterial({
- map: haloTexture,
- color: sunColor.clone(),
- transparent: true,
- opacity: 0.075,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ const bleedMaterial = resources.own(
+ 'material',
+ new THREE.SpriteMaterial({
+ map: haloTexture,
+ color: sunColor.clone(),
+ transparent: true,
+ opacity: 0.075,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ );
const bleed = new THREE.Sprite(bleedMaterial);
bleed.name = 'horizon-light-bleed';
bleed.position.set(0, 7, SUN_DISTANCE_Z - 2.2);
@@ -181,13 +194,15 @@ export function createSun(resources: SceneResourceBag): SunWorld {
limbMaterial.uniforms.uColor.value.copy(palette.sun);
uniforms.uWarmth.value = clamp01(palette.warmth);
uniforms.uRouteProgress.value = progress;
- uniforms.uTime.value = Number.isFinite(elapsedSeconds) ? elapsedSeconds : 0;
+ uniforms.uTime.value = Number.isFinite(elapsedSeconds)
+ ? elapsedSeconds
+ : 0;
uniforms.uDriveEnergy.value = energy;
limbMaterial.uniforms.uEnergy.value = energy;
- haloMaterial.opacity = 0.105 + palette.warmth * 0.05 + energy * 0.015;
+ haloMaterial.opacity = 0.075 + palette.warmth * 0.03 + energy * 0.015;
bleedMaterial.opacity = 0.055 + palette.warmth * 0.03 + energy * 0.01;
halo.scale.set(480 + energy * 18, 360 + energy * 10, 1);
- disc.position.y = 60 + palette.warmth * 14;
+ disc.position.y = 79 + palette.warmth * 14;
limb.position.y = disc.position.y;
halo.position.y = disc.position.y;
bleed.position.y = 7 + palette.warmth * 3;
diff --git a/src/scene/world/terrain.ts b/src/scene/world/terrain.ts
index b8e77a2..8938f32 100644
--- a/src/scene/world/terrain.ts
+++ b/src/scene/world/terrain.ts
@@ -2,7 +2,10 @@ import * as THREE from 'three';
import type { EnvironmentChapter } from '@/journey/types';
import type { RenderQualitySettings } from '@/scene/render-quality';
import type { SceneResourceBag } from '@/scene/resources';
-import { CHAPTER_PALETTES, type MutableChapterPalette } from '@/scene/world/palette';
+import {
+ CHAPTER_PALETTES,
+ type MutableChapterPalette,
+} from '@/scene/world/palette';
import {
HORIZON_FOG_END,
HORIZON_FOG_START,
@@ -37,7 +40,10 @@ export function createTerrain(
): TerrainWorld {
const group = new THREE.Group();
group.name = 'terrain-world';
- const geometry = resources.own('geometry', createIndexedTerrainGeometry(quality.terrainSegments));
+ const geometry = resources.own(
+ 'geometry',
+ createIndexedTerrainGeometry(quality.terrainSegments),
+ );
const initialPalette = CHAPTER_PALETTES['deep-night'];
const lowColor = new THREE.Color(initialPalette.terrainLow);
const highColor = new THREE.Color(initialPalette.terrainHigh);
@@ -55,9 +61,11 @@ export function createTerrain(
uFogColor: { value: new THREE.Color(initialPalette.fog) },
uHazeColor: { value: new THREE.Color(initialPalette.haze) },
};
- const material = resources.own('material', new THREE.ShaderMaterial({
- uniforms,
- vertexShader: `
+ const material = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: `
attribute float roadMask;
uniform float uDistance;
uniform float uFlowDistance;
@@ -71,6 +79,7 @@ export function createTerrain(
varying float vRoadMask;
varying float vDepthFade;
varying float vFlow;
+ varying vec3 vTerrainPosition;
float hash21(vec2 point) {
point = mod(point, 256.0);
@@ -106,6 +115,7 @@ export function createTerrain(
float edgeGrowth = mix(7.0, uRelief, smoothstep(14.0, 150.0, abs(position.x)));
float height = roadMask * ridgeNoise * edgeGrowth * uTerrainEnergy;
displaced.y += height;
+ vTerrainPosition = displaced;
vHeight = clamp(height / max(1.0, uRelief), 0.0, 1.0);
vRoadMask = roadMask;
@@ -117,7 +127,7 @@ export function createTerrain(
gl_Position = projectionMatrix * viewPosition;
}
`,
- fragmentShader: `
+ fragmentShader: `
uniform vec3 uLowColor;
uniform vec3 uHighColor;
uniform vec3 uFogColor;
@@ -127,25 +137,75 @@ export function createTerrain(
varying float vRoadMask;
varying float vDepthFade;
varying float vFlow;
+ varying vec3 vTerrainPosition;
void main() {
float ridgeMix = smoothstep(0.16, 0.78, vHeight);
vec3 color = mix(uLowColor, uHighColor, ridgeMix);
color = mix(color, mix(uFogColor, uHazeColor, 0.2), 1.0 - vDepthFade);
- float pulse = 0.72 + 0.28 * smoothstep(0.16, 0.55, vFlow);
+ float pulse = 0.88 + 0.12 * smoothstep(0.16, 0.55, vFlow);
float routeCalm = mix(1.0, 0.9, smoothstep(0.82, 1.0, uRouteProgress));
- float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.54, 0.88, ridgeMix);
+ float alpha = vRoadMask * vDepthFade * pulse * routeCalm * mix(0.30, 0.83, ridgeMix);
gl_FragColor = vec4(color * mix(1.15, 1.75, ridgeMix), alpha);
#include
#include
}
`,
- transparent: true,
- depthWrite: false,
- depthTest: true,
- blending: THREE.AdditiveBlending,
- toneMapped: true,
- }));
+ transparent: true,
+ depthWrite: false,
+ depthTest: true,
+ blending: THREE.AdditiveBlending,
+ toneMapped: true,
+ }),
+ );
+
+ const groundGeometry = resources.own('geometry', geometry.clone());
+ const rows = Math.max(16, Math.floor(quality.terrainSegments));
+ const columns = geometry.attributes.position.count / (rows + 1) - 1;
+ const triangles: number[] = [];
+ for (let row = 0; row < rows; row++)
+ for (let column = 0; column < columns; column++) {
+ const a = row * (columns + 1) + column,
+ b = a + columns + 1;
+ triangles.push(a, b, a + 1, a + 1, b, b + 1);
+ }
+ groundGeometry.setIndex(triangles);
+ const groundMaterial = resources.own(
+ 'material',
+ new THREE.ShaderMaterial({
+ uniforms,
+ vertexShader: material.vertexShader.replace(
+ 'displaced.y += height;',
+ 'displaced.y += height - 0.12;',
+ ),
+ fragmentShader: `
+ uniform vec3 uLowColor;
+ uniform vec3 uHighColor;
+ uniform vec3 uFogColor;
+ varying float vHeight;
+ varying float vDepthFade;
+ varying vec3 vTerrainPosition;
+ void main() {
+ vec3 normal = normalize(cross(dFdx(vTerrainPosition), dFdy(vTerrainPosition)));
+ float light = abs(dot(normal, normalize(vec3(-0.5, 0.8, -0.3))));
+ vec3 color = mix(vec3(0.002, 0.008, 0.016), uLowColor * 0.11, light);
+ color += uHighColor * pow(vHeight, 2.0) * light * 0.06;
+ color = mix(uFogColor * 0.55, color, vDepthFade);
+ gl_FragColor = vec4(color, 1.0);
+ #include
+ #include
+ }
+ `,
+ side: THREE.DoubleSide,
+ depthWrite: true,
+ depthTest: true,
+ toneMapped: true,
+ }),
+ );
+ const ground = new THREE.Mesh(groundGeometry, groundMaterial);
+ ground.name = 'sculpted-terrain-surface';
+ ground.frustumCulled = false;
+ group.add(ground);
const lines = new THREE.LineSegments(geometry, material);
lines.name = 'terrain-grid';
@@ -156,11 +216,13 @@ export function createTerrain(
group,
update(distance, speed, chapter, routeProgress, palette) {
const chapterPalette = CHAPTER_PALETTES[chapter];
- uniforms.uDistance.value = ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
+ uniforms.uDistance.value =
+ ((finiteOrZero(distance) % NOISE_PERIOD) + NOISE_PERIOD) % NOISE_PERIOD;
uniforms.uFlowDistance.value = uniforms.uDistance.value;
uniforms.uSpeed.value = clamp01(speed);
uniforms.uRouteProgress.value = clamp01(routeProgress);
- uniforms.uTerrainEnergy.value = palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
+ uniforms.uTerrainEnergy.value =
+ palette?.terrainEnergy ?? chapterPalette.terrainEnergy;
if (palette) {
lowColor.copy(palette.terrainLow);
highColor.copy(palette.terrainHigh);
@@ -177,7 +239,10 @@ export function createTerrain(
function createIndexedTerrainGeometry(segments: number) {
const zSegments = Math.max(16, Math.floor(segments));
const approximateCell = WORLD_DEPTH / zSegments;
- const xSegments = Math.max(48, Math.floor((TERRAIN_WIDTH / approximateCell) / 2) * 2);
+ const xSegments = Math.max(
+ 48,
+ Math.floor(TERRAIN_WIDTH / approximateCell / 2) * 2,
+ );
const columnCount = xSegments + 1;
const rowCount = zSegments + 1;
const positions = new Float32Array(columnCount * rowCount * 3);
@@ -193,13 +258,18 @@ function createIndexedTerrainGeometry(segments: number) {
positions[vertexOffset + 1] = 0;
positions[vertexOffset + 2] = z;
vertexOffset += 3;
- roadMasks[maskOffset] = smoothstep(ROAD_CLEAR_HALF_WIDTH, RIDGE_START, Math.abs(x));
+ roadMasks[maskOffset] = smoothstep(
+ ROAD_CLEAR_HALF_WIDTH,
+ RIDGE_START,
+ Math.abs(x),
+ );
maskOffset += 1;
}
}
const indices: number[] = [];
- const vertexIndex = (column: number, row: number): number => row * columnCount + column;
+ const vertexIndex = (column: number, row: number): number =>
+ row * columnCount + column;
for (let row = 0; row < rowCount; row += 1) {
for (let column = 0; column < columnCount; column += 1) {
const x = -TERRAIN_WIDTH / 2 + (column / xSegments) * TERRAIN_WIDTH;
@@ -207,7 +277,8 @@ function createIndexedTerrainGeometry(segments: number) {
indices.push(vertexIndex(column, row), vertexIndex(column, row + 1));
}
if (column < xSegments) {
- const nextX = -TERRAIN_WIDTH / 2 + ((column + 1) / xSegments) * TERRAIN_WIDTH;
+ const nextX =
+ -TERRAIN_WIDTH / 2 + ((column + 1) / xSegments) * TERRAIN_WIDTH;
const midpoint = (x + nextX) * 0.5;
if (Math.abs(midpoint) > ROAD_CLEAR_HALF_WIDTH) {
indices.push(vertexIndex(column, row), vertexIndex(column + 1, row));
@@ -225,7 +296,11 @@ function createIndexedTerrainGeometry(segments: number) {
}
function smoothstep(edge0: number, edge1: number, value: number): number {
- const progress = THREE.MathUtils.clamp((value - edge0) / (edge1 - edge0), 0, 1);
+ const progress = THREE.MathUtils.clamp(
+ (value - edge0) / (edge1 - edge0),
+ 0,
+ 1,
+ );
return progress * progress * (3 - 2 * progress);
}
diff --git a/src/styles/evolution.css b/src/styles/evolution.css
index 3ca049f..243b0af 100644
--- a/src/styles/evolution.css
+++ b/src/styles/evolution.css
@@ -15,7 +15,9 @@
--panel-shadow: 0 24px 80px rgb(2 8 18 / 24%);
--site-navigation-block-size: 76px;
}
-body { min-height: 100dvh; }
+body {
+ min-height: 100dvh;
+}
.site-navigation {
inset: 14px max(24px, calc((100vw - 1400px) / 2)) auto;
min-block-size: 52px;
@@ -24,7 +26,10 @@ body { min-height: 100dvh; }
background: rgb(10 18 30 / 94%);
box-shadow: 0 8px 32px rgb(2 8 18 / 12%);
}
-.site-navigation ul { grid-auto-rows: 50px; padding-inline: 8px; }
+.site-navigation ul {
+ grid-auto-rows: 50px;
+ padding-inline: 8px;
+}
.site-navigation a {
font-family: var(--sans);
font-size: 0.82rem;
@@ -32,7 +37,9 @@ body { min-height: 100dvh; }
letter-spacing: 0;
border-block-end-width: 1px;
}
-.site-navigation a[aria-current="page"] { background: rgb(158 220 226 / 5%); }
+.site-navigation a[aria-current='page'] {
+ background: rgb(158 220 226 / 5%);
+}
.hero-journey {
min-block-size: calc(100dvh - var(--site-navigation-block-size));
padding: clamp(38px, 7vh, 72px) max(5vw, calc((100vw - 1360px) / 2)) 150px;
@@ -63,22 +70,50 @@ body { min-height: 100dvh; }
letter-spacing: -0.025em;
line-height: 1.3;
}
-.hero-journey__value { max-inline-size: 38ch; line-height: 1.55; }
-.hero-journey__availability { max-inline-size: 42ch; font-size: 0.83rem; line-height: 1.5; }
-.hero-journey__actions { gap: 26px; padding-block-start: 4px; }
+.hero-journey__value {
+ max-inline-size: 38ch;
+ line-height: 1.55;
+}
+.hero-journey__availability {
+ max-inline-size: 42ch;
+ font-size: 0.83rem;
+ line-height: 1.5;
+}
+.hero-journey__actions {
+ gap: 26px;
+ padding-block-start: 4px;
+}
.hero-journey__action,
.contact-link,
-.project-link { border-radius: 8px; white-space: nowrap; font-family: var(--sans); letter-spacing: 0; font-size: 0.9rem; }
+.project-link {
+ border-radius: 8px;
+ white-space: nowrap;
+ font-family: var(--sans);
+ letter-spacing: 0;
+ font-size: 0.9rem;
+}
.hero-journey__action--primary,
-.contact-link { padding: 0 23px; min-block-size: 49px; font-weight: 650; }
+.contact-link {
+ padding: 0 23px;
+ min-block-size: 49px;
+ font-weight: 650;
+}
.hero-journey__action--primary:hover,
-.contact-link:hover { color: #08151c; background: #c2edf0; }
+.contact-link:hover {
+ color: #08151c;
+ background: #c2edf0;
+}
.hero-journey__action:active,
.contact-link:active,
.project-link:active,
.drive-start:active,
-.drive-control-button:active { transform: translateY(1px) scale(0.98); }
-.portfolio-main { max-inline-size: 1920px; margin-inline: auto; }
+.drive-control-button:active {
+ transform: translateY(1px) scale(0.98);
+}
+.portfolio-main {
+ max-inline-size: 1920px;
+ margin-inline: auto;
+}
.portfolio-section {
padding: clamp(28px, 4vw, 64px);
margin-block: clamp(140px, 23vh, 250px);
@@ -90,10 +125,16 @@ body { min-height: 100dvh; }
letter-spacing: -0.055em;
margin-block-end: 36px;
}
-.portfolio-section[data-layout="right-rail"] { inline-size: 46%; margin-inline: 49% 5%; }
-.portfolio-section[data-layout="left-timeline"] { inline-size: 63%; margin-inline: 5% 32%; }
-.portfolio-section[data-layout="full-diagram"],
-.portfolio-section[data-layout="toolkit-field"] {
+.portfolio-section[data-layout='right-rail'] {
+ inline-size: 46%;
+ margin-inline: 49% 5%;
+}
+.portfolio-section[data-layout='left-timeline'] {
+ inline-size: 63%;
+ margin-inline: 5% 32%;
+}
+.portfolio-section[data-layout='full-diagram'],
+.portfolio-section[data-layout='toolkit-field'] {
inline-size: 84%;
margin-inline: 8%;
padding: clamp(28px, 4vw, 64px);
@@ -101,46 +142,145 @@ body { min-height: 100dvh; }
border-radius: var(--radius-surface);
background: var(--surface);
}
-.portfolio-section[data-layout="project-stage"] { inline-size: 90%; margin-inline: 5%; }
-.portfolio-section[data-layout="compact-rail"] { inline-size: 80%; margin-inline: 15% 5%; }
-.portfolio-section[data-layout="destination"] { inline-size: min(680px, 53%); margin-inline: 5% auto; }
-.portfolio-section[data-layout="project-stage"] > h2,
-.portfolio-section[data-layout="project-stage"] > .section-eyebrow {
+.portfolio-section[data-layout='project-stage'] {
+ inline-size: 90%;
+ margin-inline: 5%;
+}
+.portfolio-section[data-layout='compact-rail'] {
+ inline-size: 80%;
+ margin-inline: 15% 5%;
+}
+.portfolio-section[data-layout='destination'] {
+ inline-size: min(680px, 53%);
+ margin-inline: 5% auto;
+}
+.portfolio-section[data-layout='project-stage'] > h2,
+.portfolio-section[data-layout='project-stage'] > .section-eyebrow {
padding: 0;
border: 0;
background: transparent;
text-shadow: 0 2px 24px #080d18;
}
-.section-eyebrow { font-weight: 500; font-size: 0.7rem; letter-spacing: 0.1em; }
-.portfolio-section[data-layout="right-rail"] > p { font-size: clamp(19px, 1.5vw, 24px); line-height: 1.6; letter-spacing: -0.02em; }
-.profile-facts { margin-block-start: 34px; gap: 0 20px; }
-.profile-facts dt { font-family: var(--sans); text-transform: none; letter-spacing: 0; font-size: 0.8rem; }
-.profile-facts dd { font-size: 0.95rem; }
-.experience-timeline { padding-inline-start: 2rem !important; }
-.experience-timeline::before { background: linear-gradient(var(--cyan), var(--line), transparent); }
-.experience-timeline > li::before { inset-inline-start: -2.02rem; border-radius: 50%; transform: none; }
-.experience-record { display: grid; grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.2fr); column-gap: 36px; }
-.experience-record h3 { grid-column: 1; font-size: clamp(22px, 2.1vw, 32px); font-weight: 500; }
-.experience-record__meta { grid-column: 1; }
-.experience-record ul { grid-column: 2; grid-row: 1 / span 3; margin-block-start: 0; font-size: 0.95rem; }
-.systems-operations { padding: 28px; border-color: var(--cyan); border-radius: 0 8px 8px 0; }
-.systems-operations h3 { font-weight: 500; }
+.section-eyebrow {
+ font-weight: 500;
+ font-size: 0.7rem;
+ letter-spacing: 0.1em;
+}
+.portfolio-section[data-layout='right-rail'] > p {
+ font-size: clamp(19px, 1.5vw, 24px);
+ line-height: 1.6;
+ letter-spacing: -0.02em;
+}
+.profile-facts {
+ margin-block-start: 34px;
+ gap: 0 20px;
+}
+.profile-facts dt {
+ font-family: var(--sans);
+ text-transform: none;
+ letter-spacing: 0;
+ font-size: 0.8rem;
+}
+.profile-facts dd {
+ font-size: 0.95rem;
+}
+.experience-timeline {
+ padding-inline-start: 2rem !important;
+}
+.experience-timeline::before {
+ background: linear-gradient(var(--cyan), var(--line), transparent);
+}
+.experience-timeline > li::before {
+ inset-inline-start: -2.02rem;
+ border-radius: 50%;
+ transform: none;
+}
+.experience-record {
+ display: grid;
+ grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.2fr);
+ column-gap: 36px;
+}
+.experience-record h3 {
+ grid-column: 1;
+ font-size: clamp(22px, 2.1vw, 32px);
+ font-weight: 500;
+}
+.experience-record__meta {
+ grid-column: 1;
+}
+.experience-record ul {
+ grid-column: 2;
+ grid-row: 1 / span 3;
+ margin-block-start: 0;
+ font-size: 0.95rem;
+}
+.systems-operations {
+ padding: 28px;
+ border-color: var(--cyan);
+ border-radius: 0 8px 8px 0;
+}
+.systems-operations h3 {
+ font-weight: 500;
+}
.infrastructure-case > h3,
.infrastructure-case > p,
-.portfolio-section[data-layout="full-diagram"] > h2 { padding: 0; background: none; }
-.infrastructure-case > h3 { font-size: clamp(25px, 2.5vw, 38px); font-weight: 480; }
-.infrastructure-case > p { max-inline-size: 66ch; }
-.portfolio-section .infrastructure-flow-figure { padding: 20px; background: #101e2d; border: 0; border-radius: 12px; margin-block: 36px; }
-.infrastructure-node { border-inline-start-width: 1px; border-radius: 8px; min-block-size: 150px; background: #172b3b; box-shadow: 0 12px 28px rgb(4 12 24 / 22%); }
-.infrastructure-node__sequence { font-size: 0.6rem; color: var(--text-muted); }
-.infrastructure-node strong { font-weight: 550; font-size: clamp(16px, 1.4vw, 21px); letter-spacing: -0.02em; }
-.infrastructure-node--operations { border-color: var(--line-strong); }
-.operations-proof { padding: 32px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface-soft); gap: 24px 36px; }
-.operations-proof > h4 { font-size: 1.5rem; font-weight: 520; letter-spacing: -0.03em; }
-[data-testid="infrastructure-text-equivalent"] { margin-block: 30px; }
-.project-stage__projects { gap: clamp(90px, 14vh, 160px); }
+.portfolio-section[data-layout='full-diagram'] > h2 {
+ padding: 0;
+ background: none;
+}
+.infrastructure-case > h3 {
+ font-size: clamp(25px, 2.5vw, 38px);
+ font-weight: 480;
+}
+.infrastructure-case > p {
+ max-inline-size: 66ch;
+}
+.portfolio-section .infrastructure-flow-figure {
+ padding: 20px;
+ background: #101e2d;
+ border: 0;
+ border-radius: 12px;
+ margin-block: 36px;
+}
+.infrastructure-node {
+ border-inline-start-width: 1px;
+ border-radius: 8px;
+ min-block-size: 150px;
+ background: #172b3b;
+ box-shadow: 0 12px 28px rgb(4 12 24 / 22%);
+}
+.infrastructure-node__sequence {
+ font-size: 0.6rem;
+ color: var(--text-muted);
+}
+.infrastructure-node strong {
+ font-weight: 550;
+ font-size: clamp(16px, 1.4vw, 21px);
+ letter-spacing: -0.02em;
+}
+.infrastructure-node--operations {
+ border-color: var(--line-strong);
+}
+.operations-proof {
+ padding: 32px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--surface-soft);
+ gap: 24px 36px;
+}
+.operations-proof > h4 {
+ font-size: 1.5rem;
+ font-weight: 520;
+ letter-spacing: -0.03em;
+}
+[data-testid='infrastructure-text-equivalent'] {
+ margin-block: 30px;
+}
+.project-stage__projects {
+ gap: clamp(90px, 14vh, 160px);
+}
.project-case-study,
-.project-case-study[data-project-priority="supporting"],
+.project-case-study[data-project-priority='supporting'],
.project-case-study[data-project-id][data-media-layout] {
inline-size: 100%;
margin-inline: 0;
@@ -149,169 +289,709 @@ body { min-height: 100dvh; }
background: var(--surface);
border-radius: 16px;
}
-.project-case-study__header { max-inline-size: 810px; border: 0; padding-block-end: 0; gap: 20px; }
+.project-case-study__header {
+ max-inline-size: 810px;
+ border: 0;
+ padding-block-end: 0;
+ gap: 20px;
+}
.project-case-study__header h3,
-.project-case-study[data-project-priority="supporting"] .project-case-study__header h3 { font-size: clamp(32px, 4vw, 60px); font-weight: 480; letter-spacing: -0.045em; }
-.project-case-study__header p { max-inline-size: 65ch; font-size: 1.05rem; line-height: 1.65; }
-.project-media-gallery { margin-block: 40px; }
-.portfolio-section .project-media { background: transparent; padding: 0; border: 0; border-radius: 0; }
-.project-media img { border-radius: 10px; border-color: var(--line); }
-.portfolio-section figcaption { margin-block-start: 15px; font-size: 0.84rem; line-height: 1.5; }
-.project-media-gallery[data-media-layout="portrait-strip"] { gap: clamp(20px, 4vw, 60px); padding: 36px clamp(16px, 6vw, 88px); border-radius: 12px; background: #172532; }
-.project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item + .project-media-gallery__item { margin-inline-start: 0; }
-.project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(even) { margin-block-start: 42px; }
-.project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(3n) { margin-block-start: 0; }
-.project-media-gallery[data-media-layout="portrait-strip"] img { box-shadow: 0 18px 40px rgb(3 9 18 / 35%); border-radius: 18px; }
-.project-media-gallery[data-media-layout="single"] { max-inline-size: 1080px; }
-.project-evidence { grid-template-columns: 1fr 1.3fr; gap: 28px 46px; margin-block-start: 32px; }
-.project-evidence > div { display: block; padding-block: 20px 0; border-block-start: 1px solid var(--line); border-block-end: 0; }
-.project-evidence > div:last-child { grid-column: 1 / -1; }
-.project-evidence dt { font-family: var(--sans); font-size: 0.9rem; text-transform: none; letter-spacing: 0; margin-block-end: 12px; }
-.project-evidence dd { font-size: 0.98rem; line-height: 1.65; }
-.project-evidence ul { columns: 2; column-gap: 48px; }
-.project-evidence li { break-inside: avoid; }
-.project-link { padding-inline: 20px; margin-block-start: 32px; min-block-size: 48px; }
-.compact-projects { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 30px 42px; }
+.project-case-study[data-project-priority='supporting']
+ .project-case-study__header
+ h3 {
+ font-size: clamp(32px, 4vw, 60px);
+ font-weight: 480;
+ letter-spacing: -0.045em;
+}
+.project-case-study__header p {
+ max-inline-size: 65ch;
+ font-size: 1.05rem;
+ line-height: 1.65;
+}
+.project-media-gallery {
+ margin-block: 40px;
+}
+.portfolio-section .project-media {
+ background: transparent;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+}
+.project-media img {
+ border-radius: 10px;
+ border-color: var(--line);
+}
+.portfolio-section figcaption {
+ margin-block-start: 15px;
+ font-size: 0.84rem;
+ line-height: 1.5;
+}
+.project-media-gallery[data-media-layout='portrait-strip'] {
+ gap: clamp(20px, 4vw, 60px);
+ padding: 36px clamp(16px, 6vw, 88px);
+ border-radius: 12px;
+ background: #172532;
+}
+.project-media-gallery[data-media-layout='portrait-strip']
+ > .project-media-gallery__item
+ + .project-media-gallery__item {
+ margin-inline-start: 0;
+}
+.project-media-gallery[data-media-layout='portrait-strip']
+ > .project-media-gallery__item:nth-child(even) {
+ margin-block-start: 42px;
+}
+.project-media-gallery[data-media-layout='portrait-strip']
+ > .project-media-gallery__item:nth-child(3n) {
+ margin-block-start: 0;
+}
+.project-media-gallery[data-media-layout='portrait-strip'] img {
+ box-shadow: 0 18px 40px rgb(3 9 18 / 35%);
+ border-radius: 18px;
+}
+.project-media-gallery[data-media-layout='single'] {
+ max-inline-size: 1080px;
+}
+.project-evidence {
+ grid-template-columns: 1fr 1.3fr;
+ gap: 28px 46px;
+ margin-block-start: 32px;
+}
+.project-evidence > div {
+ display: block;
+ padding-block: 20px 0;
+ border-block-start: 1px solid var(--line);
+ border-block-end: 0;
+}
+.project-evidence > div:last-child {
+ grid-column: 1 / -1;
+}
+.project-evidence dt {
+ font-family: var(--sans);
+ font-size: 0.9rem;
+ text-transform: none;
+ letter-spacing: 0;
+ margin-block-end: 12px;
+}
+.project-evidence dd {
+ font-size: 0.98rem;
+ line-height: 1.65;
+}
+.project-evidence ul {
+ columns: 2;
+ column-gap: 48px;
+}
+.project-evidence li {
+ break-inside: avoid;
+}
+.project-link {
+ padding-inline: 20px;
+ margin-block-start: 32px;
+ min-block-size: 48px;
+}
+.compact-projects {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 30px 42px;
+}
.project-card,
-.project-card[data-maturity] { margin-inline: 0; padding: 24px 0 0; }
-.project-card h3 { font-size: 1.5rem; font-weight: 530; letter-spacing: -0.03em; }
-.toolkit-grid { grid-template-columns: 1fr; gap: 0; }
-.toolkit-group { display: grid; grid-template-columns: minmax(150px, 0.75fr) minmax(0, 2fr); padding-block: 28px; gap: 28px; }
-.toolkit-group h3 { font-weight: 500; font-size: clamp(22px, 2vw, 30px); }
-.toolkit-items { grid-template-columns: repeat(3, minmax(0, 1fr)); margin-block: 0; gap: 12px 24px; padding: 0 !important; }
-.toolkit-item { grid-template-columns: 26px minmax(0, 1fr); gap: 12px; min-block-size: 56px; border: 0; font-size: 0.91rem; }
-.toolkit-item .brand-icon { inline-size: 24px; block-size: 24px; color: var(--text); }
-.contact-destination__copy { font-size: clamp(32px, 3vw, 46px); font-weight: 450; letter-spacing: -0.045em; max-inline-size: 22ch; }
-.contact-social { border-block-start: 1px solid var(--line); padding-block-start: 24px !important; }
-.drive-controls { inset-inline-start: 24px; inset-block-end: 22px; }
-.drive-controls__dock { border-radius: 9px; padding: 4px; }
-.drive-start { min-inline-size: 175px; border-radius: 6px; box-shadow: none; font-size: 0.64rem; letter-spacing: 0.06em; }
-.drive-control-button { border-radius: 6px; }
-.drive-controls__settings-trigger { border-radius: 8px; }
-.drive-popover { border-radius: 10px; }
-.journey-hud { inset-inline-end: 24px; inset-block-end: 22px; border-radius: 8px; padding: 14px 18px; }
+.project-card[data-maturity] {
+ margin-inline: 0;
+ padding: 24px 0 0;
+}
+.project-card h3 {
+ font-size: 1.5rem;
+ font-weight: 530;
+ letter-spacing: -0.03em;
+}
+.toolkit-grid {
+ grid-template-columns: 1fr;
+ gap: 0;
+}
+.toolkit-group {
+ display: grid;
+ grid-template-columns: minmax(150px, 0.75fr) minmax(0, 2fr);
+ padding-block: 28px;
+ gap: 28px;
+}
+.toolkit-group h3 {
+ font-weight: 500;
+ font-size: clamp(22px, 2vw, 30px);
+}
+.toolkit-items {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ margin-block: 0;
+ gap: 12px 24px;
+ padding: 0 !important;
+}
+.toolkit-item {
+ grid-template-columns: 26px minmax(0, 1fr);
+ gap: 12px;
+ min-block-size: 56px;
+ border: 0;
+ font-size: 0.91rem;
+}
+.toolkit-item .brand-icon {
+ inline-size: 24px;
+ block-size: 24px;
+ color: var(--text);
+}
+.contact-destination__copy {
+ font-size: clamp(32px, 3vw, 46px);
+ font-weight: 450;
+ letter-spacing: -0.045em;
+ max-inline-size: 22ch;
+}
+.contact-social {
+ border-block-start: 1px solid var(--line);
+ padding-block-start: 24px !important;
+}
+.drive-controls {
+ inset-inline-start: 24px;
+ inset-block-end: 22px;
+}
+.drive-controls__dock {
+ border-radius: 9px;
+ padding: 4px;
+}
+.drive-start {
+ min-inline-size: 175px;
+ border-radius: 6px;
+ box-shadow: none;
+ font-size: 0.64rem;
+ letter-spacing: 0.06em;
+}
+.drive-control-button {
+ border-radius: 6px;
+}
+.drive-controls__settings-trigger {
+ border-radius: 8px;
+}
+.drive-popover {
+ border-radius: 10px;
+}
+.journey-hud {
+ inset-inline-end: 24px;
+ inset-block-end: 22px;
+ border-radius: 8px;
+ padding: 14px 18px;
+}
@media (prefers-reduced-motion: no-preference) {
- .hero-journey__content > * { animation: grand-tour-enter 900ms cubic-bezier(0.16, 1, 0.3, 1) both; }
- .hero-journey__content > :nth-child(2) { animation-delay: 90ms; }
- .hero-journey__content > :nth-child(3) { animation-delay: 180ms; }
- .hero-journey__content > :nth-child(4) { animation-delay: 240ms; }
- .hero-journey__content > :nth-child(5) { animation-delay: 300ms; }
- .project-media img { transition: transform 650ms cubic-bezier(0.16, 1, 0.3, 1); }
- .project-media:hover img { transform: translateY(-4px); }
+ .hero-journey__content > * {
+ animation: grand-tour-enter 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
+ }
+ .hero-journey__content > :nth-child(2) {
+ animation-delay: 90ms;
+ }
+ .hero-journey__content > :nth-child(3) {
+ animation-delay: 180ms;
+ }
+ .hero-journey__content > :nth-child(4) {
+ animation-delay: 240ms;
+ }
+ .hero-journey__content > :nth-child(5) {
+ animation-delay: 300ms;
+ }
+ .project-media img {
+ transition: transform 650ms cubic-bezier(0.16, 1, 0.3, 1);
+ }
+ .project-media:hover img {
+ transform: translateY(-4px);
+ }
@supports (animation-timeline: view()) {
- .portfolio-section > h2, .project-case-study__header { animation: grand-tour-enter linear both; animation-timeline: view(); animation-range: entry 0% entry 95%; }
+ .portfolio-section > h2,
+ .project-case-study__header {
+ animation: grand-tour-enter linear both;
+ animation-timeline: view();
+ animation-range: entry 0% entry 95%;
+ }
+ }
+}
+@keyframes grand-tour-enter {
+ from {
+ opacity: 0;
+ transform: translateY(24px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
}
}
-@keyframes grand-tour-enter { from { opacity: 0; transform: translateY(24px); } to { opacity: 1; transform: translateY(0); } }
@media (min-width: 768px) and (max-width: 1100px) {
- .hero-journey__content { inline-size: 55%; }
- .hero-journey h1 { font-size: clamp(76px, 9vw, 100px); }
- .portfolio-section[data-layout="right-rail"] { inline-size: 65%; margin-inline: 30% 5%; }
- .portfolio-section[data-layout="left-timeline"] { inline-size: 75%; margin-inline: 5% 20%; }
- .experience-record { display: block; }
- .experience-record ul { margin-block-start: 20px; }
- .portfolio-section[data-layout="full-diagram"], .portfolio-section[data-layout="toolkit-field"] { inline-size: 90%; margin-inline: 5%; }
- .infrastructure-flow { padding-inline: 0; }
- .infrastructure-flow__route { gap: 36px 20px; }
- .infrastructure-node { padding: 12px; }
- .infrastructure-node__connector { inline-size: 20px; }
- .infrastructure-boundary { font-size: 0.51rem; }
- .toolkit-items { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .portfolio-section[data-layout="compact-rail"] { inline-size: 90%; margin-inline: 5%; }
+ .hero-journey__content {
+ inline-size: 55%;
+ }
+ .hero-journey h1 {
+ font-size: clamp(76px, 9vw, 100px);
+ }
+ .portfolio-section[data-layout='right-rail'] {
+ inline-size: 65%;
+ margin-inline: 30% 5%;
+ }
+ .portfolio-section[data-layout='left-timeline'] {
+ inline-size: 75%;
+ margin-inline: 5% 20%;
+ }
+ .experience-record {
+ display: block;
+ }
+ .experience-record ul {
+ margin-block-start: 20px;
+ }
+ .portfolio-section[data-layout='full-diagram'],
+ .portfolio-section[data-layout='toolkit-field'] {
+ inline-size: 90%;
+ margin-inline: 5%;
+ }
+ .infrastructure-flow {
+ padding-inline: 0;
+ }
+ .infrastructure-flow__route {
+ gap: 36px 20px;
+ }
+ .infrastructure-node {
+ padding: 12px;
+ }
+ .infrastructure-node__connector {
+ inline-size: 20px;
+ }
+ .infrastructure-boundary {
+ font-size: 0.51rem;
+ }
+ .toolkit-items {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .portfolio-section[data-layout='compact-rail'] {
+ inline-size: 90%;
+ margin-inline: 5%;
+ }
}
@media (max-width: 767px) {
- :root { --site-navigation-block-size: 70px; }
- .site-navigation { inset: 10px 12px auto; }
- .site-navigation summary { min-block-size: 48px; }
- .site-navigation ul { padding: 8px; border-radius: 0 0 10px 10px; }
- .hero-journey { min-block-size: calc(100dvh - 70px); padding: 32px 24px 260px; }
- .hero-journey__content { inline-size: 100%; max-inline-size: 460px; gap: 16px; background: none; padding: 0; }
- .hero-journey h1 { font-size: clamp(62px, 17vw, 94px); line-height: 0.98; }
- .hero-journey__role { font-size: 20px; max-inline-size: 30ch; }
- .hero-journey__value { font-size: 0.96rem; max-inline-size: 33ch; }
- .hero-journey__availability { font-size: 0.78rem; }
- .hero-journey__actions { gap: 20px; }
- .hero-journey__action { font-size: 0.83rem; }
- .hero-journey__action--primary { min-block-size: 46px; padding-inline: 18px; }
- .portfolio-section, .portfolio-section[data-layout] { inline-size: calc(100% - 24px); margin: 110px 12px; padding: 28px 22px; background: var(--surface-strong); border-radius: 14px; }
- .portfolio-section[data-layout="project-stage"] { padding: 0; background: transparent; }
- .portfolio-section > h2 { font-size: 44px; margin-block-end: 26px; }
- .portfolio-section[data-layout="project-stage"] > h2, .portfolio-section[data-layout="project-stage"] > .section-eyebrow { margin-inline: 12px; }
- .portfolio-section[data-layout="right-rail"] > p { font-size: 20px; }
- .experience-record { display: block; }
- .experience-record ul { margin-block-start: 18px; }
- .systems-operations { padding: 20px; }
- .operations-proof { padding: 20px; }
- .portfolio-section .infrastructure-flow-figure { padding: 15px; }
- .infrastructure-node { min-block-size: 125px; }
- .project-case-study, .project-case-study[data-project-priority="supporting"], .project-case-study[data-project-id][data-media-layout] { padding: 28px 22px; border-radius: 14px; }
- .project-case-study__header h3, .project-case-study[data-project-priority="supporting"] .project-case-study__header h3 { font-size: 34px; }
- .project-case-study__header p { font-size: 1rem; }
- .project-media-gallery[data-media-layout="portrait-strip"] { grid-template-columns: minmax(0, 1fr); padding: 22px; gap: 32px; }
- .project-media-gallery[data-media-layout="portrait-strip"] > .project-media-gallery__item:nth-child(even) { margin-block-start: 0; }
- .project-media-gallery[data-media-layout="portrait-strip"] img { max-inline-size: 290px; margin-inline: auto; }
- .project-evidence { grid-template-columns: 1fr; gap: 12px; }
- .project-evidence ul { columns: auto; }
- .compact-projects { grid-template-columns: 1fr; }
- .toolkit-group { grid-template-columns: 1fr; gap: 20px; }
- .toolkit-items { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
- .toolkit-item { font-size: 0.82rem; gap: 8px; }
- .drive-controls { inset-inline-start: 10px; inset-block-end: 10px; }
- .drive-start { min-inline-size: 144px; font-size: 0.58rem; }
- .journey-hud { inset-inline-end: 10px; inset-block-end: 10px; max-inline-size: 90px; padding: 14px 10px; }
- .contact-destination__copy { font-size: 34px; }
+ :root {
+ --site-navigation-block-size: 70px;
+ }
+ .site-navigation {
+ inset: 10px 12px auto;
+ }
+ .site-navigation summary {
+ min-block-size: 48px;
+ }
+ .site-navigation ul {
+ padding: 8px;
+ border-radius: 0 0 10px 10px;
+ }
+ .hero-journey {
+ min-block-size: calc(100dvh - 70px);
+ padding: 32px 24px 260px;
+ }
+ .hero-journey__content {
+ inline-size: 100%;
+ max-inline-size: 460px;
+ gap: 16px;
+ background: none;
+ padding: 0;
+ }
+ .hero-journey h1 {
+ font-size: clamp(62px, 17vw, 94px);
+ line-height: 0.98;
+ }
+ .hero-journey__role {
+ font-size: 20px;
+ max-inline-size: 30ch;
+ }
+ .hero-journey__value {
+ font-size: 0.96rem;
+ max-inline-size: 33ch;
+ }
+ .hero-journey__availability {
+ font-size: 0.78rem;
+ }
+ .hero-journey__actions {
+ gap: 20px;
+ }
+ .hero-journey__action {
+ font-size: 0.83rem;
+ }
+ .hero-journey__action--primary {
+ min-block-size: 46px;
+ padding-inline: 18px;
+ }
+ .portfolio-section,
+ .portfolio-section[data-layout] {
+ inline-size: calc(100% - 24px);
+ margin: 110px 12px;
+ padding: 28px 22px;
+ background: var(--surface-strong);
+ border-radius: 14px;
+ }
+ .portfolio-section[data-layout='project-stage'] {
+ padding: 0;
+ background: transparent;
+ }
+ .portfolio-section > h2 {
+ font-size: 44px;
+ margin-block-end: 26px;
+ }
+ .portfolio-section[data-layout='project-stage'] > h2,
+ .portfolio-section[data-layout='project-stage'] > .section-eyebrow {
+ margin-inline: 12px;
+ }
+ .portfolio-section[data-layout='right-rail'] > p {
+ font-size: 20px;
+ }
+ .experience-record {
+ display: block;
+ }
+ .experience-record ul {
+ margin-block-start: 18px;
+ }
+ .systems-operations {
+ padding: 20px;
+ }
+ .operations-proof {
+ padding: 20px;
+ }
+ .portfolio-section .infrastructure-flow-figure {
+ padding: 15px;
+ }
+ .infrastructure-node {
+ min-block-size: 125px;
+ }
+ .project-case-study,
+ .project-case-study[data-project-priority='supporting'],
+ .project-case-study[data-project-id][data-media-layout] {
+ padding: 28px 22px;
+ border-radius: 14px;
+ }
+ .project-case-study__header h3,
+ .project-case-study[data-project-priority='supporting']
+ .project-case-study__header
+ h3 {
+ font-size: 34px;
+ }
+ .project-case-study__header p {
+ font-size: 1rem;
+ }
+ .project-media-gallery[data-media-layout='portrait-strip'] {
+ grid-template-columns: minmax(0, 1fr);
+ padding: 22px;
+ gap: 32px;
+ }
+ .project-media-gallery[data-media-layout='portrait-strip']
+ > .project-media-gallery__item:nth-child(even) {
+ margin-block-start: 0;
+ }
+ .project-media-gallery[data-media-layout='portrait-strip'] img {
+ max-inline-size: 290px;
+ margin-inline: auto;
+ }
+ .project-evidence {
+ grid-template-columns: 1fr;
+ gap: 12px;
+ }
+ .project-evidence ul {
+ columns: auto;
+ }
+ .compact-projects {
+ grid-template-columns: 1fr;
+ }
+ .toolkit-group {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+ .toolkit-items {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px;
+ }
+ .toolkit-item {
+ font-size: 0.82rem;
+ gap: 8px;
+ }
+ .drive-controls {
+ inset-inline-start: 10px;
+ inset-block-end: 10px;
+ }
+ .drive-start {
+ min-inline-size: 144px;
+ font-size: 0.58rem;
+ }
+ .journey-hud {
+ inset-inline-end: 10px;
+ inset-block-end: 10px;
+ max-inline-size: 90px;
+ padding: 14px 10px;
+ }
+ .contact-destination__copy {
+ font-size: 34px;
+ }
}
@media (max-width: 360px) {
- .hero-journey { padding-inline: 20px; }
- .hero-journey h1 { font-size: 59px; }
- .hero-journey__role { font-size: 18px; }
- .drive-start { min-inline-size: 132px; padding-inline: 8px; }
- .journey-hud { max-inline-size: 78px; font-size: 0.6rem; }
+ .hero-journey {
+ padding-inline: 20px;
+ }
+ .hero-journey h1 {
+ font-size: 59px;
+ }
+ .hero-journey__role {
+ font-size: 18px;
+ }
+ .drive-start {
+ min-inline-size: 132px;
+ padding-inline: 8px;
+ }
+ .journey-hud {
+ max-inline-size: 78px;
+ font-size: 0.6rem;
+ }
}
@media (max-height: 650px) and (min-width: 768px) {
- .hero-journey { padding-block: 24px 90px; }
- .hero-journey__content { gap: 12px; inline-size: 52%; }
- .hero-journey h1 { font-size: 68px; max-inline-size: none; }
- .hero-journey__role { font-size: 20px; }
- .hero-journey__value { font-size: 0.92rem; }
+ .hero-journey {
+ padding-block: 24px 90px;
+ }
+ .hero-journey__content {
+ gap: 12px;
+ inline-size: 52%;
+ }
+ .hero-journey h1 {
+ font-size: 68px;
+ max-inline-size: none;
+ }
+ .hero-journey__role {
+ font-size: 20px;
+ }
+ .hero-journey__value {
+ font-size: 0.92rem;
+ }
}
@media print {
- .hero-journey__content > *, .portfolio-section > h2, .project-case-study__header { animation: none !important; }
+ .hero-journey__content > *,
+ .portfolio-section > h2,
+ .project-case-study__header {
+ animation: none !important;
+ }
}
/* An open atmospheric scrim protects copy without restoring the old hero card. */
-.hero-journey { isolation: isolate; }
+.hero-journey {
+ isolation: isolate;
+}
.hero-journey::before {
content: '';
position: absolute;
z-index: -1;
inset: -76px 0 0;
pointer-events: none;
- background: linear-gradient(90deg, rgb(8 13 24 / 98%), rgb(8 13 24 / 88%) 31%, rgb(8 13 24 / 25%) 51%, transparent 70%);
+ background: linear-gradient(
+ 90deg,
+ rgb(8 13 24 / 98%),
+ rgb(8 13 24 / 88%) 31%,
+ rgb(8 13 24 / 25%) 51%,
+ transparent 70%
+ );
}
@media (min-width: 1100px) {
- .project-case-study[data-media-layout="none"] {
+ .project-case-study[data-media-layout='none'] {
display: grid;
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
gap: 0 64px;
}
- .project-case-study[data-media-layout="none"] .project-case-study__header { grid-column: 1; align-content: start; }
- .project-case-study[data-media-layout="none"] .project-evidence { grid-column: 2; grid-row: 1 / span 2; margin-block-start: 0; grid-template-columns: 1fr; }
- .project-case-study[data-media-layout="none"] .project-evidence ul { columns: auto; }
- .project-case-study[data-media-layout="none"] .project-link { grid-column: 1; align-self: end; }
+ .project-case-study[data-media-layout='none'] .project-case-study__header {
+ grid-column: 1;
+ align-content: start;
+ }
+ .project-case-study[data-media-layout='none'] .project-evidence {
+ grid-column: 2;
+ grid-row: 1 / span 2;
+ margin-block-start: 0;
+ grid-template-columns: 1fr;
+ }
+ .project-case-study[data-media-layout='none'] .project-evidence ul {
+ columns: auto;
+ }
+ .project-case-study[data-media-layout='none'] .project-link {
+ grid-column: 1;
+ align-self: end;
+ }
}
@media (max-width: 767px) {
- .hero-journey::before { inset-block-start: -70px; background: linear-gradient(180deg, rgb(8 13 24 / 98%) 0%, rgb(8 13 24 / 92%) 43%, rgb(8 13 24 / 35%) 64%, transparent 85%); }
+ .hero-journey::before {
+ inset-block-start: -70px;
+ background: linear-gradient(
+ 180deg,
+ rgb(8 13 24 / 98%) 0%,
+ rgb(8 13 24 / 92%) 43%,
+ rgb(8 13 24 / 35%) 64%,
+ transparent 85%
+ );
+ }
}
/* Dense approved copy uses a reading scale, with hierarchy carried by headings. */
-.contact-destination__copy { font-size: clamp(26px, 2.25vw, 34px); line-height: 1.25; max-inline-size: 30ch; }
-.portfolio-section .toolkit-item { margin-block-start: 0; }
-.project-media--image > a { display: block; max-inline-size: none; cursor: zoom-in; border-radius: 10px; }
-.project-media--image > a:focus-visible { outline-offset: 6px; }
-.journey-hud__route { flex-shrink: 0; white-space: nowrap; }
-.journey-hud__section { min-inline-size: 0; }
+.contact-destination__copy {
+ font-size: clamp(26px, 2.25vw, 34px);
+ line-height: 1.25;
+ max-inline-size: 30ch;
+}
+.portfolio-section .toolkit-item {
+ margin-block-start: 0;
+}
+.project-media--image > a {
+ display: block;
+ max-inline-size: none;
+ cursor: zoom-in;
+ border-radius: 10px;
+}
+.project-media--image > a:focus-visible {
+ outline-offset: 6px;
+}
+.journey-hud__route {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+.journey-hud__section {
+ min-inline-size: 0;
+}
@media (min-width: 768px) and (max-aspect-ratio: 59/50) {
- .hero-journey::before { background: linear-gradient(180deg, rgb(8 13 24 / 96%) 0%, rgb(8 13 24 / 90%) 55%, rgb(8 13 24 / 28%) 73%, transparent 90%); }
+ .hero-journey::before {
+ background: linear-gradient(
+ 180deg,
+ rgb(8 13 24 / 96%) 0%,
+ rgb(8 13 24 / 90%) 55%,
+ rgb(8 13 24 / 28%) 73%,
+ transparent 90%
+ );
+ }
}
@media (max-width: 767px) {
- .hero-journey::before { background: linear-gradient(180deg, rgb(8 13 24 / 98%) 0%, rgb(8 13 24 / 92%) 53%, rgb(8 13 24 / 48%) 66%, transparent 85%); }
+ .hero-journey::before {
+ background: linear-gradient(
+ 180deg,
+ rgb(8 13 24 / 98%) 0%,
+ rgb(8 13 24 / 92%) 53%,
+ rgb(8 13 24 / 48%) 66%,
+ transparent 85%
+ );
+ }
+}
+
+/* The live scene has a genuine viewing corridor below navigation. */
+html[data-expressway='live'] .portfolio-section {
+ scroll-margin-block-start: calc(var(--sign-stage-bottom, 188px) + 18px);
+}
+@media print {
+ .portfolio-main {
+ clip-path: none !important;
+ }
+}
+
+/* A more deliberate typographic cadence; approved role wording stays intact. */
+.hero-journey__role {
+ text-wrap: balance;
+}
+.site-navigation a {
+ transition:
+ background-color 220ms ease,
+ color 220ms ease;
+}
+.site-navigation a:hover {
+ background: rgb(158 220 226 / 8%);
+}
+.portfolio-section > h2 {
+ text-wrap: balance;
+}
+.project-case-study {
+ box-shadow:
+ 0 25px 100px rgb(0 7 15 / 28%),
+ inset 0 1px rgb(195 232 240 / 5%);
+}
+.project-case-study__header {
+ border-inline-start: 2px solid var(--line-strong);
+ padding-inline-start: 24px;
+}
+.project-media-gallery[data-media-layout='portrait-strip'] {
+ background: radial-gradient(ellipse at 50% 30%, #203a47, #101e2a 80%);
+ border-block: 1px solid var(--line);
+}
+.project-media-gallery[data-media-layout='portrait-strip']
+ > .project-media-gallery__item:nth-child(2) {
+ margin-block-start: 56px;
+}
+.project-media-gallery[data-media-layout='portrait-strip'] figcaption {
+ max-inline-size: 30ch;
+}
+.toolkit-group + .toolkit-group {
+ border-block-start: 1px solid var(--line);
+}
+.toolkit-item {
+ transition: background-color 220ms ease;
+ border-radius: 5px;
+ padding-inline: 8px;
+}
+.toolkit-item:hover {
+ background: rgb(158 220 226 / 6%);
+}
+@media (max-width: 767px) {
+ .project-case-study__header {
+ padding-inline-start: 14px;
+ }
+ .project-media-gallery[data-media-layout='portrait-strip']
+ > .project-media-gallery__item:nth-child(2) {
+ margin-block-start: 0;
+ }
+}
+
+/* Measured chapter anchors must not move during their reveal: journey/audio
+ measure these headings. Reveal opacity alone, leaving their geometry stable. */
+@media (prefers-reduced-motion: no-preference) {
+ @supports (animation-timeline: view()) {
+ .portfolio-section > h2 {
+ animation-name: expressway-heading;
+ }
+ }
+}
+@keyframes expressway-heading {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+@media (max-height: 650px) {
+ html[data-expressway='live'] .portfolio-section {
+ scroll-margin-block-start: calc(var(--sign-stage-bottom) - 28px);
+ }
+}
+@media (min-width: 768px) and (max-width: 900px) {
+ .infrastructure-node strong {
+ font-size: 14px;
+ letter-spacing: -0.025em;
+ }
+}
+
+@media (max-width: 360px) {
+ .portfolio-section > h2 {
+ font-size: 32px;
+ }
+}
+@media (min-width: 768px) and (max-height: 559px) {
+ html[data-expressway='live'] .portfolio-section[data-layout] {
+ inline-size: 60%;
+ margin-inline: 5% 35%;
+ padding: 24px;
+ }
+ html[data-expressway='live'] .portfolio-section[data-layout='project-stage'] {
+ padding: 0;
+ }
+ .portfolio-section > h2 {
+ font-size: 38px;
+ }
+ .experience-record {
+ display: block;
+ }
+ .experience-record ul {
+ margin-block-start: 18px;
+ }
+ .compact-projects,
+ .toolkit-group,
+ .toolkit-items {
+ grid-template-columns: 1fr;
+ }
}