California gets roads, traffic, and a car to follow
This commit is contained in:
@@ -13,6 +13,7 @@
|
|||||||
# clean-clone a fresh checkout installs, builds and passes its tests
|
# clean-clone a fresh checkout installs, builds and passes its tests
|
||||||
# zero-config-boot the API answers health when handed nothing at all
|
# zero-config-boot the API answers health when handed nothing at all
|
||||||
# no-binary-art src/** carries no committed binary assets
|
# no-binary-art src/** carries no committed binary assets
|
||||||
|
# dependency-terms every locked package uses a reviewed permissive license
|
||||||
#
|
#
|
||||||
# They are deliberately independent and run in parallel: a broken build should
|
# They are deliberately independent and run in parallel: a broken build should
|
||||||
# not hide a licensing regression.
|
# not hide a licensing regression.
|
||||||
@@ -120,3 +121,23 @@ jobs:
|
|||||||
|
|
||||||
- name: no binary art under src/
|
- name: no binary art under src/
|
||||||
run: node scripts/check-no-binaries.mjs
|
run: node scripts/check-no-binaries.mjs
|
||||||
|
|
||||||
|
# ---- Job 4: package terms stay inside the reviewed perimeter ----
|
||||||
|
#
|
||||||
|
# Lockfile metadata makes this dependency-free. A new license stops here and
|
||||||
|
# gets reviewed explicitly instead of arriving transitively with a routine
|
||||||
|
# version bump.
|
||||||
|
dependency-terms:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "24"
|
||||||
|
|
||||||
|
- name: dependency license allowlist
|
||||||
|
run: node scripts/check-dependency-licenses.mjs
|
||||||
|
|
||||||
|
- name: SPDX SBOM can be generated from the lockfile
|
||||||
|
run: npm sbom --package-lock-only --sbom-format=spdx > /tmp/tera.spdx.json
|
||||||
|
|||||||
+23
-2
@@ -1,6 +1,6 @@
|
|||||||
# Tera — architecture
|
# Tera — architecture
|
||||||
|
|
||||||
**Tera** is the map view: a city from above, rendered in three.js, public and
|
**Tera** is the map view: California and its cities from above, rendered in three.js, public and
|
||||||
self-hostable. **Spaces** is the other half — the offices you walk into. They
|
self-hostable. **Spaces** is the other half — the offices you walk into. They
|
||||||
are one engine and one asset library, seen from outside and from inside.
|
are one engine and one asset library, seen from outside and from inside.
|
||||||
|
|
||||||
@@ -78,9 +78,11 @@ lumbridge-simulate/
|
|||||||
│ └── scene.ts # lights, sky, camera flights, render loop
|
│ └── scene.ts # lights, sky, camera flights, render loop
|
||||||
├── src/interiors/ # the inside of the world — floorplans, desks, presence
|
├── src/interiors/ # the inside of the world — floorplans, desks, presence
|
||||||
├── src/assets/ # the library. Original meshes and materials, Apache 2.0.
|
├── src/assets/ # the library. Original meshes and materials, Apache 2.0.
|
||||||
|
├── src/transport/ # serialisable route packs + renderer-free fixed-step sim
|
||||||
├── src/cities/ # data packs. Pure geography, no code.
|
├── src/cities/ # data packs. Pure geography, no code.
|
||||||
|
│ ├── california.ts # state/corridor scale; LA ↔ SF continuity
|
||||||
│ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks
|
│ ├── sf.ts # ~1000 lines of coastline, hills, districts, landmarks
|
||||||
│ └── la.ts # LA / OC / Riverside
|
│ └── socal.ts # LA / OC / Riverside
|
||||||
├── src/adapters/ # where outside data plugs in
|
├── src/adapters/ # where outside data plugs in
|
||||||
│ └── workie.ts # Workie API -> Marker[]
|
│ └── workie.ts # Workie API -> Marker[]
|
||||||
└── src/main.ts # the standalone demo app
|
└── src/main.ts # the standalone demo app
|
||||||
@@ -97,6 +99,25 @@ and a city share the projection, camera, lighting and render loop, and differ
|
|||||||
only in what they put in the scene. That is why `scene.ts` owns the loop and
|
only in what they put in the scene. That is why `scene.ts` owns the loop and
|
||||||
knows nothing about terrain specifically.
|
knows nothing about terrain specifically.
|
||||||
|
|
||||||
|
`transport` also stays outside the renderer. A `TransportPack` is plain JSON and
|
||||||
|
`VehicleSimulation` returns plain geographic poses. Three.js enters only in the
|
||||||
|
`roadTraffic` render layer, which projects poses through the active `World`.
|
||||||
|
That separation lets a future authoritative session server replay the same
|
||||||
|
route simulation without importing a browser or a GPU.
|
||||||
|
|
||||||
|
### 2.1 Product/control-plane boundary
|
||||||
|
|
||||||
|
Tera/Spaces is the authoritative renderer and simulation client. The separate
|
||||||
|
`lumbridgecorp` service remains the control plane: identity, tenant registry,
|
||||||
|
membership, billing, provisioning, and short-lived launch grants. It must not
|
||||||
|
grow a second world renderer or own frame-by-frame simulation.
|
||||||
|
|
||||||
|
The public Tera build remains zero-config and useful without any service. Hosted
|
||||||
|
presence, multiplayer, profiles, webcam faces, and media surfaces arrive through
|
||||||
|
adapters whose absence cannot stop the city, office, traffic, or sky from
|
||||||
|
running. This is the replacement path for the retired Phaser World 1; it is not
|
||||||
|
a migration of that renderer.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. Three rules that keep Apache 2.0 honest
|
## 3. Three rules that keep Apache 2.0 honest
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Asset intake research
|
||||||
|
|
||||||
|
Tera can learn from an open-source asset without turning that project into a
|
||||||
|
dependency. The default is to internalise the capability: identify the smallest
|
||||||
|
useful visual rule, rebuild it against Tera's contracts, and keep the result
|
||||||
|
code-only and deterministic. Copy source only when a rewrite would add no value,
|
||||||
|
and copy no binary art into `src/**`.
|
||||||
|
|
||||||
|
This review was performed on 2026-08-11 for the first office exteriors.
|
||||||
|
|
||||||
|
## California corridor vehicle
|
||||||
|
|
||||||
|
The black corridor vehicle is an original procedural Three.js asset authored in
|
||||||
|
`src/assets/vehicles/modelX.ts`. An AI-generated image was used only as a
|
||||||
|
non-shipping art-direction reference for stance, surface hierarchy, camera
|
||||||
|
distance, and late-afternoon contrast. No pixels, mesh, logo, texture, or source
|
||||||
|
file from that reference is distributed by Tera. The implementation is an
|
||||||
|
unbadged code-generated crossover with two explicit detail tiers, shared
|
||||||
|
geometry/material cloning, named wheel joints, and deterministic tests.
|
||||||
|
|
||||||
|
The corridor route pack is also hand-authored. Its nodes are coarse simulation
|
||||||
|
waypoints, not navigation data, and each segment records its own provenance and
|
||||||
|
fitness note in `src/transport/california.ts`. I-5 is presented honestly with
|
||||||
|
its I-580 and I-80 connection into San Francisco.
|
||||||
|
|
||||||
|
## Actor set
|
||||||
|
|
||||||
|
The customizable humanoid, anonymous office dog, and anonymous Tera crow under
|
||||||
|
`src/assets/actors/` are original procedural TypeScript. They use no downloaded
|
||||||
|
mesh, texture, rig, animation, logo, or motion-capture data. Each asset exposes
|
||||||
|
named pose joints, metre-scale metrics, shared-resource cloning, and explicit
|
||||||
|
disposal so a later presence layer can animate many actors without changing the
|
||||||
|
asset or ownership contracts. A caller may provide a face texture to the
|
||||||
|
humanoid, but that texture remains caller-owned and is never bundled by core.
|
||||||
|
|
||||||
|
## Sources assessed
|
||||||
|
|
||||||
|
| Source | Licence and useful part | Decision for Tera |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| [BasicProceduralBuilding](https://github.com/achrefelouafi/BasicProceduralBuilding/tree/f97984d1c2e5fae484a6c7d0dc01f721c117a6ba) | MIT. A façade grid classifies ground, window, corner, and roof cells, then instances pieces from a Blender-authored GLB kit. | Keep the façade grammar. Do not import the GLB, its coordinate conversion, or its per-kit-mesh runtime. Tera's office glyph implementation is original code at map scale. |
|
||||||
|
| [KayKit City Builder Bits 1.0](https://github.com/KayKit-Game-Assets/KayKit-City-Builder-Bits-1.0/tree/63976910ca04d16f0fc531b9c614244be8128713) | CC0 low-poly city pieces in GLTF, OBJ, and FBX, with a shared texture atlas. | Good silhouette reference and a viable source for a future optional art pack. Do not place its binaries in core `src/**`; core remains code-only. |
|
||||||
|
| [img2threejs](https://github.com/img2threejs/img2threejs) | Apache-2.0 procedural Three.js reconstruction code. | Workflow reference only. Its image-to-scene problem is broader than office exteriors. |
|
||||||
|
| [Claude-of-Duty](https://github.com/mshumer/Claude-of-Duty) | MIT, code-generated materials and modular Three.js buildings. | Reference for generated-material and browser-QA techniques. It is too large and game-specific to copy wholesale. |
|
||||||
|
| [Khronos glTF Sample Assets](https://github.com/KhronosGroup/glTF-Sample-Assets/blob/main/Models/Models.md) | A useful interoperability corpus, but every model has its own licence. | Test fixtures only after checking the individual asset's licence; it is not one uniformly reusable art library. |
|
||||||
|
| [pmndrs market](https://github.com/pmndrs/market) | CC0 asset registry and delivery tooling. | Discovery source, not a runtime dependency. Tera does not need an asset API to draw three map glyphs. |
|
||||||
|
| [open-source-3D-assets](https://github.com/ToxSam/open-source-3D-assets) | A catalogue of downloadable assets with licences recorded per entry. | Discovery source only; verify the original file and licence before any intake. |
|
||||||
|
|
||||||
|
## Pilot decision: office buildings on the city board
|
||||||
|
|
||||||
|
The reusable idea from BasicProceduralBuilding is its grammar, not its kit.
|
||||||
|
At Tera's city scale, one-metre modules are usually sub-pixel. Recreating them
|
||||||
|
as individual geometry would spend triangles and draw calls on information the
|
||||||
|
camera cannot retain. The Tera-native implementation therefore uses:
|
||||||
|
|
||||||
|
- a small serialisable `BuildingGlyph` on the existing generic `Marker` type;
|
||||||
|
- pure layouts for tower, hangar, courtyard, and block silhouettes;
|
||||||
|
- one instanced window mesh per destination, plus a few solid shell pieces;
|
||||||
|
- seeded façade variation, so reloads never reshuffle the skyline;
|
||||||
|
- reserved city footprints, so the stable glyph supersedes an anonymous block
|
||||||
|
or a coarser hand-authored landmark at the same address instead of nesting;
|
||||||
|
- the existing marker raycaster and office-open path, so no office semantics
|
||||||
|
enter the engine.
|
||||||
|
|
||||||
|
No third-party source or binary asset was copied for this pilot, so no new
|
||||||
|
third-party attribution is required in `NOTICE`. The links above record the
|
||||||
|
research provenance and the implementation comments record the design lineage.
|
||||||
|
|
||||||
|
## Intake rules
|
||||||
|
|
||||||
|
Before copying any future asset or source file:
|
||||||
|
|
||||||
|
1. Pin the upstream URL and commit, and record exactly which files are copied.
|
||||||
|
2. Prefer CC0 for artistic output. Apache-2.0, MIT, and BSD code can be used only
|
||||||
|
with their required licence and notice text preserved. A Tera rewrite does
|
||||||
|
not erase upstream authorship when source was actually copied.
|
||||||
|
3. Do not intake unlicensed work, non-commercial licences, share-alike content,
|
||||||
|
logos, trademarks, or scraped map/imagery data.
|
||||||
|
4. Keep binary art out of `src/**`. A future optional art pack belongs behind a
|
||||||
|
registry or self-hosted `/art/*` URL and must keep its own provenance ledger.
|
||||||
|
5. Copy the smallest coherent part, translate it to Tera's data and disposal
|
||||||
|
conventions, add deterministic tests, and verify it in the GPU browser.
|
||||||
|
6. Update `NOTICE` in the same change whenever copied source or bundled art has
|
||||||
|
an attribution or licence-preservation requirement.
|
||||||
|
|
||||||
|
## Useful next targets
|
||||||
|
|
||||||
|
The office catalogue already covers the common furniture. The most valuable
|
||||||
|
remaining procedural pieces are circulation and place-making rather than more
|
||||||
|
chairs: a stair/landing asset for multi-storey packs, a kitchen island/service
|
||||||
|
counter, and a small exterior plaza kit (planters, bollards, and benches). Each
|
||||||
|
is simple enough to author in Tera's metre-based asset registry; use a CC0 kit
|
||||||
|
such as KayKit as shape reference only when it materially improves the result.
|
||||||
+165
@@ -0,0 +1,165 @@
|
|||||||
|
# Tera / Spaces build plan
|
||||||
|
|
||||||
|
This is the execution order for the California world. It is intentionally a
|
||||||
|
dependency plan rather than a feature wish list: each milestone leaves behind a
|
||||||
|
tested substrate the next one can safely use.
|
||||||
|
|
||||||
|
## North star
|
||||||
|
|
||||||
|
One continuous California world at three nested scales:
|
||||||
|
|
||||||
|
1. **State / corridor** — Los Angeles to San Francisco, route traffic, flyover.
|
||||||
|
2. **City / exterior** — detailed Bay Area and Southern California boards,
|
||||||
|
office buildings as real destinations.
|
||||||
|
3. **Office / metre scale** — walkable interiors, people, rooms, and screens.
|
||||||
|
|
||||||
|
Every scale supports **Observe**. Selected actors and vehicles additionally
|
||||||
|
support **Play**. Public geometry and ambient simulation work from a static
|
||||||
|
clone; identity, private presence, realtime state, webcam faces, and shared
|
||||||
|
screens are hosted capabilities layered on top.
|
||||||
|
|
||||||
|
## Work lanes
|
||||||
|
|
||||||
|
| Lane | Owns | Can run in parallel with |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| World + simulation | transport packs, cells, origin rebasing, controllers | assets, UI, service contracts |
|
||||||
|
| Asset craft | vehicles, humanoids, dog, crow, buildings, animation | every milestone once interfaces are frozen |
|
||||||
|
| Product + camera | chapters, follow/chase/walk cameras, input, accessibility | simulation and assets |
|
||||||
|
| Realtime + media | session authority, interest zones, ACLs, WebRTC surfaces | solo play after state contracts exist |
|
||||||
|
| Quality | deterministic replays, browser captures, performance budgets, provenance | continuous; never a final cleanup phase |
|
||||||
|
|
||||||
|
The critical path is world coordinates → deterministic control → walkable actor
|
||||||
|
contract → authoritative sessions → private media. Art can stay one milestone
|
||||||
|
ahead; realtime/media must not jump ahead of the actor and access contracts.
|
||||||
|
|
||||||
|
## M0 — contracts and provenance
|
||||||
|
|
||||||
|
Status: **in progress**.
|
||||||
|
|
||||||
|
- Tera/Spaces is the renderer; `lumbridgecorp` is the control plane.
|
||||||
|
- Apache-2.0 remains the outbound code license.
|
||||||
|
- Every copied asset/data item gets a pinned source, license, hash, and intake
|
||||||
|
note. Original procedural assets record their design lineage.
|
||||||
|
- Add an automated asset-manifest gate, dependency license allowlist, and SBOM.
|
||||||
|
|
||||||
|
Exit gate: a clean clone builds and tests without private services or binary
|
||||||
|
art; every shipped dependency, route, font, and asset has recorded provenance.
|
||||||
|
|
||||||
|
## M1 — California roads and passive Model X traffic
|
||||||
|
|
||||||
|
Status: **implemented and rendered on desktop and mobile; performance-budget
|
||||||
|
instrumentation remains**.
|
||||||
|
|
||||||
|
- Coarse California board plus detailed Bay Area and SoCal boards.
|
||||||
|
- Serializable US-101 and I-5/I-580/I-80 route graphs.
|
||||||
|
- Procedural black Model X with follow and corridor detail tiers.
|
||||||
|
- Fixed-step deterministic traffic, both carriageways, instanced background
|
||||||
|
vehicles, and a chase/follow camera selected from route chapters.
|
||||||
|
|
||||||
|
Exit gate: one hero completes either route on the road; the same seed and frame
|
||||||
|
sequence reproduce the same poses; route switching leaks no GPU resources; the
|
||||||
|
declared desktop/mobile frame budgets pass in browser capture.
|
||||||
|
|
||||||
|
## M2 — solo playable driving
|
||||||
|
|
||||||
|
Status: **deterministic control core plus keyboard, standard gamepad, touch,
|
||||||
|
chase, and driver-height camera integration implemented and rendered on desktop
|
||||||
|
and mobile**. The vehicle owns a controller state separate from its render rig.
|
||||||
|
|
||||||
|
- Keyboard, gamepad, and touch input through one normalized action map.
|
||||||
|
- Chase and driver-height cameras.
|
||||||
|
- Assisted route following as a state machine; manual input takes over and can
|
||||||
|
hand back cleanly.
|
||||||
|
- Route guardrails, recovery/reset, pause and background-tab recovery.
|
||||||
|
- Fixed input traces for deterministic replay tests.
|
||||||
|
|
||||||
|
Exit gate: finish both routes manually or assisted; no NaNs, ordinary-speed
|
||||||
|
tunneling, or tab-resume jumps; a recorded input trace replays identically.
|
||||||
|
|
||||||
|
## M3 — walkable offices and actors
|
||||||
|
|
||||||
|
Status: **collision/controller foundation and procedural actor assets
|
||||||
|
implemented; office camera/input integration and identity handoff pending**.
|
||||||
|
|
||||||
|
- First-/third-person walker against the existing `Plan.blocked` wall segments.
|
||||||
|
- Door traversal and city ↔ office identity handoff.
|
||||||
|
- Customizable procedural humanoid for signed-in members.
|
||||||
|
- Anonymous office visitor is a dog; anonymous Tera visitor is a crow.
|
||||||
|
- Profiles begin as generated materials and parameters, not uploaded binaries.
|
||||||
|
|
||||||
|
Exit gate: every room reachable through doors but never through walls; identity
|
||||||
|
survives scene changes; anonymous clients receive no private presence; 30-minute
|
||||||
|
soak produces no stuck or out-of-bounds actor.
|
||||||
|
|
||||||
|
## M4 — authoritative multiplayer
|
||||||
|
|
||||||
|
- Dedicated realtime session service; do not merge it into the control plane.
|
||||||
|
- Server-authoritative actor/vehicle poses, input validation, interpolation,
|
||||||
|
reconnect, and interest zones keyed by Tera cell / Office / Floor / Room.
|
||||||
|
- `lumbridgecorp` issues short-lived launch grants and revalidates membership.
|
||||||
|
- Delta snapshots around 10–15 Hz for nearby dynamic actors; media never rides
|
||||||
|
on this socket.
|
||||||
|
|
||||||
|
Exit gate: two browsers converge within the declared latency; reconnect restores
|
||||||
|
the right cell; revocation ejects promptly; malformed and speed-hack state is
|
||||||
|
rejected; selected cell concurrency passes a repeatable load test.
|
||||||
|
|
||||||
|
## M5 — profile and webcam faces
|
||||||
|
|
||||||
|
- Generated profile face is the default.
|
||||||
|
- Webcam face is opt-in and ephemeral: explicit contextual permission, active
|
||||||
|
indicator, one-click stop, no recording/storage by default.
|
||||||
|
- Server-enforced visibility capabilities; unauthorized clients never receive
|
||||||
|
the private object or track.
|
||||||
|
|
||||||
|
Exit gate: revocation and tab close stop every track; anonymous/unapproved peers
|
||||||
|
cannot subscribe; the full product works without a camera.
|
||||||
|
|
||||||
|
## M6 — office media surfaces
|
||||||
|
|
||||||
|
- `MediaSurface` records identify office/room/screen, ACL, source, and state.
|
||||||
|
- WebRTC SFU track after authorization and explicit viewer opt-in; Three.js gets
|
||||||
|
a video texture only while subscribed.
|
||||||
|
- Share tab/window by default, muted viewer autoplay, visible broadcast state,
|
||||||
|
presenter kill switch, late join and reconnect.
|
||||||
|
|
||||||
|
Exit gate: no unauthorized subscription; revoke ends tracks and disposes GPU
|
||||||
|
textures; bandwidth adaptation and safe public placeholders work.
|
||||||
|
|
||||||
|
## M7 — playable aircraft
|
||||||
|
|
||||||
|
Keep today's aircraft ambient until vehicle, actor, camera, and network
|
||||||
|
abstractions are proven. A later `PlayableAircraft` reuses flight-source data for
|
||||||
|
the world but owns a separate controller and authority model.
|
||||||
|
|
||||||
|
Exit gate: route/altitude bounds, camera and control handoff, multiplayer
|
||||||
|
validation, and no regression to ambient live ADS-B rendering.
|
||||||
|
|
||||||
|
## Performance gates
|
||||||
|
|
||||||
|
- Named benchmark scenes: p95 frame at or below 16.7 ms desktop and 33.3 ms on
|
||||||
|
the selected supported mobile tier.
|
||||||
|
- Hard budgets per scale for resident cells, triangles, draw calls, dynamic
|
||||||
|
actors, GPU memory, and media textures.
|
||||||
|
- Fixed simulation tick separated from rendering; instancing, LOD, pooling,
|
||||||
|
frustum/distance culling, and explicit resource disposal.
|
||||||
|
- Degrade shadows, traffic density, and far detail before input, access control,
|
||||||
|
identity, or privacy enforcement.
|
||||||
|
|
||||||
|
## Immediate parallel build
|
||||||
|
|
||||||
|
The California driving vertical slice now proves the transport, vehicle,
|
||||||
|
controller, input, camera, and responsive UI contracts. Continue in these lanes:
|
||||||
|
|
||||||
|
1. **World:** corridor cell streaming, origin rebasing, city/office destination
|
||||||
|
transitions, and deterministic route-completion scenarios.
|
||||||
|
2. **Office:** integrate the walker with first-/third-person cameras, doors,
|
||||||
|
actor possession, and identity-preserving city ↔ office transitions.
|
||||||
|
3. **Assets:** add driver-view interior hints and animation/state adapters for
|
||||||
|
the existing humanoid, dog, and crow rigs; keep the code-only asset pipeline.
|
||||||
|
4. **Platform:** define versioned launch-grant, session, interest-zone, actor,
|
||||||
|
vehicle, and media-capability schemas plus their threat model—without coupling
|
||||||
|
the renderer to a hosted service.
|
||||||
|
5. **Quality:** add repeatable frame-time/draw-call budgets, screenshot baselines,
|
||||||
|
long-route and office soak tests, asset-manifest CI, and cross-browser input
|
||||||
|
coverage.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Tera
|
# Tera
|
||||||
|
|
||||||
The map view of **Lumbridge Simulate** — cities from above, in three.js.
|
The map view of **Lumbridge Simulate** — California from above, in three.js.
|
||||||
Its other half, **Spaces**, is the offices you walk into: one engine and one
|
Its other half, **Spaces**, is the offices you walk into: one engine and one
|
||||||
asset library, seen from outside and from inside.
|
asset library, seen from outside and from inside.
|
||||||
|
|
||||||
@@ -12,13 +12,18 @@ Apache 2.0. Runs at [tera.lumbridgecorp.com](https://tera.lumbridgecorp.com).
|
|||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
|
|
||||||
An engine plus data packs. The engine renders terrain, coastline, a built city
|
An engine plus data packs. The default board joins Los Angeles and San Francisco
|
||||||
on real street grids, bridges, roads, markers and air traffic. A *city pack* is
|
with live deterministic traffic on US-101 and on the honest I-5 → I-580 → I-80
|
||||||
|
approach. Choose either route chapter to follow the procedural black Model X.
|
||||||
|
|
||||||
|
The engine renders terrain, coastline, built cities on authored street grids,
|
||||||
|
bridges, roads, markers, road traffic and air traffic. A *city pack* is
|
||||||
pure data — coastlines, hills, districts, landmarks, camera chapters — so adding
|
pure data — coastlines, hills, districts, landmarks, camera chapters — so adding
|
||||||
a city is a data contribution anyone can review, not a fork.
|
a city is a data contribution anyone can review, not a fork.
|
||||||
|
|
||||||
San Francisco ships today. Los Angeles / Orange County / Riverside is next; New
|
California, the detailed Bay Area, and Los Angeles / Orange County / Riverside
|
||||||
York after that.
|
ship today. The corridor is intentionally sparse; detailed cities remain their
|
||||||
|
own boards rather than forcing a 600 km world into one full-resolution mesh.
|
||||||
|
|
||||||
A **plan view** sits top right: the board drawn flat, with the footprint of the
|
A **plan view** sits top right: the board drawn flat, with the footprint of the
|
||||||
camera's own frustum on it, so you can see where you are looking from outside
|
camera's own frustum on it, so you can see where you are looking from outside
|
||||||
@@ -128,6 +133,8 @@ answer is an RTL-SDR receiver: first-party data with nothing to comply with.
|
|||||||
```
|
```
|
||||||
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap
|
src/engine/ renderer — terrain, blocks, structures, markers, flights, scene, minimap
|
||||||
src/cities/ data packs — pure geography, no code
|
src/cities/ data packs — pure geography, no code
|
||||||
|
src/transport/ serializable route packs and renderer-independent simulation
|
||||||
|
src/assets/ original procedural asset library
|
||||||
src/adapters/ where outside data plugs in
|
src/adapters/ where outside data plugs in
|
||||||
src/tools/ instruments — god-only, dynamically imported, never statically
|
src/tools/ instruments — god-only, dynamically imported, never statically
|
||||||
```
|
```
|
||||||
@@ -144,3 +151,5 @@ what silently undoes it.
|
|||||||
## Licence
|
## Licence
|
||||||
|
|
||||||
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
Apache License 2.0 — see [LICENSE](LICENSE) and [NOTICE](NOTICE).
|
||||||
|
|
||||||
|
The ordered build plan and parallel work lanes live in [BUILD_PLAN.md](BUILD_PLAN.md).
|
||||||
|
|||||||
+70
-5
@@ -60,7 +60,7 @@
|
|||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta
|
<meta
|
||||||
property="og:image:alt"
|
property="og:image:alt"
|
||||||
content="The San Francisco Bay Area rendered from above in Tera, under a midday sun."
|
content="California rendered from above in Tera, with Los Angeles and San Francisco joined by the US-101 and I-5 corridors."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
* here is what the browser gets. It also means the first paint needs
|
* here is what the browser gets. It also means the first paint needs
|
||||||
* nothing but this document — which is the entire reason the boot card at
|
* nothing but this document — which is the entire reason the boot card at
|
||||||
* the bottom of the body can be on screen before a single module has been
|
* the bottom of the body can be on screen before a single module has been
|
||||||
* fetched, let alone before the Bay Area heightfield has been built.
|
* fetched, let alone before the California heightfield has been built.
|
||||||
*
|
*
|
||||||
* Two constraints shape every rule below.
|
* Two constraints shape every rule below.
|
||||||
*
|
*
|
||||||
@@ -666,6 +666,54 @@
|
|||||||
color: var(--ink-3);
|
color: var(--ink-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Touch driving ---------------------------------------------------
|
||||||
|
Keyboard and standard gamepads need no chrome. Coarse pointers do, and
|
||||||
|
only while a route chapter owns the follow camera. Full words rather
|
||||||
|
than mystery glyphs: the controls disappear everywhere space is tight
|
||||||
|
except on the devices that cannot drive without them. */
|
||||||
|
.drive-controls { display: none; }
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
.drive-controls:not([hidden]) {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 5;
|
||||||
|
width: min(23rem, calc(100vw - var(--s4) * 2));
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: var(--s1);
|
||||||
|
padding: var(--s1);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--r);
|
||||||
|
background: var(--glass-strong);
|
||||||
|
backdrop-filter: var(--blur);
|
||||||
|
-webkit-backdrop-filter: var(--blur);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.drive-control {
|
||||||
|
min-height: 48px;
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
.drive-control[aria-pressed="true"] {
|
||||||
|
border-color: rgba(242, 177, 52, 0.65);
|
||||||
|
background: rgba(242, 177, 52, 0.28);
|
||||||
|
color: var(--amber-ink);
|
||||||
|
}
|
||||||
|
/* The source line is useful but cannot sit underneath the controls. */
|
||||||
|
body:has(.drive-controls:not([hidden])) .source { bottom: 7.5rem; }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---- Responsive -------------------------------------------------------
|
/* ---- Responsive -------------------------------------------------------
|
||||||
Two breakpoints and no more. At 900 the left column stops being furniture
|
Two breakpoints and no more. At 900 the left column stops being furniture
|
||||||
and becomes a sheet you open; at 600 the plan stops fitting beside the
|
and becomes a sheet you open; at 600 the plan stops fitting beside the
|
||||||
@@ -835,15 +883,15 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<canvas id="scene" aria-label="Map of San Francisco, seen from above. Drag to orbit, scroll to zoom."></canvas>
|
<canvas id="scene" aria-label="Map of California, seen from above. Drag to orbit, scroll to zoom."></canvas>
|
||||||
|
|
||||||
<button id="panel-toggle" class="panel-toggle" aria-expanded="true" aria-controls="panel">
|
<button id="panel-toggle" class="panel-toggle" aria-expanded="true" aria-controls="panel">
|
||||||
<span class="glyph" aria-hidden="true">☰</span><span id="panel-toggle-label">Bay Area</span>
|
<span class="glyph" aria-hidden="true">☰</span><span id="panel-toggle-label">California</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="panel">
|
<div id="panel">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h1 id="title">San Francisco</h1>
|
<h1 id="title">California</h1>
|
||||||
<p id="subtitle">Tera · Lumbridge Simulate</p>
|
<p id="subtitle">Tera · Lumbridge Simulate</p>
|
||||||
<p id="clock" class="clock" aria-live="polite"></p>
|
<p id="clock" class="clock" aria-live="polite"></p>
|
||||||
</section>
|
</section>
|
||||||
@@ -870,6 +918,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="card hint" id="hint">
|
<div class="card hint" id="hint">
|
||||||
<span><kbd>1</kbd>–<kbd>9</kbd> chapters</span>
|
<span><kbd>1</kbd>–<kbd>9</kbd> chapters</span>
|
||||||
|
<span>choose <kbd>2</kbd> or <kbd>3</kbd> to follow</span>
|
||||||
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
|
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
|
||||||
<span><kbd>O</kbd> office</span>
|
<span><kbd>O</kbd> office</span>
|
||||||
<span><kbd>M</kbd> plan</span>
|
<span><kbd>M</kbd> plan</span>
|
||||||
@@ -886,6 +935,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="drive-controls" class="drive-controls" aria-label="Vehicle controls" hidden>
|
||||||
|
<button class="drive-control" data-drive-key="a" aria-pressed="false">Left</button>
|
||||||
|
<button class="drive-control" data-drive-key="w" aria-pressed="false">Throttle</button>
|
||||||
|
<button class="drive-control" data-drive-key="s" aria-pressed="false">Brake</button>
|
||||||
|
<button class="drive-control" data-drive-key="d" aria-pressed="false">Right</button>
|
||||||
|
<button class="drive-control" data-drive-key=" " aria-pressed="false">Handbrake</button>
|
||||||
|
<button class="drive-control" data-drive-action="assist">Assist</button>
|
||||||
|
<button class="drive-control" data-drive-action="reset">Reset</button>
|
||||||
|
<button class="drive-control" data-drive-action="camera">Camera</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<p id="source" class="source"></p>
|
<p id="source" class="source"></p>
|
||||||
|
|
||||||
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
|
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
|
||||||
@@ -899,6 +959,10 @@
|
|||||||
<h2 id="shortcuts-title">Keyboard</h2>
|
<h2 id="shortcuts-title">Keyboard</h2>
|
||||||
<dl class="keys">
|
<dl class="keys">
|
||||||
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
|
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
|
||||||
|
<dt><kbd>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive after choosing the 101 or I-5 chapter</dd>
|
||||||
|
<dt><kbd>Space</kbd></dt><dd>Handbrake while driving</dd>
|
||||||
|
<dt><kbd>P</kbd> / <kbd>R</kbd></dt><dd>Resume assisted drive / reset the car</dd>
|
||||||
|
<dt><kbd>C</kbd></dt><dd>Switch chase / driver-height camera</dd>
|
||||||
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
|
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
|
||||||
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
|
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
|
||||||
<dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd>
|
<dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd>
|
||||||
@@ -913,6 +977,7 @@
|
|||||||
<dt>One finger</dt><dd>Orbit</dd>
|
<dt>One finger</dt><dd>Orbit</dd>
|
||||||
<dt>Two fingers</dt><dd>Pinch to zoom, drag to move over the ground</dd>
|
<dt>Two fingers</dt><dd>Pinch to zoom, drag to move over the ground</dd>
|
||||||
<dt>Tap a marker</dt><dd>Its card, at the bottom of the screen</dd>
|
<dt>Tap a marker</dt><dd>Its card, at the bottom of the screen</dd>
|
||||||
|
<dt>Route controls</dt><dd>Choose 101 or I-5; the driving pad appears at the bottom</dd>
|
||||||
<dt>Tap the map</dt><dd>Dismiss the card; tap the ☰ sheet's scrim to close it</dd>
|
<dt>Tap the map</dt><dd>Dismiss the card; tap the ☰ sheet's scrim to close it</dd>
|
||||||
<dt>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
|
<dt>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc --noEmit && vite build",
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"licenses": "node scripts/check-dependency-licenses.mjs",
|
||||||
|
"sbom": "npm sbom --package-lock-only --sbom-format=spdx",
|
||||||
"test": "node --test \"src/test/*.test.ts\"",
|
"test": "node --test \"src/test/*.test.ts\"",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Lockfile license gate for every installed package, including workspaces.
|
||||||
|
*
|
||||||
|
* The allowed set is intentionally small and explicit. A new license is a
|
||||||
|
* review event, not a string this script tries to interpret optimistically.
|
||||||
|
* This does not replace third-party notices; it prevents a dependency update
|
||||||
|
* from quietly introducing terms outside Tera's permissive distribution model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const lock = JSON.parse(readFileSync(resolve(root, "package-lock.json"), "utf8"));
|
||||||
|
const allowed = new Set(["Apache-2.0", "BSD-3-Clause", "ISC", "MIT"]);
|
||||||
|
const problems = [];
|
||||||
|
const counts = new Map();
|
||||||
|
|
||||||
|
for (const [path, entry] of Object.entries(lock.packages ?? {})) {
|
||||||
|
if (path === "") continue;
|
||||||
|
|
||||||
|
let license = entry.license;
|
||||||
|
if (!license && entry.link === true && typeof entry.resolved === "string") {
|
||||||
|
try {
|
||||||
|
const workspace = JSON.parse(
|
||||||
|
readFileSync(resolve(root, entry.resolved, "package.json"), "utf8"),
|
||||||
|
);
|
||||||
|
license = workspace.license;
|
||||||
|
} catch (error) {
|
||||||
|
problems.push(`${path}: could not read linked workspace license (${String(error)})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof license !== "string" || license.length === 0) {
|
||||||
|
problems.push(`${path}: no license recorded in the lockfile or linked workspace`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!allowed.has(license)) {
|
||||||
|
problems.push(`${path}: ${license} is not in the reviewed allowlist`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
counts.set(license, (counts.get(license) ?? 0) + 1);
|
||||||
|
|
||||||
|
const resolved = entry.resolved;
|
||||||
|
if (typeof resolved === "string" && /^(?:git\+|github:|gitlab:|bitbucket:)/i.test(resolved)) {
|
||||||
|
problems.push(`${path}: source dependency ${resolved} is not a registry artifact`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
console.error("\ncheck-dependency-licenses: FAIL\n");
|
||||||
|
for (const problem of problems) console.error(` ${problem}`);
|
||||||
|
console.error(`\nReviewed allowlist: ${[...allowed].join(", ")}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
|
||||||
|
console.log(`check-dependency-licenses: ok — ${total} locked packages reviewed.`);
|
||||||
|
for (const license of [...counts.keys()].sort()) {
|
||||||
|
console.log(` ${license}: ${counts.get(license)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
|
||||||
|
export interface ActorRigBase {
|
||||||
|
/** Floor-centred root. Every actor faces -Z at yaw zero. */
|
||||||
|
root: THREE.Group;
|
||||||
|
/** True only when the builder created the material set. */
|
||||||
|
readonly ownsMaterials: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actorMesh(
|
||||||
|
name: string,
|
||||||
|
geometry: THREE.BufferGeometry,
|
||||||
|
material: THREE.Material,
|
||||||
|
options: {
|
||||||
|
position?: readonly [number, number, number];
|
||||||
|
rotation?: readonly [number, number, number];
|
||||||
|
scale?: readonly [number, number, number];
|
||||||
|
receiveShadow?: boolean;
|
||||||
|
} = {},
|
||||||
|
): THREE.Mesh {
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.name = name;
|
||||||
|
if (options.position) mesh.position.set(...options.position);
|
||||||
|
if (options.rotation) mesh.rotation.set(...options.rotation);
|
||||||
|
if (options.scale) mesh.scale.set(...options.scale);
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = options.receiveShadow ?? true;
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function namedGroup(name: string, position?: readonly [number, number, number]): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = name;
|
||||||
|
if (position) group.position.set(...position);
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireGroup(root: THREE.Object3D, name: string): THREE.Group {
|
||||||
|
const object = root.getObjectByName(name);
|
||||||
|
if (!(object instanceof THREE.Group)) throw new Error(`actor: missing joint "${name}"`);
|
||||||
|
return object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Release only resources owned by a prototype. Clones share those resources. */
|
||||||
|
export function disposeActor(
|
||||||
|
rig: ActorRigBase,
|
||||||
|
options: { disposeMaterials?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const geometries = new Set<THREE.BufferGeometry>();
|
||||||
|
const materials = new Set<THREE.Material>();
|
||||||
|
rig.root.traverse((object) => {
|
||||||
|
if (!(object instanceof THREE.Mesh)) return;
|
||||||
|
geometries.add(object.geometry);
|
||||||
|
if (Array.isArray(object.material)) {
|
||||||
|
for (const material of object.material) materials.add(material);
|
||||||
|
} else materials.add(object.material);
|
||||||
|
});
|
||||||
|
for (const geometry of geometries) geometry.dispose();
|
||||||
|
if (options.disposeMaterials ?? rig.ownsMaterials) {
|
||||||
|
for (const material of materials) material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function actorMeshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||||
|
const meshes: THREE.Mesh[] = [];
|
||||||
|
root.traverse((object) => {
|
||||||
|
if (object instanceof THREE.Mesh) meshes.push(object);
|
||||||
|
});
|
||||||
|
return meshes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
/** A low-cost anonymous Tera crow, ready to perch or flap in flight. */
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
actorMesh,
|
||||||
|
disposeActor,
|
||||||
|
namedGroup,
|
||||||
|
requireGroup,
|
||||||
|
type ActorRigBase,
|
||||||
|
} from "./common.ts";
|
||||||
|
|
||||||
|
export const CROW_METRICS = { bodyLength: 0.42, perchedHeight: 0.34, wingspan: 0.84 } as const;
|
||||||
|
|
||||||
|
export interface CrowMaterials {
|
||||||
|
feather: THREE.Material;
|
||||||
|
sheen: THREE.Material;
|
||||||
|
beak: THREE.Material;
|
||||||
|
eye: THREE.Material;
|
||||||
|
foot: THREE.Material;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrowBuildOptions {
|
||||||
|
materials?: CrowMaterials;
|
||||||
|
featherColor?: THREE.ColorRepresentation;
|
||||||
|
sheenColor?: THREE.ColorRepresentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrowJoints {
|
||||||
|
body: THREE.Group;
|
||||||
|
head: THREE.Group;
|
||||||
|
wingLeft: THREE.Group;
|
||||||
|
wingRight: THREE.Group;
|
||||||
|
tail: THREE.Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CrowRig extends ActorRigBase {
|
||||||
|
joints: CrowJoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCrowMaterials(options: CrowBuildOptions = {}): CrowMaterials {
|
||||||
|
return {
|
||||||
|
feather: new THREE.MeshStandardMaterial({ name: "crow.feather", color: options.featherColor ?? 0x111519, roughness: 0.62, metalness: 0.12 }),
|
||||||
|
sheen: new THREE.MeshStandardMaterial({ name: "crow.sheen", color: options.sheenColor ?? 0x1f2c36, roughness: 0.4, metalness: 0.28 }),
|
||||||
|
beak: new THREE.MeshStandardMaterial({ name: "crow.beak", color: 0x202327, roughness: 0.78 }),
|
||||||
|
eye: new THREE.MeshBasicMaterial({ name: "crow.eye", color: 0xd4b168, toneMapped: false }),
|
||||||
|
foot: new THREE.MeshStandardMaterial({ name: "crow.foot", color: 0x24272a, roughness: 0.9 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wingGeometry(side: -1 | 1): THREE.BufferGeometry {
|
||||||
|
const s = side;
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute(
|
||||||
|
"position",
|
||||||
|
new THREE.Float32BufferAttribute([
|
||||||
|
0, 0, 0.04,
|
||||||
|
s * 0.32, -0.015, 0.1,
|
||||||
|
s * 0.4, -0.03, 0.2,
|
||||||
|
s * 0.18, -0.015, -0.14,
|
||||||
|
0, 0, -0.16,
|
||||||
|
], 3),
|
||||||
|
);
|
||||||
|
geometry.setIndex([0, 1, 3, 1, 2, 3, 0, 3, 4]);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
geometry.name = `crow.wing.${side < 0 ? "left" : "right"}`;
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCrow(root: THREE.Group, ownsMaterials: boolean): CrowRig {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
ownsMaterials,
|
||||||
|
joints: {
|
||||||
|
body: requireGroup(root, "crow.body"),
|
||||||
|
head: requireGroup(root, "crow.head"),
|
||||||
|
wingLeft: requireGroup(root, "crow.wing.left"),
|
||||||
|
wingRight: requireGroup(root, "crow.wing.right"),
|
||||||
|
tail: requireGroup(root, "crow.tail"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCrow(options: CrowBuildOptions = {}): CrowRig {
|
||||||
|
const m = options.materials ?? createCrowMaterials(options);
|
||||||
|
const root = namedGroup("crow");
|
||||||
|
root.userData.kind = "actor";
|
||||||
|
root.userData.actorType = "anonymous-crow";
|
||||||
|
root.userData.forwardAxis = "-Z";
|
||||||
|
const body = namedGroup("crow.body", [0, 0.2, 0]);
|
||||||
|
root.add(body);
|
||||||
|
body.add(
|
||||||
|
actorMesh("crow.torso", new THREE.SphereGeometry(0.13, 12, 9), m.feather, {
|
||||||
|
rotation: [-0.16, 0, 0],
|
||||||
|
scale: [0.82, 1.08, 1.34],
|
||||||
|
}),
|
||||||
|
actorMesh("crow.breast", new THREE.SphereGeometry(0.105, 10, 8), m.sheen, {
|
||||||
|
position: [0, 0.012, -0.105],
|
||||||
|
scale: [0.72, 1, 0.58],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const head = namedGroup("crow.head", [0, 0.145, -0.105]);
|
||||||
|
body.add(head);
|
||||||
|
head.add(
|
||||||
|
actorMesh("crow.skull", new THREE.SphereGeometry(0.093, 12, 9), m.feather, { scale: [0.92, 1, 0.95] }),
|
||||||
|
actorMesh("crow.beak", new THREE.ConeGeometry(0.055, 0.18, 6), m.beak, {
|
||||||
|
position: [0, -0.018, -0.145],
|
||||||
|
rotation: [-Math.PI / 2, 0, 0],
|
||||||
|
scale: [0.72, 1, 0.68],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const side of [-1, 1] as const) {
|
||||||
|
const word = side < 0 ? "left" : "right";
|
||||||
|
head.add(
|
||||||
|
actorMesh(`crow.eye.${word}`, new THREE.SphereGeometry(0.011, 7, 5), m.eye, {
|
||||||
|
position: [side * 0.068, 0.02, -0.06],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const wing = namedGroup(`crow.wing.${word}`, [side * 0.075, 0.035, 0]);
|
||||||
|
body.add(wing);
|
||||||
|
wing.add(actorMesh(`crow.wing-mesh.${word}`, wingGeometry(side), m.feather, { receiveShadow: false }));
|
||||||
|
}
|
||||||
|
const tail = namedGroup("crow.tail", [0, -0.01, 0.13]);
|
||||||
|
body.add(tail);
|
||||||
|
for (const side of [-1, 0, 1] as const) {
|
||||||
|
tail.add(
|
||||||
|
actorMesh(`crow.tail-feather.${side}`, new THREE.ConeGeometry(0.045, 0.25, 4), m.feather, {
|
||||||
|
position: [side * 0.04, -0.015, 0.12],
|
||||||
|
rotation: [Math.PI / 2, 0, 0],
|
||||||
|
scale: [0.72, 1, 0.35],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const side of [-1, 1] as const) {
|
||||||
|
body.add(
|
||||||
|
actorMesh(`crow.foot.${side < 0 ? "left" : "right"}`, new THREE.CylinderGeometry(0.012, 0.009, 0.14, 6), m.foot, {
|
||||||
|
position: [side * 0.048, -0.14, -0.012],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return resolveCrow(root, !options.materials);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneCrow(source: CrowRig): CrowRig {
|
||||||
|
return resolveCrow(source.root.clone(true), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pose a flap. `amount=0` folds the wings; `amount=1` is a broad flight stroke. */
|
||||||
|
export function poseCrowFlight(rig: CrowRig, phase: number, amount = 1): void {
|
||||||
|
const strength = THREE.MathUtils.clamp(amount, 0, 1);
|
||||||
|
const stroke = Math.sin(phase) * 0.72 * strength;
|
||||||
|
const spread = 0.2 + strength * 0.74;
|
||||||
|
rig.joints.wingLeft.rotation.z = spread + stroke;
|
||||||
|
rig.joints.wingRight.rotation.z = -spread - stroke;
|
||||||
|
rig.joints.wingLeft.rotation.x = -0.12 * strength;
|
||||||
|
rig.joints.wingRight.rotation.x = -0.12 * strength;
|
||||||
|
rig.joints.body.rotation.x = 0.08 * Math.cos(phase) * strength;
|
||||||
|
rig.joints.tail.rotation.x = -0.12 * Math.cos(phase) * strength;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeCrow(rig: CrowRig, options: { disposeMaterials?: boolean } = {}): void {
|
||||||
|
disposeActor(rig, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/** A compact, friendly anonymous office dog with a poseable head, legs and tail. */
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
actorMesh,
|
||||||
|
disposeActor,
|
||||||
|
namedGroup,
|
||||||
|
requireGroup,
|
||||||
|
type ActorRigBase,
|
||||||
|
} from "./common.ts";
|
||||||
|
|
||||||
|
export const DOG_METRICS = { length: 0.76, shoulderHeight: 0.46, height: 0.74 } as const;
|
||||||
|
|
||||||
|
export interface DogMaterials {
|
||||||
|
coat: THREE.Material;
|
||||||
|
markings: THREE.Material;
|
||||||
|
nose: THREE.Material;
|
||||||
|
collar: THREE.Material;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DogBuildOptions {
|
||||||
|
materials?: DogMaterials;
|
||||||
|
coatColor?: THREE.ColorRepresentation;
|
||||||
|
markingsColor?: THREE.ColorRepresentation;
|
||||||
|
collarColor?: THREE.ColorRepresentation;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DogJoints {
|
||||||
|
body: THREE.Group;
|
||||||
|
head: THREE.Group;
|
||||||
|
earLeft: THREE.Group;
|
||||||
|
earRight: THREE.Group;
|
||||||
|
tail: THREE.Group;
|
||||||
|
legFrontLeft: THREE.Group;
|
||||||
|
legFrontRight: THREE.Group;
|
||||||
|
legRearLeft: THREE.Group;
|
||||||
|
legRearRight: THREE.Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DogRig extends ActorRigBase {
|
||||||
|
joints: DogJoints;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDogMaterials(options: DogBuildOptions = {}): DogMaterials {
|
||||||
|
return {
|
||||||
|
coat: new THREE.MeshStandardMaterial({ name: "dog.coat", color: options.coatColor ?? 0x9a673d, roughness: 0.92 }),
|
||||||
|
markings: new THREE.MeshStandardMaterial({ name: "dog.markings", color: options.markingsColor ?? 0xe4c9a2, roughness: 0.95 }),
|
||||||
|
nose: new THREE.MeshStandardMaterial({ name: "dog.nose", color: 0x151719, roughness: 0.72 }),
|
||||||
|
collar: new THREE.MeshStandardMaterial({ name: "dog.collar", color: options.collarColor ?? 0x2d92a7, roughness: 0.55 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDog(root: THREE.Group, ownsMaterials: boolean): DogRig {
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
ownsMaterials,
|
||||||
|
joints: {
|
||||||
|
body: requireGroup(root, "dog.body"),
|
||||||
|
head: requireGroup(root, "dog.head"),
|
||||||
|
earLeft: requireGroup(root, "dog.ear.left"),
|
||||||
|
earRight: requireGroup(root, "dog.ear.right"),
|
||||||
|
tail: requireGroup(root, "dog.tail"),
|
||||||
|
legFrontLeft: requireGroup(root, "dog.leg.front.left"),
|
||||||
|
legFrontRight: requireGroup(root, "dog.leg.front.right"),
|
||||||
|
legRearLeft: requireGroup(root, "dog.leg.rear.left"),
|
||||||
|
legRearRight: requireGroup(root, "dog.leg.rear.right"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDog(options: DogBuildOptions = {}): DogRig {
|
||||||
|
const m = options.materials ?? createDogMaterials(options);
|
||||||
|
const root = namedGroup("dog");
|
||||||
|
root.userData.kind = "actor";
|
||||||
|
root.userData.actorType = "anonymous-dog";
|
||||||
|
root.userData.forwardAxis = "-Z";
|
||||||
|
const body = namedGroup("dog.body", [0, 0.39, 0.04]);
|
||||||
|
root.add(body);
|
||||||
|
body.add(
|
||||||
|
actorMesh("dog.torso", new THREE.CapsuleGeometry(0.18, 0.34, 5, 10), m.coat, {
|
||||||
|
rotation: [Math.PI / 2, 0, 0],
|
||||||
|
scale: [0.82, 1, 0.92],
|
||||||
|
}),
|
||||||
|
actorMesh("dog.chest", new THREE.SphereGeometry(0.185, 12, 9), m.markings, {
|
||||||
|
position: [0, 0.01, -0.17],
|
||||||
|
scale: [0.72, 1, 0.56],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const head = namedGroup("dog.head", [0, 0.16, -0.32]);
|
||||||
|
body.add(head);
|
||||||
|
head.add(
|
||||||
|
actorMesh("dog.skull", new THREE.SphereGeometry(0.16, 12, 10), m.coat, { scale: [0.82, 0.92, 0.88] }),
|
||||||
|
actorMesh("dog.muzzle", new THREE.SphereGeometry(0.105, 12, 8), m.markings, {
|
||||||
|
position: [0, -0.045, -0.13],
|
||||||
|
scale: [0.8, 0.62, 1],
|
||||||
|
}),
|
||||||
|
actorMesh("dog.nose", new THREE.SphereGeometry(0.045, 10, 7), m.nose, {
|
||||||
|
position: [0, -0.035, -0.224],
|
||||||
|
scale: [1.15, 0.72, 0.7],
|
||||||
|
}),
|
||||||
|
actorMesh("dog.collar", new THREE.TorusGeometry(0.118, 0.016, 6, 18), m.collar, {
|
||||||
|
position: [0, -0.11, 0.1],
|
||||||
|
rotation: [Math.PI / 2, 0, 0],
|
||||||
|
scale: [1, 0.82, 1],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const side of [-1, 1] as const) {
|
||||||
|
const word = side < 0 ? "left" : "right";
|
||||||
|
const ear = namedGroup(`dog.ear.${word}`, [side * 0.1, 0.105, -0.015]);
|
||||||
|
head.add(ear);
|
||||||
|
ear.add(
|
||||||
|
actorMesh(`dog.ear-flap.${word}`, new THREE.ConeGeometry(0.075, 0.19, 5), m.coat, {
|
||||||
|
position: [side * 0.015, -0.065, 0.02],
|
||||||
|
rotation: [0.18, 0, side * 0.28],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const z of [-0.2, 0.22] as const) {
|
||||||
|
for (const side of [-1, 1] as const) {
|
||||||
|
const fore = z < 0 ? "front" : "rear";
|
||||||
|
const word = side < 0 ? "left" : "right";
|
||||||
|
const leg = namedGroup(`dog.leg.${fore}.${word}`, [side * 0.125, -0.1, z]);
|
||||||
|
body.add(leg);
|
||||||
|
leg.add(
|
||||||
|
actorMesh(`dog.leg-mesh.${fore}.${word}`, new THREE.CapsuleGeometry(0.046, 0.19, 3, 7), m.coat, {
|
||||||
|
position: [0, -0.135, 0],
|
||||||
|
}),
|
||||||
|
actorMesh(`dog.paw.${fore}.${word}`, new THREE.SphereGeometry(0.06, 8, 6), m.markings, {
|
||||||
|
position: [0, -0.29, -0.022],
|
||||||
|
scale: [0.84, 0.48, 1.18],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tail = namedGroup("dog.tail", [0, 0.03, 0.31]);
|
||||||
|
body.add(tail);
|
||||||
|
tail.rotation.x = 0.68;
|
||||||
|
tail.add(
|
||||||
|
actorMesh("dog.tail-mesh", new THREE.CapsuleGeometry(0.04, 0.25, 4, 8), m.coat, {
|
||||||
|
position: [0, 0.15, 0],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return resolveDog(root, !options.materials);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneDog(source: DogRig): DogRig {
|
||||||
|
return resolveDog(source.root.clone(true), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poseDogWalk(rig: DogRig, phase: number, amount = 0.55): void {
|
||||||
|
const swing = Math.sin(phase) * THREE.MathUtils.clamp(amount, 0, 0.8);
|
||||||
|
rig.joints.legFrontLeft.rotation.x = swing;
|
||||||
|
rig.joints.legRearRight.rotation.x = swing;
|
||||||
|
rig.joints.legFrontRight.rotation.x = -swing;
|
||||||
|
rig.joints.legRearLeft.rotation.x = -swing;
|
||||||
|
rig.joints.body.position.y = 0.39 + Math.abs(Math.cos(phase)) * Math.abs(swing) * 0.018;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poseDogAttention(rig: DogRig, lookYaw: number, tailPhase: number): void {
|
||||||
|
rig.joints.head.rotation.y = THREE.MathUtils.clamp(lookYaw, -0.9, 0.9);
|
||||||
|
rig.joints.head.rotation.x = -0.08;
|
||||||
|
rig.joints.earLeft.rotation.z = 0.08;
|
||||||
|
rig.joints.earRight.rotation.z = -0.08;
|
||||||
|
rig.joints.tail.rotation.z = Math.sin(tailPhase) * 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeDog(rig: DogRig, options: { disposeMaterials?: boolean } = {}): void {
|
||||||
|
disposeActor(rig, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/**
|
||||||
|
* A friendly, original humanoid avatar built entirely from Three.js primitives.
|
||||||
|
*
|
||||||
|
* Dimensions are metres, the origin is between the soles, and -Z is forward.
|
||||||
|
* The face is a slightly curved dark display with an optional caller-owned
|
||||||
|
* texture. A deployment can build one prototype per appearance/webcam stream,
|
||||||
|
* then cheaply clone that prototype for repeated views: clones share immutable
|
||||||
|
* geometry and materials while all animation joints remain independent.
|
||||||
|
*/
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
actorMesh,
|
||||||
|
disposeActor,
|
||||||
|
namedGroup,
|
||||||
|
requireGroup,
|
||||||
|
type ActorRigBase,
|
||||||
|
} from "./common.ts";
|
||||||
|
|
||||||
|
export const HUMANOID_METRICS = {
|
||||||
|
height: 1.76,
|
||||||
|
shoulderWidth: 0.46,
|
||||||
|
hipY: 0.94,
|
||||||
|
eyeY: 1.65,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface HumanoidMaterials {
|
||||||
|
skin: THREE.Material;
|
||||||
|
outfit: THREE.Material;
|
||||||
|
accent: THREE.Material;
|
||||||
|
sole: THREE.Material;
|
||||||
|
hair: THREE.Material;
|
||||||
|
face: THREE.Material;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HumanoidBuildOptions {
|
||||||
|
materials?: HumanoidMaterials;
|
||||||
|
skinTone?: THREE.ColorRepresentation;
|
||||||
|
outfitColor?: THREE.ColorRepresentation;
|
||||||
|
accentColor?: THREE.ColorRepresentation;
|
||||||
|
hairColor?: THREE.ColorRepresentation;
|
||||||
|
/** Caller-owned and never disposed by this asset. */
|
||||||
|
faceTexture?: THREE.Texture;
|
||||||
|
bodyShape?: "slim" | "average" | "broad";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HumanoidJoints {
|
||||||
|
pelvis: THREE.Group;
|
||||||
|
torso: THREE.Group;
|
||||||
|
head: THREE.Group;
|
||||||
|
shoulderLeft: THREE.Group;
|
||||||
|
shoulderRight: THREE.Group;
|
||||||
|
elbowLeft: THREE.Group;
|
||||||
|
elbowRight: THREE.Group;
|
||||||
|
hipLeft: THREE.Group;
|
||||||
|
hipRight: THREE.Group;
|
||||||
|
kneeLeft: THREE.Group;
|
||||||
|
kneeRight: THREE.Group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HumanoidRig extends ActorRigBase {
|
||||||
|
joints: HumanoidJoints;
|
||||||
|
/** Front-facing surface for a webcam or generated profile face. */
|
||||||
|
face: THREE.Mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HumanoidPose {
|
||||||
|
walkPhase?: number;
|
||||||
|
stride?: number;
|
||||||
|
armSwing?: number;
|
||||||
|
headYaw?: number;
|
||||||
|
headPitch?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHumanoidMaterials(options: HumanoidBuildOptions = {}): HumanoidMaterials {
|
||||||
|
return {
|
||||||
|
skin: new THREE.MeshStandardMaterial({
|
||||||
|
name: "humanoid.skin",
|
||||||
|
color: options.skinTone ?? 0x9b6246,
|
||||||
|
roughness: 0.68,
|
||||||
|
}),
|
||||||
|
outfit: new THREE.MeshStandardMaterial({
|
||||||
|
name: "humanoid.outfit",
|
||||||
|
color: options.outfitColor ?? 0x26364b,
|
||||||
|
roughness: 0.72,
|
||||||
|
}),
|
||||||
|
accent: new THREE.MeshStandardMaterial({
|
||||||
|
name: "humanoid.accent",
|
||||||
|
color: options.accentColor ?? 0x4fa9c8,
|
||||||
|
roughness: 0.55,
|
||||||
|
}),
|
||||||
|
sole: new THREE.MeshStandardMaterial({
|
||||||
|
name: "humanoid.sole",
|
||||||
|
color: 0x181b1e,
|
||||||
|
roughness: 0.9,
|
||||||
|
}),
|
||||||
|
hair: new THREE.MeshStandardMaterial({
|
||||||
|
name: "humanoid.hair",
|
||||||
|
color: options.hairColor ?? 0x241b18,
|
||||||
|
roughness: 0.86,
|
||||||
|
}),
|
||||||
|
face: new THREE.MeshBasicMaterial({
|
||||||
|
name: "humanoid.face",
|
||||||
|
color: options.faceTexture ? 0xffffff : 0x18242b,
|
||||||
|
map: options.faceTexture ?? null,
|
||||||
|
toneMapped: false,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function limb(
|
||||||
|
joint: THREE.Group,
|
||||||
|
name: string,
|
||||||
|
material: THREE.Material,
|
||||||
|
radius: number,
|
||||||
|
length: number,
|
||||||
|
): void {
|
||||||
|
joint.add(
|
||||||
|
actorMesh(name, new THREE.CapsuleGeometry(radius, Math.max(0.01, length - radius * 2), 4, 8), material, {
|
||||||
|
position: [0, -length / 2, 0],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveHumanoid(root: THREE.Group, ownsMaterials: boolean): HumanoidRig {
|
||||||
|
const face = root.getObjectByName("humanoid.face");
|
||||||
|
if (!(face instanceof THREE.Mesh)) throw new Error("humanoid: missing face surface");
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
ownsMaterials,
|
||||||
|
face,
|
||||||
|
joints: {
|
||||||
|
pelvis: requireGroup(root, "humanoid.pelvis"),
|
||||||
|
torso: requireGroup(root, "humanoid.torso"),
|
||||||
|
head: requireGroup(root, "humanoid.head"),
|
||||||
|
shoulderLeft: requireGroup(root, "humanoid.shoulder.left"),
|
||||||
|
shoulderRight: requireGroup(root, "humanoid.shoulder.right"),
|
||||||
|
elbowLeft: requireGroup(root, "humanoid.elbow.left"),
|
||||||
|
elbowRight: requireGroup(root, "humanoid.elbow.right"),
|
||||||
|
hipLeft: requireGroup(root, "humanoid.hip.left"),
|
||||||
|
hipRight: requireGroup(root, "humanoid.hip.right"),
|
||||||
|
kneeLeft: requireGroup(root, "humanoid.knee.left"),
|
||||||
|
kneeRight: requireGroup(root, "humanoid.knee.right"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHumanoid(options: HumanoidBuildOptions = {}): HumanoidRig {
|
||||||
|
const materials = options.materials ?? createHumanoidMaterials(options);
|
||||||
|
const shape = options.bodyShape ?? "average";
|
||||||
|
const widthScale = shape === "slim" ? 0.86 : shape === "broad" ? 1.14 : 1;
|
||||||
|
const root = namedGroup("humanoid");
|
||||||
|
root.userData.kind = "actor";
|
||||||
|
root.userData.actorType = "humanoid";
|
||||||
|
root.userData.forwardAxis = "-Z";
|
||||||
|
|
||||||
|
const pelvis = namedGroup("humanoid.pelvis", [0, HUMANOID_METRICS.hipY, 0]);
|
||||||
|
root.add(pelvis);
|
||||||
|
pelvis.add(
|
||||||
|
actorMesh("humanoid.pelvis.shell", new THREE.CapsuleGeometry(0.12, 0.12, 4, 8), materials.outfit, {
|
||||||
|
scale: [1.25 * widthScale, 0.75, 0.9],
|
||||||
|
position: [0, 0.02, 0],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const torso = namedGroup("humanoid.torso", [0, 0.11, 0]);
|
||||||
|
pelvis.add(torso);
|
||||||
|
torso.add(
|
||||||
|
actorMesh("humanoid.chest", new THREE.CapsuleGeometry(0.18, 0.25, 5, 10), materials.outfit, {
|
||||||
|
position: [0, 0.24, 0],
|
||||||
|
scale: [1.08 * widthScale, 1, 0.72],
|
||||||
|
}),
|
||||||
|
actorMesh("humanoid.chest.accent", new THREE.BoxGeometry(0.2 * widthScale, 0.065, 0.018), materials.accent, {
|
||||||
|
position: [0, 0.32, -0.134],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const head = namedGroup("humanoid.head", [0, 0.58, 0]);
|
||||||
|
torso.add(head);
|
||||||
|
head.add(
|
||||||
|
actorMesh("humanoid.head.shell", new THREE.SphereGeometry(0.12, 16, 12), materials.skin, {
|
||||||
|
position: [0, 0.1, 0],
|
||||||
|
scale: [0.86, 1.05, 0.86],
|
||||||
|
}),
|
||||||
|
actorMesh("humanoid.hair", new THREE.SphereGeometry(0.122, 14, 8, 0, Math.PI * 2, 0, Math.PI * 0.47), materials.hair, {
|
||||||
|
position: [0, 0.115, 0.004],
|
||||||
|
scale: [0.88, 1.06, 0.88],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const face = actorMesh("humanoid.face", new THREE.PlaneGeometry(0.125, 0.105, 2, 2), materials.face, {
|
||||||
|
position: [0, 0.09, -0.105],
|
||||||
|
rotation: [0, Math.PI, 0],
|
||||||
|
receiveShadow: false,
|
||||||
|
});
|
||||||
|
head.add(face);
|
||||||
|
|
||||||
|
const shoulderY = 0.47;
|
||||||
|
for (const side of [-1, 1] as const) {
|
||||||
|
const word = side < 0 ? "left" : "right";
|
||||||
|
const shoulder = namedGroup(`humanoid.shoulder.${word}`, [side * 0.23 * widthScale, shoulderY, 0]);
|
||||||
|
torso.add(shoulder);
|
||||||
|
limb(shoulder, `humanoid.upper-arm.${word}`, materials.outfit, 0.066, 0.3);
|
||||||
|
const elbow = namedGroup(`humanoid.elbow.${word}`, [0, -0.3, 0]);
|
||||||
|
shoulder.add(elbow);
|
||||||
|
limb(elbow, `humanoid.forearm.${word}`, materials.skin, 0.055, 0.27);
|
||||||
|
elbow.add(
|
||||||
|
actorMesh(`humanoid.hand.${word}`, new THREE.SphereGeometry(0.065, 10, 8), materials.skin, {
|
||||||
|
position: [0, -0.295, -0.012],
|
||||||
|
scale: [0.72, 1.08, 0.55],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const side of [-1, 1] as const) {
|
||||||
|
const word = side < 0 ? "left" : "right";
|
||||||
|
const hip = namedGroup(`humanoid.hip.${word}`, [side * 0.105 * widthScale, -0.02, 0]);
|
||||||
|
pelvis.add(hip);
|
||||||
|
limb(hip, `humanoid.thigh.${word}`, materials.outfit, 0.085, 0.43);
|
||||||
|
const knee = namedGroup(`humanoid.knee.${word}`, [0, -0.43, 0]);
|
||||||
|
hip.add(knee);
|
||||||
|
limb(knee, `humanoid.shin.${word}`, materials.outfit, 0.072, 0.4);
|
||||||
|
knee.add(
|
||||||
|
actorMesh(`humanoid.shoe.${word}`, new THREE.BoxGeometry(0.15, 0.09, 0.25), materials.sole, {
|
||||||
|
position: [0, -0.405, -0.055],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveHumanoid(root, !options.materials);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone the hierarchy while sharing geometry and materials. */
|
||||||
|
export function cloneHumanoid(source: HumanoidRig): HumanoidRig {
|
||||||
|
return resolveHumanoid(source.root.clone(true), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function poseHumanoid(rig: HumanoidRig, pose: HumanoidPose = {}): void {
|
||||||
|
const phase = pose.walkPhase ?? 0;
|
||||||
|
const stride = THREE.MathUtils.clamp(pose.stride ?? 0, 0, 0.75);
|
||||||
|
const arm = THREE.MathUtils.clamp(pose.armSwing ?? stride * 0.85, 0, 0.7);
|
||||||
|
const swing = Math.sin(phase);
|
||||||
|
const bendLeft = Math.max(0, -swing) * stride * 0.7;
|
||||||
|
const bendRight = Math.max(0, swing) * stride * 0.7;
|
||||||
|
rig.joints.hipLeft.rotation.x = swing * stride;
|
||||||
|
rig.joints.hipRight.rotation.x = -swing * stride;
|
||||||
|
rig.joints.kneeLeft.rotation.x = -bendLeft;
|
||||||
|
rig.joints.kneeRight.rotation.x = -bendRight;
|
||||||
|
rig.joints.shoulderLeft.rotation.x = -swing * arm;
|
||||||
|
rig.joints.shoulderRight.rotation.x = swing * arm;
|
||||||
|
rig.joints.elbowLeft.rotation.x = 0.1 + Math.max(0, swing) * 0.18;
|
||||||
|
rig.joints.elbowRight.rotation.x = 0.1 + Math.max(0, -swing) * 0.18;
|
||||||
|
rig.joints.pelvis.position.y = HUMANOID_METRICS.hipY + Math.abs(Math.cos(phase)) * stride * 0.018;
|
||||||
|
rig.joints.head.rotation.y = THREE.MathUtils.clamp(pose.headYaw ?? 0, -0.9, 0.9);
|
||||||
|
rig.joints.head.rotation.x = THREE.MathUtils.clamp(pose.headPitch ?? 0, -0.45, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disposeHumanoid(
|
||||||
|
rig: HumanoidRig,
|
||||||
|
options: { disposeMaterials?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
disposeActor(rig, options);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
export {
|
||||||
|
HUMANOID_METRICS,
|
||||||
|
buildHumanoid,
|
||||||
|
cloneHumanoid,
|
||||||
|
createHumanoidMaterials,
|
||||||
|
disposeHumanoid,
|
||||||
|
poseHumanoid,
|
||||||
|
type HumanoidBuildOptions,
|
||||||
|
type HumanoidJoints,
|
||||||
|
type HumanoidMaterials,
|
||||||
|
type HumanoidPose,
|
||||||
|
type HumanoidRig,
|
||||||
|
} from "./humanoid.ts";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DOG_METRICS,
|
||||||
|
buildDog,
|
||||||
|
cloneDog,
|
||||||
|
createDogMaterials,
|
||||||
|
disposeDog,
|
||||||
|
poseDogAttention,
|
||||||
|
poseDogWalk,
|
||||||
|
type DogBuildOptions,
|
||||||
|
type DogJoints,
|
||||||
|
type DogMaterials,
|
||||||
|
type DogRig,
|
||||||
|
} from "./dog.ts";
|
||||||
|
|
||||||
|
export {
|
||||||
|
CROW_METRICS,
|
||||||
|
buildCrow,
|
||||||
|
cloneCrow,
|
||||||
|
createCrowMaterials,
|
||||||
|
disposeCrow,
|
||||||
|
poseCrowFlight,
|
||||||
|
type CrowBuildOptions,
|
||||||
|
type CrowJoints,
|
||||||
|
type CrowMaterials,
|
||||||
|
type CrowRig,
|
||||||
|
} from "./crow.ts";
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
export {
|
||||||
|
MODEL_X_METRICS,
|
||||||
|
advanceModelXWheels,
|
||||||
|
buildModelX,
|
||||||
|
cloneModelX,
|
||||||
|
createModelXMaterials,
|
||||||
|
disposeModelX,
|
||||||
|
modelXInstanceParts,
|
||||||
|
setModelXSteering,
|
||||||
|
setModelXWheelRotation,
|
||||||
|
type ModelXBuildOptions,
|
||||||
|
type ModelXDetail,
|
||||||
|
type ModelXInstancePart,
|
||||||
|
type ModelXMaterials,
|
||||||
|
type ModelXRig,
|
||||||
|
type ModelXWheel,
|
||||||
|
type ModelXWheels,
|
||||||
|
} from "./modelX.ts";
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
/**
|
||||||
|
* A procedural, unbadged black Model X-style electric crossover.
|
||||||
|
*
|
||||||
|
* The asset is authored in metres with its origin on the road at the centre of
|
||||||
|
* the wheelbase. It faces -Z at yaw zero, matching the rest of Tera; +X is the
|
||||||
|
* vehicle's right. The broad, low nose, rising panoramic glass, cab-forward
|
||||||
|
* roof and tapered tail carry the silhouette. Fine detail is deliberately
|
||||||
|
* sparse because this car is normally read from a corridor or chase camera.
|
||||||
|
*
|
||||||
|
* Build one rig and clone it. Clones share geometry and materials while their
|
||||||
|
* wheel and steering joints remain independent. Dispose the original only
|
||||||
|
* after every clone has left the scene.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||||
|
|
||||||
|
export const MODEL_X_METRICS = {
|
||||||
|
length: 5.04,
|
||||||
|
width: 2.08,
|
||||||
|
height: 1.68,
|
||||||
|
wheelbase: 2.965,
|
||||||
|
track: 1.78,
|
||||||
|
wheelRadius: 0.405,
|
||||||
|
tireWidth: 0.285,
|
||||||
|
maxSteeringAngle: 0.62,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ModelXDetail = "corridor" | "follow";
|
||||||
|
|
||||||
|
export interface ModelXMaterials {
|
||||||
|
paint: THREE.Material;
|
||||||
|
glass: THREE.Material;
|
||||||
|
trim: THREE.Material;
|
||||||
|
tire: THREE.Material;
|
||||||
|
wheel: THREE.Material;
|
||||||
|
brake: THREE.Material;
|
||||||
|
headlight: THREE.Material;
|
||||||
|
tailLight: THREE.Material;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelXBuildOptions {
|
||||||
|
detail?: ModelXDetail;
|
||||||
|
/** Used only when `materials` is omitted. */
|
||||||
|
paint?: THREE.ColorRepresentation;
|
||||||
|
/** A complete externally owned skin, useful for a world-level material pool. */
|
||||||
|
materials?: ModelXMaterials;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelXWheel {
|
||||||
|
/** Yaw this group to steer. Rear steering groups stay at zero. */
|
||||||
|
steering: THREE.Group;
|
||||||
|
/** Rotate this group around local X to roll the wheel. */
|
||||||
|
spin: THREE.Group;
|
||||||
|
tire: THREE.Mesh;
|
||||||
|
rim: THREE.Mesh;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelXWheels {
|
||||||
|
frontLeft: ModelXWheel;
|
||||||
|
frontRight: ModelXWheel;
|
||||||
|
rearLeft: ModelXWheel;
|
||||||
|
rearRight: ModelXWheel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelXRig {
|
||||||
|
root: THREE.Group;
|
||||||
|
/** Static bodywork; useful when a traffic renderer instances body meshes. */
|
||||||
|
body: THREE.Group;
|
||||||
|
wheels: ModelXWheels;
|
||||||
|
/** True only on a prototype that created its own default material set. */
|
||||||
|
readonly ownsMaterials: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelXInstancePart {
|
||||||
|
name: string;
|
||||||
|
geometry: THREE.BufferGeometry;
|
||||||
|
material: THREE.Material | THREE.Material[];
|
||||||
|
/** Transform from the vehicle root to this drawable in the neutral pose. */
|
||||||
|
matrix: THREE.Matrix4;
|
||||||
|
castShadow: boolean;
|
||||||
|
receiveShadow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNIT_BOX = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
const UNIT_PLANE = new THREE.PlaneGeometry(1, 1);
|
||||||
|
|
||||||
|
/** Default materials are intentionally untextured: no binary art and no UV dependency. */
|
||||||
|
export function createModelXMaterials(
|
||||||
|
paint: THREE.ColorRepresentation = 0x050607,
|
||||||
|
): ModelXMaterials {
|
||||||
|
const body = new THREE.MeshPhysicalMaterial({
|
||||||
|
name: "model-x.paint",
|
||||||
|
color: paint,
|
||||||
|
metalness: 0.72,
|
||||||
|
roughness: 0.22,
|
||||||
|
clearcoat: 1,
|
||||||
|
clearcoatRoughness: 0.12,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
paint: body,
|
||||||
|
glass: new THREE.MeshPhysicalMaterial({
|
||||||
|
name: "model-x.glass",
|
||||||
|
color: 0x101b22,
|
||||||
|
metalness: 0.12,
|
||||||
|
roughness: 0.12,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.86,
|
||||||
|
side: THREE.DoubleSide,
|
||||||
|
}),
|
||||||
|
trim: new THREE.MeshStandardMaterial({
|
||||||
|
name: "model-x.trim",
|
||||||
|
color: 0x101214,
|
||||||
|
metalness: 0.68,
|
||||||
|
roughness: 0.3,
|
||||||
|
}),
|
||||||
|
tire: new THREE.MeshStandardMaterial({
|
||||||
|
name: "model-x.tire",
|
||||||
|
color: 0x111213,
|
||||||
|
metalness: 0,
|
||||||
|
roughness: 0.92,
|
||||||
|
}),
|
||||||
|
wheel: new THREE.MeshStandardMaterial({
|
||||||
|
name: "model-x.wheel",
|
||||||
|
color: 0x2d3135,
|
||||||
|
metalness: 0.88,
|
||||||
|
roughness: 0.25,
|
||||||
|
}),
|
||||||
|
brake: new THREE.MeshStandardMaterial({
|
||||||
|
name: "model-x.brake",
|
||||||
|
color: 0x72777a,
|
||||||
|
metalness: 0.92,
|
||||||
|
roughness: 0.32,
|
||||||
|
}),
|
||||||
|
headlight: new THREE.MeshStandardMaterial({
|
||||||
|
name: "model-x.headlight",
|
||||||
|
color: 0xd9f4ff,
|
||||||
|
emissive: 0xb8eaff,
|
||||||
|
emissiveIntensity: 2.4,
|
||||||
|
roughness: 0.16,
|
||||||
|
}),
|
||||||
|
tailLight: new THREE.MeshStandardMaterial({
|
||||||
|
name: "model-x.tail-light",
|
||||||
|
color: 0xff2433,
|
||||||
|
emissive: 0xd70918,
|
||||||
|
emissiveIntensity: 1.9,
|
||||||
|
roughness: 0.2,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Placement {
|
||||||
|
position?: readonly [number, number, number];
|
||||||
|
scale?: readonly [number, number, number];
|
||||||
|
rotation?: readonly [number, number, number];
|
||||||
|
}
|
||||||
|
|
||||||
|
function matrix(place: Placement): THREE.Matrix4 {
|
||||||
|
const position = new THREE.Vector3(...(place.position ?? [0, 0, 0]));
|
||||||
|
const quaternion = new THREE.Quaternion().setFromEuler(
|
||||||
|
new THREE.Euler(...(place.rotation ?? [0, 0, 0]), "YXZ"),
|
||||||
|
);
|
||||||
|
const scale = new THREE.Vector3(...(place.scale ?? [1, 1, 1]));
|
||||||
|
return new THREE.Matrix4().compose(position, quaternion, scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
class StaticBatch {
|
||||||
|
private readonly groups = new Map<THREE.Material, THREE.BufferGeometry[]>();
|
||||||
|
|
||||||
|
add(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): void {
|
||||||
|
const transformed = geometry.clone().applyMatrix4(matrix(place));
|
||||||
|
const found = this.groups.get(material);
|
||||||
|
if (found) found.push(transformed);
|
||||||
|
else this.groups.set(material, [transformed]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a one-off generated shape, then release its untransformed source. */
|
||||||
|
addOwned(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): void {
|
||||||
|
this.add(geometry, material, place);
|
||||||
|
geometry.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
box(material: THREE.Material, place: Placement): void {
|
||||||
|
this.add(UNIT_BOX, material, place);
|
||||||
|
}
|
||||||
|
|
||||||
|
build(name: string): THREE.Group {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = name;
|
||||||
|
for (const [material, geometries] of this.groups) {
|
||||||
|
const geometry =
|
||||||
|
geometries.length === 1 ? geometries[0] : mergeGeometries(geometries, false);
|
||||||
|
if (geometries.length > 1) for (const item of geometries) item.dispose();
|
||||||
|
if (!geometry) continue;
|
||||||
|
const mesh = new THREE.Mesh(geometry, material);
|
||||||
|
mesh.name = `${name}:${material.name || "surface"}`;
|
||||||
|
mesh.castShadow = true;
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
group.add(mesh);
|
||||||
|
}
|
||||||
|
this.groups.clear();
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BodySection {
|
||||||
|
z: number;
|
||||||
|
halfWidth: number;
|
||||||
|
bottom: number;
|
||||||
|
shoulder: number;
|
||||||
|
belt: number;
|
||||||
|
roof: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BODY_SECTIONS: readonly BodySection[] = [
|
||||||
|
{ z: -2.52, halfWidth: 0.58, bottom: 0.55, shoulder: 0.68, belt: 0.79, roof: 0.84 },
|
||||||
|
{ z: -2.34, halfWidth: 0.88, bottom: 0.47, shoulder: 0.69, belt: 0.88, roof: 0.93 },
|
||||||
|
{ z: -1.58, halfWidth: 1.0, bottom: 0.4, shoulder: 0.72, belt: 0.98, roof: 1.08 },
|
||||||
|
{ z: -0.78, halfWidth: 0.99, bottom: 0.39, shoulder: 0.76, belt: 1.06, roof: 1.32 },
|
||||||
|
{ z: -0.12, halfWidth: 0.97, bottom: 0.39, shoulder: 0.78, belt: 1.12, roof: 1.58 },
|
||||||
|
{ z: 0.72, halfWidth: 0.96, bottom: 0.4, shoulder: 0.78, belt: 1.1, roof: 1.68 },
|
||||||
|
{ z: 1.48, halfWidth: 0.97, bottom: 0.41, shoulder: 0.77, belt: 1.06, roof: 1.52 },
|
||||||
|
{ z: 2.28, halfWidth: 0.89, bottom: 0.48, shoulder: 0.72, belt: 0.98, roof: 1.18 },
|
||||||
|
{ z: 2.52, halfWidth: 0.63, bottom: 0.57, shoulder: 0.69, belt: 0.82, roof: 0.9 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** A low-poly longitudinal loft with deliberately strong shoulder highlights. */
|
||||||
|
function bodyLoft(): THREE.BufferGeometry {
|
||||||
|
const positions: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
const ring = 10;
|
||||||
|
|
||||||
|
for (const s of BODY_SECTIONS) {
|
||||||
|
const points: readonly [number, number][] = [
|
||||||
|
[-s.halfWidth * 0.7, s.bottom],
|
||||||
|
[-s.halfWidth, s.shoulder],
|
||||||
|
[-s.halfWidth * 0.98, s.belt],
|
||||||
|
[-s.halfWidth * 0.83, s.roof - 0.08],
|
||||||
|
[-s.halfWidth * 0.58, s.roof],
|
||||||
|
[s.halfWidth * 0.58, s.roof],
|
||||||
|
[s.halfWidth * 0.83, s.roof - 0.08],
|
||||||
|
[s.halfWidth * 0.98, s.belt],
|
||||||
|
[s.halfWidth, s.shoulder],
|
||||||
|
[s.halfWidth * 0.7, s.bottom],
|
||||||
|
];
|
||||||
|
for (const [x, y] of points) positions.push(x, y, s.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let z = 0; z < BODY_SECTIONS.length - 1; z++) {
|
||||||
|
for (let i = 0; i < ring; i++) {
|
||||||
|
const next = (i + 1) % ring;
|
||||||
|
const a = z * ring + i;
|
||||||
|
const b = z * ring + next;
|
||||||
|
const c = (z + 1) * ring + next;
|
||||||
|
const d = (z + 1) * ring + i;
|
||||||
|
indices.push(a, b, d, b, c, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontCenter = positions.length / 3;
|
||||||
|
positions.push(0, 0.72, BODY_SECTIONS[0]!.z);
|
||||||
|
const rearCenter = positions.length / 3;
|
||||||
|
positions.push(0, 0.74, BODY_SECTIONS[BODY_SECTIONS.length - 1]!.z);
|
||||||
|
for (let i = 0; i < ring; i++) {
|
||||||
|
const next = (i + 1) % ring;
|
||||||
|
indices.push(frontCenter, next, i);
|
||||||
|
const base = (BODY_SECTIONS.length - 1) * ring;
|
||||||
|
indices.push(rearCenter, base + i, base + next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
geometry.computeBoundingBox();
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
geometry.name = "model-x.body-loft";
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function quad(points: readonly [number, number, number][]): THREE.BufferGeometry {
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(points.flat(), 3));
|
||||||
|
geometry.setIndex([0, 1, 2, 0, 2, 3]);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBody(materials: ModelXMaterials, detail: ModelXDetail): THREE.Group {
|
||||||
|
const batch = new StaticBatch();
|
||||||
|
batch.addOwned(bodyLoft(), materials.paint);
|
||||||
|
|
||||||
|
// The windows are fitted panels rather than holes. Against black paint their
|
||||||
|
// blue-grey reflectance is what separates the greenhouse from the body.
|
||||||
|
batch.addOwned(
|
||||||
|
quad([
|
||||||
|
[-0.77, 1.04, -0.79],
|
||||||
|
[0.77, 1.04, -0.79],
|
||||||
|
[0.63, 1.56, -0.14],
|
||||||
|
[-0.63, 1.56, -0.14],
|
||||||
|
]),
|
||||||
|
materials.glass,
|
||||||
|
);
|
||||||
|
batch.addOwned(
|
||||||
|
quad([
|
||||||
|
[-0.61, 1.58, -0.1],
|
||||||
|
[0.61, 1.58, -0.1],
|
||||||
|
[0.58, 1.64, 1.18],
|
||||||
|
[-0.58, 1.64, 1.18],
|
||||||
|
]),
|
||||||
|
materials.glass,
|
||||||
|
);
|
||||||
|
batch.addOwned(
|
||||||
|
quad([
|
||||||
|
[-0.65, 1.5, 1.45],
|
||||||
|
[0.65, 1.5, 1.45],
|
||||||
|
[0.72, 1.04, 2.12],
|
||||||
|
[-0.72, 1.04, 2.12],
|
||||||
|
]),
|
||||||
|
materials.glass,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const side of [-1, 1]) {
|
||||||
|
const x = side * 0.956;
|
||||||
|
batch.addOwned(
|
||||||
|
quad([
|
||||||
|
[x, 1.06, -0.71],
|
||||||
|
[x, 1.55, -0.08],
|
||||||
|
[x, 1.59, 0.3],
|
||||||
|
[x, 1.06, 0.3],
|
||||||
|
]),
|
||||||
|
materials.glass,
|
||||||
|
);
|
||||||
|
batch.addOwned(
|
||||||
|
quad([
|
||||||
|
[x, 1.06, 0.34],
|
||||||
|
[x, 1.59, 0.34],
|
||||||
|
[side * 0.94, 1.49, 1.34],
|
||||||
|
[side * 0.96, 1.05, 1.46],
|
||||||
|
]),
|
||||||
|
materials.glass,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Slim pillars and the falcon-door roof seam survive a chase camera while
|
||||||
|
// keeping the side glass readable as two doors rather than one dark strip.
|
||||||
|
batch.box(materials.trim, {
|
||||||
|
position: [x, 1.31, 0.32],
|
||||||
|
scale: [0.028, 0.56, 0.045],
|
||||||
|
});
|
||||||
|
batch.box(materials.trim, {
|
||||||
|
position: [side * 0.76, 1.625, 0.73],
|
||||||
|
scale: [0.022, 0.025, 0.86],
|
||||||
|
rotation: [0, 0, side * -0.08],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush black handles: relief and a highlight, never a badge.
|
||||||
|
if (detail === "follow") {
|
||||||
|
for (const z of [-0.24, 0.82]) {
|
||||||
|
batch.box(materials.trim, {
|
||||||
|
position: [side * 0.987, 1.025, z],
|
||||||
|
scale: [0.018, 0.035, 0.22],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Headlights sweep back into the fender; taillights wrap the rear corner.
|
||||||
|
batch.box(materials.headlight, {
|
||||||
|
position: [side * 0.65, 0.88, -2.39],
|
||||||
|
scale: [0.56, 0.085, 0.035],
|
||||||
|
rotation: [0, side * 0.13, side * 0.04],
|
||||||
|
});
|
||||||
|
batch.box(materials.tailLight, {
|
||||||
|
position: [side * 0.65, 0.97, 2.35],
|
||||||
|
scale: [0.58, 0.075, 0.04],
|
||||||
|
rotation: [0, side * -0.12, side * -0.03],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower aero surfaces stop the black shell dissolving into the road.
|
||||||
|
batch.box(materials.trim, {
|
||||||
|
position: [0, 0.39, -1.92],
|
||||||
|
scale: [1.82, 0.13, 1.02],
|
||||||
|
});
|
||||||
|
batch.box(materials.trim, {
|
||||||
|
position: [0, 0.42, 2.32],
|
||||||
|
scale: [1.5, 0.15, 0.3],
|
||||||
|
});
|
||||||
|
batch.add(UNIT_PLANE, materials.trim, {
|
||||||
|
position: [0, 1.685, 0.68],
|
||||||
|
scale: [1.18, 0.72, 1],
|
||||||
|
rotation: [-Math.PI / 2, 0, 0],
|
||||||
|
});
|
||||||
|
|
||||||
|
const body = batch.build("model-x.body");
|
||||||
|
body.userData.kind = "vehicle-body";
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WheelGeometry {
|
||||||
|
tire: THREE.BufferGeometry;
|
||||||
|
rim: THREE.BufferGeometry;
|
||||||
|
brake: THREE.BufferGeometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWheelGeometry(detail: ModelXDetail): WheelGeometry {
|
||||||
|
const radialSegments = detail === "follow" ? 24 : 16;
|
||||||
|
const tubularSegments = detail === "follow" ? 12 : 8;
|
||||||
|
const tire = new THREE.TorusGeometry(0.315, 0.09, tubularSegments, radialSegments);
|
||||||
|
tire.rotateY(Math.PI / 2);
|
||||||
|
tire.name = "model-x.wheel.tire";
|
||||||
|
|
||||||
|
const rimParts: THREE.BufferGeometry[] = [];
|
||||||
|
const lip = new THREE.TorusGeometry(0.218, 0.024, 6, radialSegments);
|
||||||
|
lip.rotateY(Math.PI / 2);
|
||||||
|
rimParts.push(lip);
|
||||||
|
const hub = new THREE.CylinderGeometry(0.067, 0.067, 0.1, 12, 1);
|
||||||
|
hub.rotateZ(Math.PI / 2);
|
||||||
|
rimParts.push(hub);
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
const angle = (i / 5) * Math.PI * 2;
|
||||||
|
const spoke = UNIT_BOX.clone().applyMatrix4(
|
||||||
|
matrix({
|
||||||
|
position: [0, Math.cos(angle) * 0.13, Math.sin(angle) * 0.13],
|
||||||
|
scale: [0.055, 0.255, 0.035],
|
||||||
|
rotation: [angle, 0, 0],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
rimParts.push(spoke);
|
||||||
|
}
|
||||||
|
const rim = mergeGeometries(rimParts, false);
|
||||||
|
for (const part of rimParts) part.dispose();
|
||||||
|
if (!rim) throw new Error("model-x: could not build wheel rim");
|
||||||
|
rim.name = "model-x.wheel.rim";
|
||||||
|
|
||||||
|
const brake = new THREE.CylinderGeometry(0.17, 0.17, MODEL_X_METRICS.tireWidth + 0.018, 20, 1);
|
||||||
|
brake.rotateZ(Math.PI / 2);
|
||||||
|
brake.name = "model-x.wheel.brake";
|
||||||
|
return { tire, rim, brake };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildWheel(
|
||||||
|
name: string,
|
||||||
|
x: number,
|
||||||
|
z: number,
|
||||||
|
geometries: WheelGeometry,
|
||||||
|
materials: ModelXMaterials,
|
||||||
|
): ModelXWheel {
|
||||||
|
const steering = new THREE.Group();
|
||||||
|
steering.name = `${name}.steering`;
|
||||||
|
steering.position.set(x, MODEL_X_METRICS.wheelRadius, z);
|
||||||
|
|
||||||
|
const spin = new THREE.Group();
|
||||||
|
spin.name = `${name}.spin`;
|
||||||
|
steering.add(spin);
|
||||||
|
|
||||||
|
const brake = new THREE.Mesh(geometries.brake, materials.brake);
|
||||||
|
brake.name = `${name}.brake`;
|
||||||
|
brake.castShadow = true;
|
||||||
|
spin.add(brake);
|
||||||
|
|
||||||
|
const rim = new THREE.Mesh(geometries.rim, materials.wheel);
|
||||||
|
rim.name = `${name}.rim`;
|
||||||
|
rim.castShadow = true;
|
||||||
|
spin.add(rim);
|
||||||
|
|
||||||
|
const tire = new THREE.Mesh(geometries.tire, materials.tire);
|
||||||
|
tire.name = `${name}.tire`;
|
||||||
|
tire.castShadow = true;
|
||||||
|
tire.receiveShadow = true;
|
||||||
|
spin.add(tire);
|
||||||
|
|
||||||
|
return { steering, spin, tire, rim };
|
||||||
|
}
|
||||||
|
|
||||||
|
const WHEEL_KEYS = ["frontLeft", "frontRight", "rearLeft", "rearRight"] as const;
|
||||||
|
|
||||||
|
function resolveRig(root: THREE.Group, ownsMaterials: boolean): ModelXRig {
|
||||||
|
const body = root.getObjectByName("model-x.body");
|
||||||
|
if (!(body instanceof THREE.Group)) throw new Error("model-x: body group is missing");
|
||||||
|
|
||||||
|
const wheels = {} as Record<(typeof WHEEL_KEYS)[number], ModelXWheel>;
|
||||||
|
for (const key of WHEEL_KEYS) {
|
||||||
|
const steering = root.getObjectByName(`${key}.steering`);
|
||||||
|
const spin = root.getObjectByName(`${key}.spin`);
|
||||||
|
const tire = root.getObjectByName(`${key}.tire`);
|
||||||
|
const rim = root.getObjectByName(`${key}.rim`);
|
||||||
|
if (!(steering instanceof THREE.Group) || !(spin instanceof THREE.Group)) {
|
||||||
|
throw new Error(`model-x: wheel rig "${key}" is incomplete`);
|
||||||
|
}
|
||||||
|
if (!(tire instanceof THREE.Mesh) || !(rim instanceof THREE.Mesh)) {
|
||||||
|
throw new Error(`model-x: wheel meshes for "${key}" are missing`);
|
||||||
|
}
|
||||||
|
wheels[key] = { steering, spin, tire, rim };
|
||||||
|
}
|
||||||
|
return { root, body, wheels, ownsMaterials };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildModelX(options: ModelXBuildOptions = {}): ModelXRig {
|
||||||
|
const detail = options.detail ?? "follow";
|
||||||
|
const materials = options.materials ?? createModelXMaterials(options.paint);
|
||||||
|
const root = new THREE.Group();
|
||||||
|
root.name = "model-x";
|
||||||
|
root.userData.kind = "vehicle";
|
||||||
|
root.userData.vehicleModel = "model-x";
|
||||||
|
root.userData.forwardAxis = "-Z";
|
||||||
|
|
||||||
|
root.add(buildBody(materials, detail));
|
||||||
|
const geometry = createWheelGeometry(detail);
|
||||||
|
const halfTrack = MODEL_X_METRICS.track / 2;
|
||||||
|
const halfWheelbase = MODEL_X_METRICS.wheelbase / 2;
|
||||||
|
const wheels: ModelXWheels = {
|
||||||
|
frontLeft: buildWheel("frontLeft", -halfTrack, -halfWheelbase, geometry, materials),
|
||||||
|
frontRight: buildWheel("frontRight", halfTrack, -halfWheelbase, geometry, materials),
|
||||||
|
rearLeft: buildWheel("rearLeft", -halfTrack, halfWheelbase, geometry, materials),
|
||||||
|
rearRight: buildWheel("rearRight", halfTrack, halfWheelbase, geometry, materials),
|
||||||
|
};
|
||||||
|
for (const key of WHEEL_KEYS) root.add(wheels[key].steering);
|
||||||
|
return { root, body: root.getObjectByName("model-x.body") as THREE.Group, wheels, ownsMaterials: !options.materials };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clone the hierarchy while sharing all immutable geometry and material resources. */
|
||||||
|
export function cloneModelX(source: ModelXRig): ModelXRig {
|
||||||
|
return resolveRig(source.root.clone(true), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setModelXWheelRotation(rig: ModelXRig, radians: number): void {
|
||||||
|
for (const key of WHEEL_KEYS) rig.wheels[key].spin.rotation.x = radians;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function advanceModelXWheels(rig: ModelXRig, distanceMetres: number): void {
|
||||||
|
const delta = -distanceMetres / MODEL_X_METRICS.wheelRadius;
|
||||||
|
for (const key of WHEEL_KEYS) rig.wheels[key].spin.rotation.x += delta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set both front wheels, clamped to the physical steering envelope. */
|
||||||
|
export function setModelXSteering(rig: ModelXRig, radians: number): void {
|
||||||
|
const angle = THREE.MathUtils.clamp(
|
||||||
|
radians,
|
||||||
|
-MODEL_X_METRICS.maxSteeringAngle,
|
||||||
|
MODEL_X_METRICS.maxSteeringAngle,
|
||||||
|
);
|
||||||
|
rig.wheels.frontLeft.steering.rotation.y = angle;
|
||||||
|
rig.wheels.frontRight.steering.rotation.y = angle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describe neutral-pose drawables for a traffic renderer that groups matching
|
||||||
|
* pieces into InstancedMesh batches. Matrices are cloned and safe to retain.
|
||||||
|
*/
|
||||||
|
export function modelXInstanceParts(rig: ModelXRig): ModelXInstancePart[] {
|
||||||
|
rig.root.updateMatrixWorld(true);
|
||||||
|
const inverseRoot = rig.root.matrixWorld.clone().invert();
|
||||||
|
const result: ModelXInstancePart[] = [];
|
||||||
|
rig.root.traverse((object) => {
|
||||||
|
if (!(object instanceof THREE.Mesh)) return;
|
||||||
|
result.push({
|
||||||
|
name: object.name,
|
||||||
|
geometry: object.geometry,
|
||||||
|
material: object.material,
|
||||||
|
matrix: inverseRoot.clone().multiply(object.matrixWorld),
|
||||||
|
castShadow: object.castShadow,
|
||||||
|
receiveShadow: object.receiveShadow,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose the prototype's unique geometry, and its default materials if owned.
|
||||||
|
* Never dispose a clone: it references the same GPU resources as the prototype.
|
||||||
|
*/
|
||||||
|
export function disposeModelX(
|
||||||
|
rig: ModelXRig,
|
||||||
|
options: { disposeMaterials?: boolean } = {},
|
||||||
|
): void {
|
||||||
|
const geometries = new Set<THREE.BufferGeometry>();
|
||||||
|
const materials = new Set<THREE.Material>();
|
||||||
|
rig.root.traverse((object) => {
|
||||||
|
if (!(object instanceof THREE.Mesh)) return;
|
||||||
|
geometries.add(object.geometry);
|
||||||
|
if (Array.isArray(object.material)) {
|
||||||
|
for (const material of object.material) materials.add(material);
|
||||||
|
} else materials.add(object.material);
|
||||||
|
});
|
||||||
|
for (const geometry of geometries) geometry.dispose();
|
||||||
|
if (options.disposeMaterials ?? rig.ownsMaterials) {
|
||||||
|
for (const material of materials) material.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
/**
|
||||||
|
* California at corridor scale: Los Angeles to San Francisco.
|
||||||
|
*
|
||||||
|
* This is deliberately sparse. San Francisco and Southern California remain
|
||||||
|
* the detailed boards; this one is the connective tissue between them. A
|
||||||
|
* roughly two-kilometre height cell and a handful of range-scale hills keep the
|
||||||
|
* state readable without pretending a 600 km drive is one city mesh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||||
|
import type { City, LatLng } from "../engine/types.ts";
|
||||||
|
|
||||||
|
function routePath(routeId: string): LatLng[] {
|
||||||
|
const route = CALIFORNIA_TRANSPORT.routes.find((candidate) => candidate.id === routeId);
|
||||||
|
if (!route) throw new Error(`california: missing transport route "${routeId}"`);
|
||||||
|
const segments = new Map(CALIFORNIA_TRANSPORT.segments.map((segment) => [segment.id, segment]));
|
||||||
|
const nodes = new Map(CALIFORNIA_TRANSPORT.nodes.map((node) => [node.id, node]));
|
||||||
|
const points: LatLng[] = [];
|
||||||
|
for (const id of route.segmentIds) {
|
||||||
|
const segment = segments.get(id);
|
||||||
|
if (!segment) continue;
|
||||||
|
const from = nodes.get(segment.fromNodeId)?.position;
|
||||||
|
const to = nodes.get(segment.toNodeId)?.position;
|
||||||
|
if (!from || !to) continue;
|
||||||
|
if (points.length === 0) points.push([from.lat, from.lng]);
|
||||||
|
points.push([to.lat, to.lng]);
|
||||||
|
}
|
||||||
|
return points;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CALIFORNIA_US_101 = routePath("la-sf-us-101");
|
||||||
|
export const CALIFORNIA_I_5 = routePath("la-sf-i-5");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Original, hand-authored coast silhouette for this coarse board. It is a
|
||||||
|
* visual boundary, not survey data; the eastern and northern edges close well
|
||||||
|
* outside the route so the heightfield's coastal falloff stays off the road.
|
||||||
|
*/
|
||||||
|
export const CORRIDOR_LAND: LatLng[] = [
|
||||||
|
[38.2, -123.35],
|
||||||
|
[37.92, -122.74],
|
||||||
|
[37.79, -122.5],
|
||||||
|
[37.45, -122.43],
|
||||||
|
[37.08, -122.28],
|
||||||
|
[36.62, -121.94],
|
||||||
|
[36.15, -121.7],
|
||||||
|
[35.65, -121.23],
|
||||||
|
[35.18, -120.85],
|
||||||
|
[34.72, -120.64],
|
||||||
|
[34.42, -120.48],
|
||||||
|
[34.18, -119.95],
|
||||||
|
[34.03, -118.74],
|
||||||
|
[33.72, -118.15],
|
||||||
|
[33.35, -117.72],
|
||||||
|
[33.1, -117.38],
|
||||||
|
[33.05, -117.1],
|
||||||
|
[38.25, -117.1],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CALIFORNIA_CITY: City = {
|
||||||
|
id: "california",
|
||||||
|
name: "California",
|
||||||
|
center: { lat: 35.66, lng: -120.15 },
|
||||||
|
bounds: { minLat: 33.15, maxLat: 38.05, minLng: -123.05, maxLng: -117.55 },
|
||||||
|
latScale: 58,
|
||||||
|
verticalExaggeration: 2.25,
|
||||||
|
// About 2.2 km. This board is a route atlas; city detail lives one level in.
|
||||||
|
cellLat: 0.02,
|
||||||
|
cellLng: 0.024,
|
||||||
|
coastFalloff: 0.025,
|
||||||
|
landmasses: [CORRIDOR_LAND],
|
||||||
|
parks: [],
|
||||||
|
inlandWater: [],
|
||||||
|
hills: [
|
||||||
|
{ name: "Santa Monica Mountains", lat: 34.12, lng: -118.65, elevation: 900, radius: 0.34 },
|
||||||
|
{ name: "San Emigdio Mountains", lat: 34.88, lng: -119.05, elevation: 2_000, radius: 0.48 },
|
||||||
|
{ name: "Temblor Range", lat: 35.36, lng: -119.83, elevation: 1_300, radius: 0.56 },
|
||||||
|
{ name: "Santa Lucia Range", lat: 35.75, lng: -121.25, elevation: 1_580, radius: 0.7 },
|
||||||
|
{ name: "Diablo Range", lat: 36.63, lng: -121.18, elevation: 1_300, radius: 0.8 },
|
||||||
|
{ name: "Mount Hamilton", lat: 37.34, lng: -121.64, elevation: 1_280, radius: 0.34 },
|
||||||
|
{ name: "Santa Cruz Mountains", lat: 37.18, lng: -122.18, elevation: 1_150, radius: 0.52 },
|
||||||
|
],
|
||||||
|
districts: [],
|
||||||
|
landmarks: [
|
||||||
|
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, height: 1_100, footprint: 0.055, shape: "tower", color: 0x9b856b, label: true },
|
||||||
|
{ name: "San Francisco", lat: 37.7749, lng: -122.4194, height: 1_000, footprint: 0.05, shape: "tower", color: 0x8799a8, label: true },
|
||||||
|
],
|
||||||
|
bridges: [],
|
||||||
|
roads: [
|
||||||
|
{ path: CALIFORNIA_US_101, width: 1, kind: "freeway" },
|
||||||
|
{ path: CALIFORNIA_I_5, width: 1.06, kind: "freeway" },
|
||||||
|
],
|
||||||
|
chapters: [
|
||||||
|
{
|
||||||
|
id: "california-overview",
|
||||||
|
label: "California",
|
||||||
|
shortLabel: "State",
|
||||||
|
number: "01",
|
||||||
|
description: "Los Angeles and San Francisco joined as one living route board.",
|
||||||
|
focus: { lat: 35.66, lng: -120.15, distance: 370, height: 320, rotation: 0.68 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "la-sf-us-101",
|
||||||
|
label: "US-101",
|
||||||
|
shortLabel: "101",
|
||||||
|
number: "02",
|
||||||
|
description: "Follow the black Model X up the coast and Salinas Valley through Santa Barbara and San Jose.",
|
||||||
|
focus: { lat: 35.8, lng: -121.04, distance: 112, height: 72, rotation: 0.8 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "la-sf-i-5",
|
||||||
|
label: "I-5 · I-580 · I-80",
|
||||||
|
shortLabel: "I-5",
|
||||||
|
number: "03",
|
||||||
|
description: "Follow the black Model X through the Central Valley, then the honest Bay approach over I-580 and I-80.",
|
||||||
|
focus: { lat: 36.15, lng: -120.15, distance: 105, height: 70, rotation: 0.78 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "los-angeles",
|
||||||
|
label: "Los Angeles",
|
||||||
|
shortLabel: "LA",
|
||||||
|
number: "04",
|
||||||
|
description: "The southern door into Tera's detailed Southern California board.",
|
||||||
|
focus: { lat: 34.0522, lng: -118.2437, distance: 38, height: 26, rotation: 0.8 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "san-francisco",
|
||||||
|
label: "San Francisco",
|
||||||
|
shortLabel: "SF",
|
||||||
|
number: "05",
|
||||||
|
description: "The northern door into the detailed Bay Area board and Lumbridge HQ.",
|
||||||
|
focus: { lat: 37.7749, lng: -122.4194, distance: 38, height: 26, rotation: 0.8 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
palette: {
|
||||||
|
skyTop: 0x7da6c9,
|
||||||
|
skyHorizon: 0xe9d8bb,
|
||||||
|
sea: 0x477891,
|
||||||
|
lake: 0x527f91,
|
||||||
|
shore: 0xc9b789,
|
||||||
|
sand: 0xd9c693,
|
||||||
|
flats: 0xb7a16c,
|
||||||
|
upland: 0x9a8155,
|
||||||
|
park: 0x66764c,
|
||||||
|
parkHigh: 0x485d43,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CALIFORNIA_CITY;
|
||||||
+61
-6
@@ -73,6 +73,20 @@ interface Box {
|
|||||||
commercial: number;
|
commercial: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A named building's claim on the anonymous city scatter, in scene units.
|
||||||
|
*
|
||||||
|
* Circles are deliberately conservative. Anonymous buildings rotate with
|
||||||
|
* their districts and named glyphs rotate with their streets; a circle is the
|
||||||
|
* one cheap overlap test that cannot leave a corner poking through, and there
|
||||||
|
* are only a handful of reservations to test.
|
||||||
|
*/
|
||||||
|
export interface BuildingReservation {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
radius: number;
|
||||||
|
}
|
||||||
|
|
||||||
function polygonBounds(poly: [number, number][]) {
|
function polygonBounds(poly: [number, number][]) {
|
||||||
let minLat = Infinity;
|
let minLat = Infinity;
|
||||||
let maxLat = -Infinity;
|
let maxLat = -Infinity;
|
||||||
@@ -87,7 +101,10 @@ function polygonBounds(poly: [number, number][]) {
|
|||||||
return { minLat, maxLat, minLng, maxLng };
|
return { minLat, maxLat, minLng, maxLng };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createBlocks(world: World): THREE.InstancedMesh {
|
export function createBlocks(
|
||||||
|
world: World,
|
||||||
|
reservations: readonly BuildingReservation[] = [],
|
||||||
|
): THREE.InstancedMesh {
|
||||||
const boxes: Box[] = [];
|
const boxes: Box[] = [];
|
||||||
let seedBase = 1337;
|
let seedBase = 1337;
|
||||||
|
|
||||||
@@ -165,15 +182,40 @@ export function createBlocks(world: World): THREE.InstancedMesh {
|
|||||||
// towers assemble their sites, and Salesforce Tower is about 5:1.
|
// towers assemble their sites, and Salesforce Tower is about 5:1.
|
||||||
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
|
const fill = isTower ? 1.5 + rand() * 0.7 : 0.78 + rand() * 0.18;
|
||||||
|
|
||||||
|
const width = LOT * fill;
|
||||||
|
const depth = LOT * fill * (0.85 + rand() * 0.3);
|
||||||
|
const rotation = angle + (rand() - 0.5) * 0.03;
|
||||||
|
const color = new THREE.Color(
|
||||||
|
palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep a named building legible instead of drawing it inside a random
|
||||||
|
* one at the same address.
|
||||||
|
*
|
||||||
|
* Every random property is drawn before this test. The sequence is
|
||||||
|
* load-bearing: skipping those calls for one reserved lot would
|
||||||
|
* reshuffle every anonymous building after it and turn a local change
|
||||||
|
* into a whole new skyline.
|
||||||
|
*/
|
||||||
|
const radius = Math.hypot(width, depth) / 2;
|
||||||
|
if (
|
||||||
|
reservations.some(
|
||||||
|
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < radius + reserved.radius,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
boxes.push({
|
boxes.push({
|
||||||
x,
|
x,
|
||||||
z,
|
z,
|
||||||
y: world.groundAt(lat, lng),
|
y: world.groundAt(lat, lng),
|
||||||
w: LOT * fill,
|
w: width,
|
||||||
d: LOT * fill * (0.85 + rand() * 0.3),
|
d: depth,
|
||||||
h: world.metres(heightM),
|
h: world.metres(heightM),
|
||||||
rot: angle + (rand() - 0.5) * 0.03,
|
rot: rotation,
|
||||||
color: new THREE.Color(palette[Math.floor(rand() * palette.length)] ?? 0xd9d3c6),
|
color,
|
||||||
// A tower is an office whatever district it landed in.
|
// A tower is an office whatever district it landed in.
|
||||||
commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
|
commercial: Math.min(1, commercial + (isTower ? 0.4 : 0)),
|
||||||
});
|
});
|
||||||
@@ -230,12 +272,25 @@ export function createBlocks(world: World): THREE.InstancedMesh {
|
|||||||
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
|
* specific silhouettes — a pyramid at Montgomery, a white finger on Telegraph
|
||||||
* Hill, the red tripod on the ridge — and a box would not do.
|
* Hill, the red tripod on the ridge — and a box would not do.
|
||||||
*/
|
*/
|
||||||
export function createLandmarks(world: World): THREE.Group {
|
export function createLandmarks(
|
||||||
|
world: World,
|
||||||
|
reservations: readonly BuildingReservation[] = [],
|
||||||
|
): THREE.Group {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = "landmarks";
|
group.name = "landmarks";
|
||||||
|
|
||||||
for (const lm of world.city.landmarks) {
|
for (const lm of world.city.landmarks) {
|
||||||
const [x, z] = world.project(lm.lat, lm.lng);
|
const [x, z] = world.project(lm.lat, lm.lng);
|
||||||
|
// A richer stable glyph at this address supersedes the coarse landmark
|
||||||
|
// primitive. Drawing both would hide the glyph inside the old mesh and
|
||||||
|
// leave two different sources claiming the same real building.
|
||||||
|
if (
|
||||||
|
reservations.some(
|
||||||
|
(reserved) => Math.hypot(x - reserved.x, z - reserved.z) < reserved.radius,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const base = world.groundAt(lm.lat, lm.lng);
|
const base = world.groundAt(lm.lat, lm.lng);
|
||||||
const h = world.metres(lm.height);
|
const h = world.metres(lm.height);
|
||||||
const w = lm.footprint * world.lngScale * 2;
|
const w = lm.footprint * world.lngScale * 2;
|
||||||
|
|||||||
@@ -0,0 +1,406 @@
|
|||||||
|
/**
|
||||||
|
* Map-scale buildings for destinations that deserve more than a pin.
|
||||||
|
*
|
||||||
|
* This is not a general building generator and it intentionally loads no kit.
|
||||||
|
* At Tera's camera distances a one-metre façade module is sub-pixel; reproducing
|
||||||
|
* it as geometry would turn three destinations into thousands of triangles and
|
||||||
|
* several draw calls per material. What survives at map scale is the grammar:
|
||||||
|
* silhouette, repeated window bays, a distinct ground-floor door and a roof
|
||||||
|
* line. Those are generated here from a small data shape and one cached unit
|
||||||
|
* box, with all windows in one InstancedMesh.
|
||||||
|
*
|
||||||
|
* The placement idea was researched against achrefelouafi's MIT-licensed
|
||||||
|
* BasicProceduralBuilding, which classifies façade cells into ground, window,
|
||||||
|
* corner and roof pieces. No source or binary asset from that project is copied
|
||||||
|
* here: its GLB part kit and Blender-coordinate port solve a different-scale
|
||||||
|
* problem. See ASSET_RESEARCH.md.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { BuildingGlyph } from "./types.ts";
|
||||||
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
|
export interface BuildingGlyphHandle {
|
||||||
|
group: THREE.Group;
|
||||||
|
/** Solid shell pieces only; windows do not need thousands of ray targets. */
|
||||||
|
pickables: THREE.Object3D[];
|
||||||
|
/** World-space height above the group's ground datum. */
|
||||||
|
anchorY: number;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildingSegment {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
width: number;
|
||||||
|
depth: number;
|
||||||
|
base: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WindowPlacement {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
z: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
yaw: number;
|
||||||
|
lit: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BODY = 0x8d9aa4;
|
||||||
|
const WINDOW_DAY = new THREE.Color(0x334c5d);
|
||||||
|
const WINDOW_LIT = new THREE.Color(0xd7c68d);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The silhouette in metres, kept pure so the contract can be unit-tested with
|
||||||
|
* no WebGL context. Segments never overlap on the same vertical plane except
|
||||||
|
* where a tower deliberately steps inward above its podium.
|
||||||
|
*/
|
||||||
|
export function layoutBuildingGlyph(glyph: BuildingGlyph): BuildingSegment[] {
|
||||||
|
const width = Math.max(4, glyph.width);
|
||||||
|
const depth = Math.max(4, glyph.depth);
|
||||||
|
const height = Math.max(3, glyph.height);
|
||||||
|
|
||||||
|
switch (glyph.profile) {
|
||||||
|
case "tower": {
|
||||||
|
const podium = height * 0.13;
|
||||||
|
const shaft = height * 0.72;
|
||||||
|
return [
|
||||||
|
{ x: 0, z: 0, width, depth, base: 0, height: podium },
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
z: 0,
|
||||||
|
width: width * 0.74,
|
||||||
|
depth: depth * 0.78,
|
||||||
|
base: podium,
|
||||||
|
height: shaft,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
z: 0,
|
||||||
|
width: width * 0.56,
|
||||||
|
depth: depth * 0.6,
|
||||||
|
base: podium + shaft,
|
||||||
|
height: height - podium - shaft,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "courtyard": {
|
||||||
|
const wing = Math.max(3.5, Math.min(width, depth) * 0.28);
|
||||||
|
return [
|
||||||
|
{ x: 0, z: -(depth - wing) / 2, width, depth: wing, base: 0, height },
|
||||||
|
{ x: 0, z: (depth - wing) / 2, width, depth: wing, base: 0, height },
|
||||||
|
{
|
||||||
|
x: -(width - wing) / 2,
|
||||||
|
z: 0,
|
||||||
|
width: wing,
|
||||||
|
depth: Math.max(wing, depth - wing * 2),
|
||||||
|
base: 0,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: (width - wing) / 2,
|
||||||
|
z: 0,
|
||||||
|
width: wing,
|
||||||
|
depth: Math.max(wing, depth - wing * 2),
|
||||||
|
base: 0,
|
||||||
|
height,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "hangar":
|
||||||
|
return [{ x: 0, z: 0, width, depth, base: 0, height: height * 0.72 }];
|
||||||
|
case "block":
|
||||||
|
default:
|
||||||
|
return [{ x: 0, z: 0, width, depth, base: 0, height }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBuildingGlyph(
|
||||||
|
world: World,
|
||||||
|
glyph: BuildingGlyph,
|
||||||
|
accentColor: number,
|
||||||
|
): BuildingGlyphHandle {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = `building-glyph:${glyph.profile}`;
|
||||||
|
// A positive three.js yaw turns local north west. Compass headings increase
|
||||||
|
// eastward, so the sign is reversed once at the world/building boundary.
|
||||||
|
const heading = Number.isFinite(glyph.heading) ? glyph.heading : 0;
|
||||||
|
group.rotation.y = -(heading * Math.PI) / 180;
|
||||||
|
|
||||||
|
const pickables: THREE.Object3D[] = [];
|
||||||
|
const segments = layoutBuildingGlyph(glyph);
|
||||||
|
const horizontal = 1 / world.metresPerUnit;
|
||||||
|
const vertical = (m: number) => world.metres(m);
|
||||||
|
const bodyColor = new THREE.Color(glyph.bodyColor ?? DEFAULT_BODY);
|
||||||
|
const body = new THREE.MeshLambertMaterial({ color: bodyColor });
|
||||||
|
body.name = "building shell";
|
||||||
|
const trim = new THREE.MeshLambertMaterial({ color: bodyColor.clone().multiplyScalar(0.72) });
|
||||||
|
trim.name = "building roof and trim";
|
||||||
|
const glass = new THREE.MeshLambertMaterial({
|
||||||
|
// Instanced colours are multiplied by the base material colour, so white
|
||||||
|
// is the neutral carrier for the cool and warm pane colours below.
|
||||||
|
color: 0xffffff,
|
||||||
|
emissive: new THREE.Color(accentColor).multiplyScalar(0.34),
|
||||||
|
emissiveIntensity: 0.36,
|
||||||
|
});
|
||||||
|
glass.name = "building windows";
|
||||||
|
const accent = new THREE.MeshLambertMaterial({
|
||||||
|
color: accentColor,
|
||||||
|
emissive: accentColor,
|
||||||
|
emissiveIntensity: 0.42,
|
||||||
|
});
|
||||||
|
accent.name = "building door";
|
||||||
|
|
||||||
|
const unit = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
unit.translate(0, 0.5, 0);
|
||||||
|
const windows: WindowPlacement[] = [];
|
||||||
|
const random = mulberry32(glyph.seed ?? hashGlyph(glyph));
|
||||||
|
|
||||||
|
for (const segment of segments) {
|
||||||
|
const shell = new THREE.Mesh(unit, body);
|
||||||
|
shell.name = "building shell segment";
|
||||||
|
shell.position.set(segment.x * horizontal, vertical(segment.base), segment.z * horizontal);
|
||||||
|
shell.scale.set(segment.width * horizontal, vertical(segment.height), segment.depth * horizontal);
|
||||||
|
shell.castShadow = true;
|
||||||
|
shell.receiveShadow = true;
|
||||||
|
group.add(shell);
|
||||||
|
pickables.push(shell);
|
||||||
|
|
||||||
|
// A slightly proud cap makes each setback legible from above, where the
|
||||||
|
// camera spends nearly all its time. It is one centimetre in the data and a
|
||||||
|
// deliberately larger fraction of a pixel after vertical exaggeration.
|
||||||
|
const cap = new THREE.Mesh(unit, trim);
|
||||||
|
cap.name = "building roof line";
|
||||||
|
cap.position.set(
|
||||||
|
segment.x * horizontal,
|
||||||
|
vertical(segment.base + segment.height),
|
||||||
|
segment.z * horizontal,
|
||||||
|
);
|
||||||
|
cap.scale.set(
|
||||||
|
(segment.width + 0.7) * horizontal,
|
||||||
|
Math.max(0.012, vertical(0.28)),
|
||||||
|
(segment.depth + 0.7) * horizontal,
|
||||||
|
);
|
||||||
|
cap.castShadow = true;
|
||||||
|
group.add(cap);
|
||||||
|
pickables.push(cap);
|
||||||
|
|
||||||
|
collectWindows(windows, segment, glyph, horizontal, vertical, random);
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowGeometry = new THREE.BoxGeometry(1, 1, 1);
|
||||||
|
const windowMesh = new THREE.InstancedMesh(windowGeometry, glass, windows.length);
|
||||||
|
windowMesh.name = "building window bays";
|
||||||
|
windowMesh.castShadow = false;
|
||||||
|
windowMesh.receiveShadow = false;
|
||||||
|
const matrix = new THREE.Matrix4();
|
||||||
|
const quaternion = new THREE.Quaternion();
|
||||||
|
const position = new THREE.Vector3();
|
||||||
|
const scale = new THREE.Vector3();
|
||||||
|
const up = new THREE.Vector3(0, 1, 0);
|
||||||
|
windows.forEach((window, i) => {
|
||||||
|
position.set(window.x, window.y, window.z);
|
||||||
|
quaternion.setFromAxisAngle(up, window.yaw);
|
||||||
|
scale.set(window.width, window.height, 0.012);
|
||||||
|
matrix.compose(position, quaternion, scale);
|
||||||
|
windowMesh.setMatrixAt(i, matrix);
|
||||||
|
// A few warm panes stop the repeated grid reading as graph paper. The
|
||||||
|
// choice is seeded, so the same office has the same lights after a reload.
|
||||||
|
windowMesh.setColorAt(i, window.lit ? WINDOW_LIT : WINDOW_DAY);
|
||||||
|
});
|
||||||
|
windowMesh.instanceMatrix.needsUpdate = true;
|
||||||
|
windowMesh.instanceColor!.needsUpdate = true;
|
||||||
|
windowMesh.computeBoundingSphere();
|
||||||
|
group.add(windowMesh);
|
||||||
|
|
||||||
|
let gable: THREE.BufferGeometry | null = null;
|
||||||
|
if (glyph.profile === "hangar") {
|
||||||
|
const bodyHeight = glyph.height * 0.72;
|
||||||
|
const rise = glyph.height - bodyHeight;
|
||||||
|
// The triangular prism completes the upper 28% of the silhouette; its two
|
||||||
|
// sloped faces are what turn a low grey block into a hangar at map scale.
|
||||||
|
gable = gableGeometry(
|
||||||
|
Math.max(4, glyph.width) * horizontal,
|
||||||
|
Math.max(4, glyph.depth) * horizontal,
|
||||||
|
vertical(rise),
|
||||||
|
);
|
||||||
|
const roof = new THREE.Mesh(gable, trim);
|
||||||
|
roof.name = "hangar roof";
|
||||||
|
roof.position.y = vertical(bodyHeight);
|
||||||
|
roof.castShadow = true;
|
||||||
|
roof.receiveShadow = true;
|
||||||
|
group.add(roof);
|
||||||
|
pickables.push(roof);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One bright address on the south/front façade. It is navigation, not a
|
||||||
|
// physically accurate entrance schedule, and stays readable after the
|
||||||
|
// repeated bays have merged into their average at distance.
|
||||||
|
const first = segments[0] as BuildingSegment;
|
||||||
|
const door = new THREE.Mesh(unit, accent);
|
||||||
|
door.name = "building entrance";
|
||||||
|
const doorWidth = glyph.profile === "hangar" ? first.width * 0.42 : Math.min(5, first.width * 0.22);
|
||||||
|
const doorHeight = glyph.profile === "hangar" ? first.height * 0.6 : Math.min(5, first.height * 0.55);
|
||||||
|
door.position.set(
|
||||||
|
first.x * horizontal,
|
||||||
|
vertical(first.base),
|
||||||
|
(first.z + first.depth / 2 + 0.18) * horizontal,
|
||||||
|
);
|
||||||
|
door.scale.set(doorWidth * horizontal, vertical(doorHeight), 0.028);
|
||||||
|
door.castShadow = false;
|
||||||
|
group.add(door);
|
||||||
|
pickables.push(door);
|
||||||
|
|
||||||
|
const anchorY = vertical(glyph.height) + Math.max(0.18, vertical(2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
pickables,
|
||||||
|
anchorY,
|
||||||
|
dispose() {
|
||||||
|
windowMesh.dispose();
|
||||||
|
unit.dispose();
|
||||||
|
windowGeometry.dispose();
|
||||||
|
gable?.dispose();
|
||||||
|
body.dispose();
|
||||||
|
trim.dispose();
|
||||||
|
glass.dispose();
|
||||||
|
accent.dispose();
|
||||||
|
pickables.length = 0;
|
||||||
|
group.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectWindows(
|
||||||
|
out: WindowPlacement[],
|
||||||
|
segment: BuildingSegment,
|
||||||
|
glyph: BuildingGlyph,
|
||||||
|
horizontal: number,
|
||||||
|
vertical: (m: number) => number,
|
||||||
|
random: () => number,
|
||||||
|
) {
|
||||||
|
const floorShare = segment.height / Math.max(1, glyph.height);
|
||||||
|
const rows =
|
||||||
|
glyph.profile === "hangar"
|
||||||
|
? 1
|
||||||
|
: Math.max(1, Math.min(18, Math.round(Math.max(1, glyph.storeys) * floorShare)));
|
||||||
|
const rowPitch = segment.height / (rows + 0.35);
|
||||||
|
const windowHeightM = Math.max(1.1, rowPitch * (glyph.profile === "tower" ? 0.5 : 0.58));
|
||||||
|
const y0 = segment.base + rowPitch * 0.6;
|
||||||
|
|
||||||
|
const addFacade = (
|
||||||
|
spanM: number,
|
||||||
|
face: "north" | "south" | "east" | "west",
|
||||||
|
) => {
|
||||||
|
const cols = Math.max(2, Math.min(10, Math.round(spanM / 6.5)));
|
||||||
|
const bay = spanM / cols;
|
||||||
|
const width = bay * (0.5 + random() * 0.1) * horizontal;
|
||||||
|
for (let row = 0; row < rows; row += 1) {
|
||||||
|
for (let col = 0; col < cols; col += 1) {
|
||||||
|
const along = -spanM / 2 + bay * (col + 0.5);
|
||||||
|
const y = vertical(y0 + rowPitch * row + windowHeightM / 2);
|
||||||
|
const lit = random() > 0.77;
|
||||||
|
switch (face) {
|
||||||
|
case "north":
|
||||||
|
out.push({
|
||||||
|
x: (segment.x + along) * horizontal,
|
||||||
|
y,
|
||||||
|
z: (segment.z - segment.depth / 2 - 0.08) * horizontal,
|
||||||
|
width,
|
||||||
|
height: vertical(windowHeightM),
|
||||||
|
yaw: 0,
|
||||||
|
lit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "south":
|
||||||
|
out.push({
|
||||||
|
x: (segment.x - along) * horizontal,
|
||||||
|
y,
|
||||||
|
z: (segment.z + segment.depth / 2 + 0.08) * horizontal,
|
||||||
|
width,
|
||||||
|
height: vertical(windowHeightM),
|
||||||
|
yaw: Math.PI,
|
||||||
|
lit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "east":
|
||||||
|
out.push({
|
||||||
|
x: (segment.x + segment.width / 2 + 0.08) * horizontal,
|
||||||
|
y,
|
||||||
|
z: (segment.z + along) * horizontal,
|
||||||
|
width,
|
||||||
|
height: vertical(windowHeightM),
|
||||||
|
yaw: Math.PI / 2,
|
||||||
|
lit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case "west":
|
||||||
|
out.push({
|
||||||
|
x: (segment.x - segment.width / 2 - 0.08) * horizontal,
|
||||||
|
y,
|
||||||
|
z: (segment.z - along) * horizontal,
|
||||||
|
width,
|
||||||
|
height: vertical(windowHeightM),
|
||||||
|
yaw: -Math.PI / 2,
|
||||||
|
lit,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
addFacade(segment.width, "north");
|
||||||
|
addFacade(segment.width, "south");
|
||||||
|
addFacade(segment.depth, "east");
|
||||||
|
addFacade(segment.depth, "west");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A triangular prism whose ridge runs along local X. */
|
||||||
|
function gableGeometry(width: number, depth: number, rise: number): THREE.BufferGeometry {
|
||||||
|
const x = width / 2;
|
||||||
|
const z = depth / 2;
|
||||||
|
const positions = new Float32Array([
|
||||||
|
-x, 0, -z,
|
||||||
|
-x, 0, z,
|
||||||
|
-x, rise, 0,
|
||||||
|
x, 0, -z,
|
||||||
|
x, 0, z,
|
||||||
|
x, rise, 0,
|
||||||
|
]);
|
||||||
|
const indices = [
|
||||||
|
0, 1, 2,
|
||||||
|
3, 5, 4,
|
||||||
|
0, 3, 4, 0, 4, 1,
|
||||||
|
1, 4, 5, 1, 5, 2,
|
||||||
|
2, 5, 3, 2, 3, 0,
|
||||||
|
];
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
geometry.computeVertexNormals();
|
||||||
|
return geometry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashGlyph(glyph: BuildingGlyph): number {
|
||||||
|
const text = `${glyph.profile}:${glyph.width}:${glyph.depth}:${glyph.height}:${glyph.heading}`;
|
||||||
|
let hash = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < text.length; i += 1) {
|
||||||
|
hash ^= text.charCodeAt(i);
|
||||||
|
hash = Math.imul(hash, 0x01000193);
|
||||||
|
}
|
||||||
|
return hash >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mulberry32(seed: number): () => number {
|
||||||
|
let value = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
value = (value + 0x6d2b79f5) >>> 0;
|
||||||
|
let next = Math.imul(value ^ (value >>> 15), 1 | value);
|
||||||
|
next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next;
|
||||||
|
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
|
import { createBuildingGlyph, type BuildingGlyphHandle } from "./buildingGlyph.ts";
|
||||||
import type { Marker, MarkerPalette } from "./types.ts";
|
import type { Marker, MarkerPalette } from "./types.ts";
|
||||||
import type { World } from "./world.ts";
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
|
|||||||
group.name = "markers";
|
group.name = "markers";
|
||||||
const pickables: THREE.Object3D[] = [];
|
const pickables: THREE.Object3D[] = [];
|
||||||
const anchors = new Map<string, THREE.Vector3>();
|
const anchors = new Map<string, THREE.Vector3>();
|
||||||
|
const buildings: BuildingGlyphHandle[] = [];
|
||||||
|
|
||||||
// One shared geometry per shape; colour varies per instance material, which
|
// One shared geometry per shape; colour varies per instance material, which
|
||||||
// is cheap enough at the scale markers live at (hundreds, not tens of
|
// is cheap enough at the scale markers live at (hundreds, not tens of
|
||||||
@@ -61,6 +63,8 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
|
|||||||
};
|
};
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
|
for (const building of buildings) building.dispose();
|
||||||
|
buildings.length = 0;
|
||||||
for (const child of [...group.children]) group.remove(child);
|
for (const child of [...group.children]) group.remove(child);
|
||||||
pickables.length = 0;
|
pickables.length = 0;
|
||||||
anchors.clear();
|
anchors.clear();
|
||||||
@@ -73,6 +77,23 @@ export function createMarkerLayer(world: World, palette: MarkerPalette): MarkerL
|
|||||||
const [x, z] = world.project(m.lat, m.lng);
|
const [x, z] = world.project(m.lat, m.lng);
|
||||||
const base = world.groundAt(m.lat, m.lng);
|
const base = world.groundAt(m.lat, m.lng);
|
||||||
|
|
||||||
|
if (located && m.glyph?.kind === "building") {
|
||||||
|
const building = createBuildingGlyph(
|
||||||
|
world,
|
||||||
|
m.glyph,
|
||||||
|
palette[m.colorKey] ?? FALLBACK_COLOR,
|
||||||
|
);
|
||||||
|
building.group.position.set(x, base, z);
|
||||||
|
for (const target of building.pickables) {
|
||||||
|
target.userData.marker = m;
|
||||||
|
pickables.push(target);
|
||||||
|
}
|
||||||
|
anchors.set(m.id, new THREE.Vector3(x, base + building.anchorY, z));
|
||||||
|
buildings.push(building);
|
||||||
|
group.add(building.group);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const pin = new THREE.Group();
|
const pin = new THREE.Group();
|
||||||
pin.position.set(x, base + PIN_LIFT, z);
|
pin.position.set(x, base + PIN_LIFT, z);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* The road-traffic rendering layer.
|
||||||
|
*
|
||||||
|
* Simulation remains geographic and three-free in `transport/vehicleSim.ts`.
|
||||||
|
* This layer projects those poses onto one `World`, draws one articulated hero
|
||||||
|
* car, and batches every background car into instanced asset parts. Route
|
||||||
|
* switching and follow-camera state are imperative because they are viewer
|
||||||
|
* choices, not properties of the open transport pack.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import {
|
||||||
|
buildModelX,
|
||||||
|
disposeModelX,
|
||||||
|
modelXInstanceParts,
|
||||||
|
setModelXSteering,
|
||||||
|
setModelXWheelRotation,
|
||||||
|
} from "../assets/vehicles/index.ts";
|
||||||
|
import type { TransportPack } from "../transport/types.ts";
|
||||||
|
import {
|
||||||
|
NEUTRAL_VEHICLE_ACTIONS,
|
||||||
|
VehicleController,
|
||||||
|
normalizeVehicleActions,
|
||||||
|
type VehicleActionSnapshot,
|
||||||
|
type VehicleControllerState,
|
||||||
|
} from "../transport/vehicleController.ts";
|
||||||
|
import { VehicleSimulation, type VehiclePose } from "../transport/vehicleSim.ts";
|
||||||
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
|
export interface RoadTrafficOptions {
|
||||||
|
pack: TransportPack;
|
||||||
|
routeId: string;
|
||||||
|
count?: number;
|
||||||
|
seed?: number;
|
||||||
|
/** Vehicle metres to scene units. State-scale cars are intentional glyphs. */
|
||||||
|
scale?: number;
|
||||||
|
/** Route-distance compression for playable corridor travel. Defaults to 900. */
|
||||||
|
travelScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoadTrafficLayer {
|
||||||
|
group: THREE.Group;
|
||||||
|
routeId(): string;
|
||||||
|
setRoute(routeId: string): void;
|
||||||
|
setFollowing(following: boolean): void;
|
||||||
|
following(): boolean;
|
||||||
|
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
|
||||||
|
setCameraMode(mode: VehicleCameraMode): void;
|
||||||
|
cameraMode(): VehicleCameraMode;
|
||||||
|
hero(): Readonly<VehicleControllerState>;
|
||||||
|
tick(dt: number): void;
|
||||||
|
dispose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VehicleCameraMode = "chase" | "driver";
|
||||||
|
|
||||||
|
interface BatchPart {
|
||||||
|
mesh: THREE.InstancedMesh;
|
||||||
|
local: THREE.Matrix4;
|
||||||
|
}
|
||||||
|
|
||||||
|
type RenderPose = Pick<VehiclePose, "lat" | "lng" | "headingDeg" | "wheelRadians"> & {
|
||||||
|
lane?: number;
|
||||||
|
lateralOffsetM?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createRoadTrafficLayer(
|
||||||
|
world: World,
|
||||||
|
camera: THREE.PerspectiveCamera,
|
||||||
|
controls: OrbitControls,
|
||||||
|
options: RoadTrafficOptions,
|
||||||
|
): RoadTrafficLayer {
|
||||||
|
const group = new THREE.Group();
|
||||||
|
group.name = "road-traffic";
|
||||||
|
const heroScale = options.scale ?? 0.18;
|
||||||
|
// Background traffic is deliberately quieter. One oversized convoy reads as
|
||||||
|
// map symbols; one detailed hero against smaller traffic reads as a camera.
|
||||||
|
const backgroundScale = heroScale * 0.62;
|
||||||
|
const count = Math.max(2, Math.min(40, Math.floor(options.count ?? 14)));
|
||||||
|
const simulation = new VehicleSimulation(options.pack, {
|
||||||
|
routeId: options.routeId,
|
||||||
|
count,
|
||||||
|
seed: options.seed,
|
||||||
|
});
|
||||||
|
const controller = new VehicleController(options.pack, {
|
||||||
|
routeId: options.routeId,
|
||||||
|
mode: "assisted",
|
||||||
|
initialSpeedMps: 24,
|
||||||
|
travelScale: options.travelScale ?? 900,
|
||||||
|
});
|
||||||
|
|
||||||
|
// One articulated, higher-detail car for the follow camera.
|
||||||
|
const heroRig = buildModelX({ detail: "follow" });
|
||||||
|
heroRig.root.name = "model-x-hero";
|
||||||
|
group.add(heroRig.root);
|
||||||
|
|
||||||
|
// Background traffic is one draw call per asset part, not per car. The
|
||||||
|
// neutral prototype itself is never attached to the scene.
|
||||||
|
const backgroundPrototype = buildModelX({ detail: "corridor" });
|
||||||
|
const backgroundCount = count - 1;
|
||||||
|
const batches: BatchPart[] = modelXInstanceParts(backgroundPrototype).map((part) => {
|
||||||
|
const material = Array.isArray(part.material) ? part.material[0] : part.material;
|
||||||
|
if (!material) throw new Error(`road traffic: asset part "${part.name}" has no material`);
|
||||||
|
const mesh = new THREE.InstancedMesh(part.geometry, material, backgroundCount);
|
||||||
|
mesh.name = `traffic:${part.name}`;
|
||||||
|
mesh.castShadow = part.castShadow;
|
||||||
|
mesh.receiveShadow = part.receiveShadow;
|
||||||
|
mesh.frustumCulled = false;
|
||||||
|
group.add(mesh);
|
||||||
|
return { mesh, local: part.matrix };
|
||||||
|
});
|
||||||
|
|
||||||
|
const rootMatrix = new THREE.Matrix4();
|
||||||
|
const instanceMatrix = new THREE.Matrix4();
|
||||||
|
const position = new THREE.Vector3();
|
||||||
|
const quaternion = new THREE.Quaternion();
|
||||||
|
const scaleVector = new THREE.Vector3(backgroundScale, backgroundScale, backgroundScale);
|
||||||
|
const yawEuler = new THREE.Euler(0, 0, 0, "YXZ");
|
||||||
|
const followPosition = new THREE.Vector3();
|
||||||
|
const followTarget = new THREE.Vector3();
|
||||||
|
const followOffset = new THREE.Vector3();
|
||||||
|
let isFollowing = false;
|
||||||
|
let activeCameraMode: VehicleCameraMode = "chase";
|
||||||
|
let vehicleActions: VehicleActionSnapshot = { ...NEUTRAL_VEHICLE_ACTIONS };
|
||||||
|
|
||||||
|
function scenePose(pose: RenderPose, out: THREE.Vector3): THREE.Vector3 {
|
||||||
|
const [x, z] = world.project(pose.lat, pose.lng);
|
||||||
|
const heading = (pose.headingDeg * Math.PI) / 180;
|
||||||
|
// Lane 0 hugs the median. Southbound traffic's right side naturally moves
|
||||||
|
// to the other carriageway because its heading is reversed.
|
||||||
|
const laneOffset =
|
||||||
|
0.2 + (pose.lane ?? 0) * 0.2 + (pose.lateralOffsetM ?? 0) * 0.018;
|
||||||
|
out.set(
|
||||||
|
x + Math.cos(heading) * laneOffset,
|
||||||
|
// The asset origin is on the tyre contact plane. Keep only a tiny lift
|
||||||
|
// above the generated road to avoid z-fighting; `0.22` here made the
|
||||||
|
// vehicle hover more than its own rendered height at corridor scale.
|
||||||
|
world.groundAt(pose.lat, pose.lng) + 0.155,
|
||||||
|
z + Math.sin(heading) * laneOffset,
|
||||||
|
);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoot(pose: RenderPose, steering = 0): void {
|
||||||
|
scenePose(pose, position);
|
||||||
|
heroRig.root.position.copy(position);
|
||||||
|
heroRig.root.rotation.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
|
||||||
|
heroRig.root.scale.setScalar(heroScale);
|
||||||
|
setModelXWheelRotation(heroRig, -pose.wheelRadians);
|
||||||
|
setModelXSteering(heroRig, steering);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBatches(poses: readonly VehiclePose[]): void {
|
||||||
|
for (let index = 1; index < poses.length; index += 1) {
|
||||||
|
const pose = poses[index];
|
||||||
|
if (!pose) continue;
|
||||||
|
scenePose(pose, position);
|
||||||
|
yawEuler.set(0, (-pose.headingDeg * Math.PI) / 180, 0);
|
||||||
|
quaternion.setFromEuler(yawEuler);
|
||||||
|
rootMatrix.compose(position, quaternion, scaleVector);
|
||||||
|
for (const batch of batches) {
|
||||||
|
instanceMatrix.multiplyMatrices(rootMatrix, batch.local);
|
||||||
|
batch.mesh.setMatrixAt(index - 1, instanceMatrix);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const batch of batches) batch.mesh.instanceMatrix.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFollow(pose: RenderPose, dt: number): void {
|
||||||
|
const heading = (pose.headingDeg * Math.PI) / 180;
|
||||||
|
const forwardX = Math.sin(heading);
|
||||||
|
const forwardZ = -Math.cos(heading);
|
||||||
|
if (activeCameraMode === "driver") {
|
||||||
|
// A hood/driver-height view. The procedural corridor asset has no cabin
|
||||||
|
// texture to hide, so the camera sits just above its glass and looks far
|
||||||
|
// enough ahead that route curvature reads before the car reaches it.
|
||||||
|
followTarget
|
||||||
|
.copy(heroRig.root.position)
|
||||||
|
.add(followOffset.set(forwardX * 2.2, 0.14, forwardZ * 2.2));
|
||||||
|
followPosition
|
||||||
|
.copy(heroRig.root.position)
|
||||||
|
.add(followOffset.set(forwardX * 0.03, 0.32, forwardZ * 0.03));
|
||||||
|
} else {
|
||||||
|
const rightX = Math.cos(heading);
|
||||||
|
const rightZ = Math.sin(heading);
|
||||||
|
followTarget
|
||||||
|
.copy(heroRig.root.position)
|
||||||
|
.add(followOffset.set(forwardX * 0.32, 0.1, forwardZ * 0.32));
|
||||||
|
followPosition
|
||||||
|
.copy(heroRig.root.position)
|
||||||
|
.add(
|
||||||
|
followOffset.set(
|
||||||
|
-forwardX * 0.9 - rightX * 0.3,
|
||||||
|
0.4,
|
||||||
|
-forwardZ * 0.9 - rightZ * 0.3,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const blend = 1 - Math.exp(-Math.max(0, dt) * 4.5);
|
||||||
|
camera.position.lerp(followPosition, blend);
|
||||||
|
controls.target.lerp(followTarget, blend);
|
||||||
|
camera.lookAt(controls.target);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh(dt: number): void {
|
||||||
|
const poses = simulation.poses();
|
||||||
|
const hero = controller.state();
|
||||||
|
applyRoot(hero, hero.steering * 0.55);
|
||||||
|
applyBatches(poses);
|
||||||
|
if (isFollowing) applyFollow(hero, dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
group,
|
||||||
|
routeId: () => simulation.routeId(),
|
||||||
|
setRoute(routeId) {
|
||||||
|
simulation.setRoute(routeId);
|
||||||
|
controller.setRoute(routeId);
|
||||||
|
refresh(0);
|
||||||
|
},
|
||||||
|
setFollowing(following) {
|
||||||
|
isFollowing = following;
|
||||||
|
controls.enabled = !following;
|
||||||
|
if (following) refresh(1);
|
||||||
|
},
|
||||||
|
following: () => isFollowing,
|
||||||
|
setVehicleActions(actions) {
|
||||||
|
vehicleActions = normalizeVehicleActions(actions);
|
||||||
|
},
|
||||||
|
setCameraMode(mode) {
|
||||||
|
activeCameraMode = mode;
|
||||||
|
if (isFollowing) refresh(1);
|
||||||
|
},
|
||||||
|
cameraMode: () => activeCameraMode,
|
||||||
|
hero: () => controller.state(),
|
||||||
|
tick(dt) {
|
||||||
|
simulation.tick(dt);
|
||||||
|
controller.tick(dt, vehicleActions);
|
||||||
|
// Mode/reset requests are edges. Analogue axes remain held until the
|
||||||
|
// input adapter publishes a changed snapshot.
|
||||||
|
vehicleActions.modeRequest = "none";
|
||||||
|
vehicleActions.reset = false;
|
||||||
|
refresh(dt);
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
controls.enabled = true;
|
||||||
|
for (const batch of batches) group.remove(batch.mesh);
|
||||||
|
group.remove(heroRig.root);
|
||||||
|
disposeModelX(backgroundPrototype);
|
||||||
|
disposeModelX(heroRig);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+61
-4
@@ -28,7 +28,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { createBlocks, createLandmarks } from "./blocks.ts";
|
import { createBlocks, createLandmarks, type BuildingReservation } from "./blocks.ts";
|
||||||
import { createNightLights, type NightLights } from "./nightlights.ts";
|
import { createNightLights, type NightLights } from "./nightlights.ts";
|
||||||
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
import { createFlightLayer, type FlightLayer } from "./flights.ts";
|
||||||
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
import { createCloudLayer, type CloudLayer } from "./clouds.ts";
|
||||||
@@ -41,7 +41,17 @@ import {
|
|||||||
type SatelliteLayer,
|
type SatelliteLayer,
|
||||||
} from "./satellites.ts";
|
} from "./satellites.ts";
|
||||||
import { createSceneKit, type Pose } from "./scenekit.ts";
|
import { createSceneKit, type Pose } from "./scenekit.ts";
|
||||||
|
import {
|
||||||
|
createRoadTrafficLayer,
|
||||||
|
type RoadTrafficLayer,
|
||||||
|
type RoadTrafficOptions,
|
||||||
|
type VehicleCameraMode,
|
||||||
|
} from "./roadTraffic.ts";
|
||||||
import type { Stage, StageScene } from "./stage.ts";
|
import type { Stage, StageScene } from "./stage.ts";
|
||||||
|
import type {
|
||||||
|
VehicleActionSnapshot,
|
||||||
|
VehicleControllerState,
|
||||||
|
} from "../transport/vehicleController.ts";
|
||||||
import { createBridges, createRoads } from "./structures.ts";
|
import { createBridges, createRoads } from "./structures.ts";
|
||||||
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
import { createShorePlates, createTerrain, createWater, paletteFor } from "./terrain.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -58,6 +68,17 @@ import { World, type FieldProgress } from "./world.ts";
|
|||||||
export interface SceneOptions {
|
export interface SceneOptions {
|
||||||
city: City;
|
city: City;
|
||||||
markerPalette?: MarkerPalette;
|
markerPalette?: MarkerPalette;
|
||||||
|
/**
|
||||||
|
* Markers available at construction time.
|
||||||
|
*
|
||||||
|
* Building glyphs in this first set reserve their footprints in the
|
||||||
|
* anonymous block scatter. Later `setMarkers()` calls remain cheap and do
|
||||||
|
* not rebuild a city, so callers should put stable destinations here and use
|
||||||
|
* updates for genuinely live marker feeds.
|
||||||
|
*/
|
||||||
|
markers?: Marker[];
|
||||||
|
/** Optional deterministic road traffic for a state/corridor-scale board. */
|
||||||
|
roadTraffic?: RoadTrafficOptions;
|
||||||
flights?: FlightSource;
|
flights?: FlightSource;
|
||||||
/**
|
/**
|
||||||
* Element sets to propagate, if this deployment has any.
|
* Element sets to propagate, if this deployment has any.
|
||||||
@@ -148,6 +169,12 @@ export interface SceneHandle {
|
|||||||
flyTo(chapterId: string): void;
|
flyTo(chapterId: string): void;
|
||||||
current(): string;
|
current(): string;
|
||||||
onChapterChange(fn: (id: string) => void): void;
|
onChapterChange(fn: (id: string) => void): void;
|
||||||
|
/** Device-neutral input for the corridor hero; a no-op on boards without one. */
|
||||||
|
setVehicleActions(actions: Partial<VehicleActionSnapshot>): void;
|
||||||
|
/** Current playable corridor state, or null on a city-scale board. */
|
||||||
|
vehicleState(): Readonly<VehicleControllerState> | null;
|
||||||
|
setVehicleCamera(mode: VehicleCameraMode): void;
|
||||||
|
vehicleCamera(): VehicleCameraMode | null;
|
||||||
setMarkers(markers: Marker[]): void;
|
setMarkers(markers: Marker[]): void;
|
||||||
/** Take this city off the stage and release everything it built. */
|
/** Take this city off the stage and release everything it built. */
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
@@ -283,9 +310,19 @@ export async function createScene(
|
|||||||
scene.add(createShorePlates(world));
|
scene.add(createShorePlates(world));
|
||||||
scene.add(createTerrain(world));
|
scene.add(createTerrain(world));
|
||||||
scene.add(createRoads(world));
|
scene.add(createRoads(world));
|
||||||
const blocks = createBlocks(world);
|
const buildingReservations: BuildingReservation[] = [];
|
||||||
|
for (const marker of options.markers ?? []) {
|
||||||
|
const glyph = marker.glyph;
|
||||||
|
if (marker.located === false || glyph?.kind !== "building") continue;
|
||||||
|
const [x, z] = world.project(marker.lat, marker.lng);
|
||||||
|
// Five metres of breathing room keeps an anonymous wall from sitting
|
||||||
|
// exactly on the authored façade after both reservation circles touch.
|
||||||
|
const radius = (Math.hypot(glyph.width, glyph.depth) / 2 + 5) / world.metresPerUnit;
|
||||||
|
buildingReservations.push({ x, z, radius });
|
||||||
|
}
|
||||||
|
const blocks = createBlocks(world, buildingReservations);
|
||||||
scene.add(blocks);
|
scene.add(blocks);
|
||||||
scene.add(createLandmarks(world));
|
scene.add(createLandmarks(world, buildingReservations));
|
||||||
scene.add(createBridges(world));
|
scene.add(createBridges(world));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -300,8 +337,14 @@ export async function createScene(
|
|||||||
scene.add(clouds.group);
|
scene.add(clouds.group);
|
||||||
|
|
||||||
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
const markerLayer: MarkerLayer = createMarkerLayer(world, options.markerPalette ?? {});
|
||||||
|
markerLayer.setMarkers(options.markers ?? []);
|
||||||
scene.add(markerLayer.group);
|
scene.add(markerLayer.group);
|
||||||
|
|
||||||
|
const roadTraffic: RoadTrafficLayer | null = options.roadTraffic
|
||||||
|
? createRoadTrafficLayer(world, kit.camera, kit.controls, options.roadTraffic)
|
||||||
|
: null;
|
||||||
|
if (roadTraffic) scene.add(roadTraffic.group);
|
||||||
|
|
||||||
let flightLayer: FlightLayer | null = null;
|
let flightLayer: FlightLayer | null = null;
|
||||||
let flightTimer = 0;
|
let flightTimer = 0;
|
||||||
if (options.flights) {
|
if (options.flights) {
|
||||||
@@ -365,7 +408,15 @@ export async function createScene(
|
|||||||
function flyTo(chapterId: string) {
|
function flyTo(chapterId: string) {
|
||||||
const ch = chapterById[chapterId];
|
const ch = chapterById[chapterId];
|
||||||
if (!ch) return;
|
if (!ch) return;
|
||||||
kit.flyTo(chapterPose(ch));
|
const route = options.roadTraffic?.pack.routes.find((candidate) => candidate.id === chapterId);
|
||||||
|
if (route && roadTraffic) {
|
||||||
|
roadTraffic.setRoute(route.id);
|
||||||
|
roadTraffic.setFollowing(true);
|
||||||
|
} else {
|
||||||
|
roadTraffic?.setFollowing(false);
|
||||||
|
roadTraffic?.setVehicleActions({});
|
||||||
|
kit.flyTo(chapterPose(ch));
|
||||||
|
}
|
||||||
if (currentChapter !== chapterId) {
|
if (currentChapter !== chapterId) {
|
||||||
currentChapter = chapterId;
|
currentChapter = chapterId;
|
||||||
for (const fn of chapterListeners) fn(chapterId);
|
for (const fn of chapterListeners) fn(chapterId);
|
||||||
@@ -396,6 +447,7 @@ export async function createScene(
|
|||||||
onExit: () => kit.resetPick(),
|
onExit: () => kit.resetPick(),
|
||||||
tick(dt) {
|
tick(dt) {
|
||||||
kit.tick(dt);
|
kit.tick(dt);
|
||||||
|
roadTraffic?.tick(dt);
|
||||||
clouds.tick(dt);
|
clouds.tick(dt);
|
||||||
if (options.flights && flightLayer) {
|
if (options.flights && flightLayer) {
|
||||||
flightTimer -= dt;
|
flightTimer -= dt;
|
||||||
@@ -444,6 +496,7 @@ export async function createScene(
|
|||||||
clouds.dispose();
|
clouds.dispose();
|
||||||
nightLights.dispose();
|
nightLights.dispose();
|
||||||
markerLayer.dispose();
|
markerLayer.dispose();
|
||||||
|
roadTraffic?.dispose();
|
||||||
kit.dispose();
|
kit.dispose();
|
||||||
scene.traverse((obj) => {
|
scene.traverse((obj) => {
|
||||||
const mesh = obj as THREE.Mesh;
|
const mesh = obj as THREE.Mesh;
|
||||||
@@ -481,6 +534,10 @@ export async function createScene(
|
|||||||
onChapterChange(fn) {
|
onChapterChange(fn) {
|
||||||
chapterListeners.push(fn);
|
chapterListeners.push(fn);
|
||||||
},
|
},
|
||||||
|
setVehicleActions: (actions) => roadTraffic?.setVehicleActions(actions),
|
||||||
|
vehicleState: () => roadTraffic?.hero() ?? null,
|
||||||
|
setVehicleCamera: (mode) => roadTraffic?.setCameraMode(mode),
|
||||||
|
vehicleCamera: () => roadTraffic?.cameraMode() ?? null,
|
||||||
setMarkers(markers) {
|
setMarkers(markers) {
|
||||||
markerLayer.setMarkers(markers);
|
markerLayer.setMarkers(markers);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { Bridge, LatLng } from "./types.ts";
|
|||||||
import type { World } from "./world.ts";
|
import type { World } from "./world.ts";
|
||||||
|
|
||||||
/** Resample a lat/lng path into scene-space points that ride the ground. */
|
/** Resample a lat/lng path into scene-space points that ride the ground. */
|
||||||
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.05): THREE.Vector3[] {
|
function drapePath(world: World, path: LatLng[], samplesPerLeg = 14, lift = 0.14): THREE.Vector3[] {
|
||||||
const out: THREE.Vector3[] = [];
|
const out: THREE.Vector3[] = [];
|
||||||
for (let i = 0; i < path.length - 1; i++) {
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
const from = path[i];
|
const from = path[i];
|
||||||
@@ -40,12 +40,64 @@ function ribbon(points: THREE.Vector3[], width: number, color: number): THREE.Me
|
|||||||
return mesh;
|
return mesh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A draped, flat road deck. A tube turns a freeway into a raised pipeline. */
|
||||||
|
function roadRibbon(
|
||||||
|
points: readonly THREE.Vector3[],
|
||||||
|
width: number,
|
||||||
|
color: number,
|
||||||
|
lift = 0,
|
||||||
|
): THREE.Mesh {
|
||||||
|
const positions: number[] = [];
|
||||||
|
const normals: number[] = [];
|
||||||
|
const indices: number[] = [];
|
||||||
|
const half = width / 2;
|
||||||
|
|
||||||
|
for (let index = 0; index < points.length; index += 1) {
|
||||||
|
const point = points[index];
|
||||||
|
const previous = points[Math.max(0, index - 1)];
|
||||||
|
const next = points[Math.min(points.length - 1, index + 1)];
|
||||||
|
if (!point || !previous || !next) continue;
|
||||||
|
const dx = next.x - previous.x;
|
||||||
|
const dz = next.z - previous.z;
|
||||||
|
const length = Math.hypot(dx, dz) || 1;
|
||||||
|
const nx = -dz / length;
|
||||||
|
const nz = dx / length;
|
||||||
|
positions.push(
|
||||||
|
point.x + nx * half, point.y + lift, point.z + nz * half,
|
||||||
|
point.x - nx * half, point.y + lift, point.z - nz * half,
|
||||||
|
);
|
||||||
|
normals.push(0, 1, 0, 0, 1, 0);
|
||||||
|
if (index < points.length - 1) {
|
||||||
|
const a = index * 2;
|
||||||
|
indices.push(a, a + 2, a + 1, a + 1, a + 2, a + 3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry();
|
||||||
|
geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3));
|
||||||
|
geometry.setIndex(indices);
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
const mesh = new THREE.Mesh(
|
||||||
|
geometry,
|
||||||
|
new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide }),
|
||||||
|
);
|
||||||
|
mesh.receiveShadow = true;
|
||||||
|
return mesh;
|
||||||
|
}
|
||||||
|
|
||||||
export function createRoads(world: World): THREE.Group {
|
export function createRoads(world: World): THREE.Group {
|
||||||
const group = new THREE.Group();
|
const group = new THREE.Group();
|
||||||
group.name = "roads";
|
group.name = "roads";
|
||||||
for (const road of world.city.roads) {
|
for (const road of world.city.roads) {
|
||||||
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
|
const color = road.kind === "freeway" ? 0x7d7166 : 0x8b8578;
|
||||||
group.add(ribbon(drapePath(world, road.path), road.width, color));
|
const path = drapePath(world, road.path);
|
||||||
|
group.add(roadRibbon(path, road.width, color));
|
||||||
|
if (road.kind === "freeway") {
|
||||||
|
// One warm median stroke is enough at corridor scale to read as divided
|
||||||
|
// highway without spending a textured asset or a draw call per lane.
|
||||||
|
group.add(roadRibbon(path, Math.max(0.025, road.width * 0.035), 0xd7c27c, 0.012));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,6 +255,37 @@ export interface Pin {
|
|||||||
blurb?: string;
|
blurb?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A small, map-scale building drawn in place of a pin.
|
||||||
|
*
|
||||||
|
* This is deliberately a glyph rather than an architectural model. A city is
|
||||||
|
* normally viewed from kilometres away, so loading a façade kit with one mesh
|
||||||
|
* per window buys triangles nobody can see and gives up the one-draw-call city
|
||||||
|
* that `blocks.ts` works hard to preserve. The glyph keeps the useful grammar
|
||||||
|
* — a ground floor, repeated bays, a roof line and a deterministic silhouette
|
||||||
|
* — and expresses it in a handful of procedural meshes.
|
||||||
|
*
|
||||||
|
* Metres are used here because these values describe a real building even
|
||||||
|
* though the city scene does not: `World` converts horizontal metres with
|
||||||
|
* `metresPerUnit` and vertical metres with the city's exaggeration.
|
||||||
|
*/
|
||||||
|
export interface BuildingGlyph {
|
||||||
|
kind: "building";
|
||||||
|
width: number;
|
||||||
|
depth: number;
|
||||||
|
height: number;
|
||||||
|
/** Approximate occupied floors; used to choose the façade rhythm. */
|
||||||
|
storeys: number;
|
||||||
|
/** Compass bearing of local −Z, degrees clockwise from true north. */
|
||||||
|
heading: number;
|
||||||
|
/** The silhouette family, not a tenant or product category. */
|
||||||
|
profile: "tower" | "hangar" | "courtyard" | "block";
|
||||||
|
/** Stable variation for bay widths and lit panes. */
|
||||||
|
seed?: number;
|
||||||
|
/** Neutral shell colour. The marker palette still supplies the door/accent. */
|
||||||
|
bodyColor?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/** A `Pin` placed on a city, in degrees. */
|
/** A `Pin` placed on a city, in degrees. */
|
||||||
export interface Marker extends Pin {
|
export interface Marker extends Pin {
|
||||||
lat: number;
|
lat: number;
|
||||||
@@ -265,6 +296,8 @@ export interface Marker extends Pin {
|
|||||||
* is that it is real is worse than admitting the gap.
|
* is that it is real is worse than admitting the gap.
|
||||||
*/
|
*/
|
||||||
located?: boolean;
|
located?: boolean;
|
||||||
|
/** Optional map-scale representation. Omit it for the ordinary pin. */
|
||||||
|
glyph?: BuildingGlyph;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Caller-supplied `colorKey` -> colour. */
|
/** Caller-supplied `colorKey` -> colour. */
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/** Device adapters for the renderer-independent vehicle action contract. */
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeVehicleActions,
|
||||||
|
type VehicleActionSnapshot,
|
||||||
|
} from "../transport/vehicleController.ts";
|
||||||
|
|
||||||
|
export interface GamepadButtonLike {
|
||||||
|
pressed: boolean;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamepadLike {
|
||||||
|
axes: readonly number[];
|
||||||
|
buttons: readonly GamepadButtonLike[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamepadButtonState {
|
||||||
|
assist: boolean;
|
||||||
|
reset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GamepadVehicleSample {
|
||||||
|
actions: VehicleActionSnapshot;
|
||||||
|
buttons: GamepadButtonState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function axis(value: number | undefined, deadzone = 0.12): number {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
const clamped = Math.max(-1, Math.min(1, value ?? 0));
|
||||||
|
if (Math.abs(clamped) <= deadzone) return 0;
|
||||||
|
return Math.sign(clamped) * ((Math.abs(clamped) - deadzone) / (1 - deadzone));
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(pad: GamepadLike, index: number): number {
|
||||||
|
const found = pad.buttons[index];
|
||||||
|
if (!found) return 0;
|
||||||
|
return Math.max(0, Math.min(1, Number.isFinite(found.value) ? found.value : found.pressed ? 1 : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard-layout mapping: left stick steers, triggers brake/throttle, B is
|
||||||
|
* handbrake, Y resumes assistance, and X resets. Mode/reset are rising edges.
|
||||||
|
*/
|
||||||
|
export function sampleStandardGamepad(
|
||||||
|
pad: GamepadLike,
|
||||||
|
previous: GamepadButtonState = { assist: false, reset: false },
|
||||||
|
): GamepadVehicleSample {
|
||||||
|
const buttons = {
|
||||||
|
assist: button(pad, 3) > 0.5,
|
||||||
|
reset: button(pad, 2) > 0.5,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
actions: normalizeVehicleActions({
|
||||||
|
steering: axis(pad.axes[0]),
|
||||||
|
brake: button(pad, 6),
|
||||||
|
throttle: button(pad, 7),
|
||||||
|
handbrake: button(pad, 1) > 0.5,
|
||||||
|
modeRequest: buttons.assist && !previous.assist ? "assisted" : "none",
|
||||||
|
reset: buttons.reset && !previous.reset,
|
||||||
|
}),
|
||||||
|
buttons,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyboard/touch and gamepad may be used together; strongest intent wins. */
|
||||||
|
export function mergeVehicleActions(
|
||||||
|
primary: Partial<VehicleActionSnapshot>,
|
||||||
|
secondary: Partial<VehicleActionSnapshot>,
|
||||||
|
): VehicleActionSnapshot {
|
||||||
|
const a = normalizeVehicleActions(primary);
|
||||||
|
const b = normalizeVehicleActions(secondary);
|
||||||
|
return normalizeVehicleActions({
|
||||||
|
throttle: Math.max(a.throttle, b.throttle),
|
||||||
|
brake: Math.max(a.brake, b.brake),
|
||||||
|
steering: Math.abs(b.steering) > Math.abs(a.steering) ? b.steering : a.steering,
|
||||||
|
handbrake: a.handbrake || b.handbrake,
|
||||||
|
modeRequest: b.modeRequest !== "none" ? b.modeRequest : a.modeRequest,
|
||||||
|
reset: a.reset || b.reset,
|
||||||
|
});
|
||||||
|
}
|
||||||
+10
-1
@@ -29,7 +29,7 @@
|
|||||||
* +Z down the page.
|
* +Z down the page.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Pin, View } from "../engine/types.ts";
|
import type { BuildingGlyph, Pin, View } from "../engine/types.ts";
|
||||||
|
|
||||||
// ---- Geometry -------------------------------------------------------------
|
// ---- Geometry -------------------------------------------------------------
|
||||||
|
|
||||||
@@ -248,6 +248,15 @@ export interface OfficeSite {
|
|||||||
* has no name worth printing.
|
* has no name worth printing.
|
||||||
*/
|
*/
|
||||||
label?: string;
|
label?: string;
|
||||||
|
/**
|
||||||
|
* The public silhouette the city can draw before this office pack is loaded.
|
||||||
|
*
|
||||||
|
* Optional for the same reason `site` is optional: an office does not need a
|
||||||
|
* world address to render, and a sited office does not need to claim that its
|
||||||
|
* exterior is known. When present, `offices/sites.ts` hands this plain data to
|
||||||
|
* the generic marker layer; the city still never imports an office pack.
|
||||||
|
*/
|
||||||
|
exterior?: BuildingGlyph;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/**
|
||||||
|
* Renderer-independent walking over a resolved office plan.
|
||||||
|
*
|
||||||
|
* Input is a direction on the office floor plane, not keys or stick events.
|
||||||
|
* The controller advances on a fixed clock, sweeps the walker's circular
|
||||||
|
* footprint against the exact collision segments produced by `Plan`, and
|
||||||
|
* projects blocked motion along a wall so diagonal input slides instead of
|
||||||
|
* stopping. Doors need no special case: the wall resolver has already left a
|
||||||
|
* gap in `LevelPlan.collision` for every passable opening.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Bounds, LevelPlan, Segment } from "./plan.ts";
|
||||||
|
import type { Point2 } from "./types.ts";
|
||||||
|
|
||||||
|
const EPSILON = 1e-8;
|
||||||
|
const BISECTION_STEPS = 24;
|
||||||
|
const SLIDE_PASSES = 3;
|
||||||
|
|
||||||
|
export const DEFAULT_WALKER_RADIUS = 0.3;
|
||||||
|
export const DEFAULT_WALKER_SPEED = 1.6;
|
||||||
|
export const DEFAULT_FIXED_STEP = 1 / 60;
|
||||||
|
export const DEFAULT_MAX_CATCH_UP_STEPS = 8;
|
||||||
|
|
||||||
|
/** A world-space direction on the office floor plane. */
|
||||||
|
export interface WalkerAction {
|
||||||
|
x: number;
|
||||||
|
z: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkerSpawn {
|
||||||
|
levelId: string;
|
||||||
|
position: Point2;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkerOptions extends WalkerSpawn {
|
||||||
|
/** Circular footprint radius, in metres. */
|
||||||
|
radius?: number;
|
||||||
|
/** Metres per second at full input. */
|
||||||
|
speed?: number;
|
||||||
|
/** Simulation seconds per movement step. */
|
||||||
|
fixedStep?: number;
|
||||||
|
/** Prevents a resumed/backgrounded tab from running an unbounded backlog. */
|
||||||
|
maxCatchUpSteps?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkerState {
|
||||||
|
levelId: string;
|
||||||
|
position: Point2;
|
||||||
|
/** Last non-zero normalized action; useful as a renderer-facing heading. */
|
||||||
|
facing: Point2;
|
||||||
|
/** Total successfully travelled distance, in metres, since the last reset. */
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The small part of `Plan` movement depends on. A test or server can implement it too. */
|
||||||
|
export interface WalkerPlan {
|
||||||
|
level(id: string): Pick<LevelPlan, "bounds" | "collision"> | null;
|
||||||
|
blocked(levelId: string, from: Point2, to: Point2, radius?: number): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalkerController {
|
||||||
|
/** A defensive snapshot: callers cannot corrupt the simulation's finite state. */
|
||||||
|
state(): WalkerState;
|
||||||
|
/** Add real time; zero or more fixed simulation steps may run. */
|
||||||
|
tick(elapsedSeconds: number, action: WalkerAction): WalkerState;
|
||||||
|
/** Return to the original spawn, or atomically adopt another valid spawn. */
|
||||||
|
reset(spawn?: WalkerSpawn): WalkerState;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clamp arbitrary planar input to the unit disc. Non-finite input means idle;
|
||||||
|
* letting one bad gamepad sample become NaN would otherwise poison every frame.
|
||||||
|
*/
|
||||||
|
export function normalizeWalkerAction(action: WalkerAction): WalkerAction {
|
||||||
|
if (!finitePoint(action)) return { x: 0, z: 0 };
|
||||||
|
const length = Math.hypot(action.x, action.z);
|
||||||
|
if (length <= 1) return { x: action.x, z: action.z };
|
||||||
|
return { x: action.x / length, z: action.z / length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createWalker(plan: WalkerPlan, options: WalkerOptions): WalkerController {
|
||||||
|
const radius = positive(options.radius ?? DEFAULT_WALKER_RADIUS, "radius");
|
||||||
|
const speed = positive(options.speed ?? DEFAULT_WALKER_SPEED, "speed");
|
||||||
|
const fixedStep = positive(options.fixedStep ?? DEFAULT_FIXED_STEP, "fixedStep");
|
||||||
|
const maxCatchUpSteps = integer(options.maxCatchUpSteps ?? DEFAULT_MAX_CATCH_UP_STEPS);
|
||||||
|
|
||||||
|
let spawn = checkedSpawn(plan, options, radius);
|
||||||
|
let position = copy(spawn.position);
|
||||||
|
let facing: Point2 = { x: 0, z: -1 };
|
||||||
|
let distance = 0;
|
||||||
|
let accumulator = 0;
|
||||||
|
|
||||||
|
function snapshot(): WalkerState {
|
||||||
|
return {
|
||||||
|
levelId: spawn.levelId,
|
||||||
|
position: copy(position),
|
||||||
|
facing: copy(facing),
|
||||||
|
distance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset(next = spawn): WalkerState {
|
||||||
|
spawn = checkedSpawn(plan, next, radius);
|
||||||
|
position = copy(spawn.position);
|
||||||
|
facing = { x: 0, z: -1 };
|
||||||
|
distance = 0;
|
||||||
|
accumulator = 0;
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick(elapsedSeconds: number, rawAction: WalkerAction): WalkerState {
|
||||||
|
// The internals are private, but this also makes the recovery policy clear
|
||||||
|
// if a future refactor exposes a mutable transport/state object.
|
||||||
|
if (!finitePoint(position) || !validPosition(plan, spawn.levelId, position, radius)) reset();
|
||||||
|
if (!(elapsedSeconds > 0) || !Number.isFinite(elapsedSeconds)) return snapshot();
|
||||||
|
|
||||||
|
const action = normalizeWalkerAction(rawAction);
|
||||||
|
if (Math.hypot(action.x, action.z) > EPSILON) facing = copy(action);
|
||||||
|
|
||||||
|
const maxBacklog = fixedStep * maxCatchUpSteps;
|
||||||
|
accumulator = Math.min(maxBacklog, accumulator + elapsedSeconds);
|
||||||
|
let steps = 0;
|
||||||
|
while (accumulator + EPSILON >= fixedStep && steps < maxCatchUpSteps) {
|
||||||
|
accumulator -= fixedStep;
|
||||||
|
if (accumulator < 0) accumulator = 0;
|
||||||
|
steps += 1;
|
||||||
|
const amount = speed * fixedStep;
|
||||||
|
const before = position;
|
||||||
|
position = moveWithSliding(plan, spawn.levelId, position, {
|
||||||
|
x: action.x * amount,
|
||||||
|
z: action.z * amount,
|
||||||
|
}, radius);
|
||||||
|
distance += Math.hypot(position.x - before.x, position.z - before.z);
|
||||||
|
}
|
||||||
|
return snapshot();
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: snapshot, tick, reset };
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveWithSliding(
|
||||||
|
plan: WalkerPlan,
|
||||||
|
levelId: string,
|
||||||
|
start: Point2,
|
||||||
|
displacement: Point2,
|
||||||
|
radius: number,
|
||||||
|
): Point2 {
|
||||||
|
const level = plan.level(levelId);
|
||||||
|
if (!level) return copy(start);
|
||||||
|
|
||||||
|
// A configured high speed still cannot tunnel: no sweep is longer than half
|
||||||
|
// a radius. `Plan.blocked` is swept too; the subdivision primarily makes a
|
||||||
|
// corner followed by a slide behave consistently.
|
||||||
|
const length = Math.hypot(displacement.x, displacement.z);
|
||||||
|
const slices = Math.max(1, Math.ceil(length / Math.max(radius * 0.5, 0.01)));
|
||||||
|
const slice = { x: displacement.x / slices, z: displacement.z / slices };
|
||||||
|
let at = copy(start);
|
||||||
|
for (let index = 0; index < slices; index += 1) {
|
||||||
|
at = moveSlice(plan, levelId, level.bounds, level.collision, at, slice, radius);
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSlice(
|
||||||
|
plan: WalkerPlan,
|
||||||
|
levelId: string,
|
||||||
|
bounds: Bounds,
|
||||||
|
segments: readonly Segment[],
|
||||||
|
start: Point2,
|
||||||
|
initial: Point2,
|
||||||
|
radius: number,
|
||||||
|
): Point2 {
|
||||||
|
let at = copy(start);
|
||||||
|
let remaining = copy(initial);
|
||||||
|
|
||||||
|
for (let pass = 0; pass < SLIDE_PASSES; pass += 1) {
|
||||||
|
if (Math.hypot(remaining.x, remaining.z) <= EPSILON) break;
|
||||||
|
const target = bounded(add(at, remaining), bounds, radius);
|
||||||
|
const attempted = { x: target.x - at.x, z: target.z - at.z };
|
||||||
|
if (Math.hypot(attempted.x, attempted.z) <= EPSILON) break;
|
||||||
|
if (!plan.blocked(levelId, at, target, radius)) {
|
||||||
|
at = target;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fraction = clearFraction(plan, levelId, at, attempted, radius);
|
||||||
|
if (fraction > 0) at = add(at, scale(attempted, fraction));
|
||||||
|
const left = scale(attempted, 1 - fraction);
|
||||||
|
const wall = nearestBlockingSegment(at, add(at, left), segments, radius);
|
||||||
|
if (!wall) break;
|
||||||
|
|
||||||
|
const wx = wall.to.x - wall.from.x;
|
||||||
|
const wz = wall.to.z - wall.from.z;
|
||||||
|
const wallLength = Math.hypot(wx, wz);
|
||||||
|
if (wallLength <= EPSILON) break;
|
||||||
|
const tx = wx / wallLength;
|
||||||
|
const tz = wz / wallLength;
|
||||||
|
const along = left.x * tx + left.z * tz;
|
||||||
|
remaining = { x: tx * along, z: tz * along };
|
||||||
|
}
|
||||||
|
return at;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Largest prefix of a blocked displacement whose whole swept capsule is clear. */
|
||||||
|
function clearFraction(
|
||||||
|
plan: WalkerPlan,
|
||||||
|
levelId: string,
|
||||||
|
start: Point2,
|
||||||
|
displacement: Point2,
|
||||||
|
radius: number,
|
||||||
|
): number {
|
||||||
|
let low = 0;
|
||||||
|
let high = 1;
|
||||||
|
for (let index = 0; index < BISECTION_STEPS; index += 1) {
|
||||||
|
const middle = (low + high) / 2;
|
||||||
|
if (plan.blocked(levelId, start, add(start, scale(displacement, middle)), radius)) high = middle;
|
||||||
|
else low = middle;
|
||||||
|
}
|
||||||
|
// Stay microscopically on the clear side so the projected slide does not
|
||||||
|
// begin inside the wall because of a last-bit rounding difference.
|
||||||
|
return Math.max(0, low - 1e-7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nearestBlockingSegment(
|
||||||
|
from: Point2,
|
||||||
|
to: Point2,
|
||||||
|
segments: readonly Segment[],
|
||||||
|
radius: number,
|
||||||
|
): Segment | null {
|
||||||
|
let nearest: Segment | null = null;
|
||||||
|
let best = Infinity;
|
||||||
|
for (const segment of segments) {
|
||||||
|
const distance = segmentDistance(from, to, segment.from, segment.to);
|
||||||
|
const clearance = radius + segment.thickness / 2;
|
||||||
|
if (distance >= clearance + 1e-6 || distance >= best) continue;
|
||||||
|
best = distance;
|
||||||
|
nearest = segment;
|
||||||
|
}
|
||||||
|
return nearest;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkedSpawn(plan: WalkerPlan, spawn: WalkerSpawn, radius: number): WalkerSpawn {
|
||||||
|
if (!spawn.levelId || !finitePoint(spawn.position)) {
|
||||||
|
throw new RangeError("walker spawn must name a level and contain finite coordinates");
|
||||||
|
}
|
||||||
|
if (!validPosition(plan, spawn.levelId, spawn.position, radius)) {
|
||||||
|
throw new RangeError("walker spawn must be inside the level bounds and clear of walls");
|
||||||
|
}
|
||||||
|
return { levelId: spawn.levelId, position: copy(spawn.position) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function validPosition(plan: WalkerPlan, levelId: string, point: Point2, radius: number): boolean {
|
||||||
|
const level = plan.level(levelId);
|
||||||
|
return level !== null && inside(point, level.bounds, radius) && !plan.blocked(levelId, point, point, radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inside(point: Point2, bounds: Bounds, radius: number): boolean {
|
||||||
|
return (
|
||||||
|
point.x >= bounds.minX + radius && point.x <= bounds.maxX - radius &&
|
||||||
|
point.z >= bounds.minZ + radius && point.z <= bounds.maxZ - radius
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bounded(point: Point2, bounds: Bounds, radius: number): Point2 {
|
||||||
|
return {
|
||||||
|
x: Math.min(bounds.maxX - radius, Math.max(bounds.minX + radius, point.x)),
|
||||||
|
z: Math.min(bounds.maxZ - radius, Math.max(bounds.minZ + radius, point.z)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function positive(value: number, name: string): number {
|
||||||
|
if (!(value > 0) || !Number.isFinite(value)) throw new RangeError(`${name} must be finite and positive`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function integer(value: number): number {
|
||||||
|
if (!Number.isInteger(value) || value < 1) throw new RangeError("maxCatchUpSteps must be a positive integer");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finitePoint(point: Point2): boolean {
|
||||||
|
return Number.isFinite(point.x) && Number.isFinite(point.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copy(point: Point2): Point2 {
|
||||||
|
return { x: point.x, z: point.z };
|
||||||
|
}
|
||||||
|
|
||||||
|
function add(a: Point2, b: Point2): Point2 {
|
||||||
|
return { x: a.x + b.x, z: a.z + b.z };
|
||||||
|
}
|
||||||
|
|
||||||
|
function scale(point: Point2, amount: number): Point2 {
|
||||||
|
return { x: point.x * amount, z: point.z * amount };
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentDistance(a1: Point2, a2: Point2, b1: Point2, b2: Point2): number {
|
||||||
|
if (segmentsCross(a1, a2, b1, b2)) return 0;
|
||||||
|
return Math.min(
|
||||||
|
pointSegmentDistance(a1, b1, b2),
|
||||||
|
pointSegmentDistance(a2, b1, b2),
|
||||||
|
pointSegmentDistance(b1, a1, a2),
|
||||||
|
pointSegmentDistance(b2, a1, a2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentsCross(a1: Point2, a2: Point2, b1: Point2, b2: Point2): boolean {
|
||||||
|
const ab1 = cross(a1, a2, b1);
|
||||||
|
const ab2 = cross(a1, a2, b2);
|
||||||
|
const ba1 = cross(b1, b2, a1);
|
||||||
|
const ba2 = cross(b1, b2, a2);
|
||||||
|
// Proper crossing only. Collinear, disjoint segments must fall through to
|
||||||
|
// endpoint distance; treating every collinear pair as a crossing would make
|
||||||
|
// a walker sliding parallel to a distant wall collide with it.
|
||||||
|
return ab1 * ab2 < 0 && ba1 * ba2 < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cross(a: Point2, b: Point2, point: Point2): number {
|
||||||
|
return (b.x - a.x) * (point.z - a.z) - (b.z - a.z) * (point.x - a.x);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointSegmentDistance(point: Point2, a: Point2, b: Point2): number {
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dz = b.z - a.z;
|
||||||
|
const lengthSquared = dx * dx + dz * dz;
|
||||||
|
if (lengthSquared <= EPSILON) return Math.hypot(point.x - a.x, point.z - a.z);
|
||||||
|
const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.z - a.z) * dz) / lengthSquared));
|
||||||
|
return Math.hypot(point.x - (a.x + t * dx), point.z - (a.z + t * dz));
|
||||||
|
}
|
||||||
+176
-35
@@ -32,8 +32,16 @@ import type { Pose } from "./engine/scenekit.ts";
|
|||||||
import { createStage, deviceProfile } from "./engine/stage.ts";
|
import { createStage, deviceProfile } from "./engine/stage.ts";
|
||||||
import { daylightPhase } from "./engine/solar.ts";
|
import { daylightPhase } from "./engine/solar.ts";
|
||||||
import type { City, Marker, MarkerPalette, View } from "./engine/types.ts";
|
import type { City, Marker, MarkerPalette, View } from "./engine/types.ts";
|
||||||
|
import CALIFORNIA from "./cities/california.ts";
|
||||||
import SAN_FRANCISCO from "./cities/sf.ts";
|
import SAN_FRANCISCO from "./cities/sf.ts";
|
||||||
import SOCAL from "./cities/socal.ts";
|
import SOCAL from "./cities/socal.ts";
|
||||||
|
import CALIFORNIA_TRANSPORT from "./transport/california.ts";
|
||||||
|
import {
|
||||||
|
mergeVehicleActions,
|
||||||
|
sampleStandardGamepad,
|
||||||
|
type GamepadButtonState,
|
||||||
|
} from "./input/vehicle.ts";
|
||||||
|
import type { VehicleActionSnapshot } from "./transport/vehicleController.ts";
|
||||||
import {
|
import {
|
||||||
createTeraClient,
|
createTeraClient,
|
||||||
describeLiveness,
|
describeLiveness,
|
||||||
@@ -78,6 +86,7 @@ import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts
|
|||||||
import type { PoseEditor } from "./tools/poseEditor.ts";
|
import type { PoseEditor } from "./tools/poseEditor.ts";
|
||||||
|
|
||||||
const CITIES: { id: string; label: string; city: City }[] = [
|
const CITIES: { id: string; label: string; city: City }[] = [
|
||||||
|
{ id: "california", label: "California", city: CALIFORNIA },
|
||||||
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
|
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
|
||||||
{ id: "socal", label: "SoCal", city: SOCAL },
|
{ id: "socal", label: "SoCal", city: SOCAL },
|
||||||
];
|
];
|
||||||
@@ -131,7 +140,7 @@ const stage = createStage(canvas);
|
|||||||
const tera = createTeraClient({ fetch: authFetch });
|
const tera = createTeraClient({ fetch: authFetch });
|
||||||
|
|
||||||
let city: SceneHandle | null = null;
|
let city: SceneHandle | null = null;
|
||||||
let cityId = "sf";
|
let cityId = "california";
|
||||||
/**
|
/**
|
||||||
* The city the user last *asked* for, which is not the same as the one that is
|
* The city the user last *asked* for, which is not the same as the one that is
|
||||||
* mounted or even the one that is being built.
|
* mounted or even the one that is being built.
|
||||||
@@ -145,13 +154,13 @@ let cityId = "sf";
|
|||||||
* to be against the intention, and the intention is recorded synchronously in
|
* to be against the intention, and the intention is recorded synchronously in
|
||||||
* the click handler.
|
* the click handler.
|
||||||
*/
|
*/
|
||||||
let wantedCity = "sf";
|
let wantedCity = "california";
|
||||||
let office: OfficeScene | null = null;
|
let office: OfficeScene | null = null;
|
||||||
let inside = false;
|
let inside = false;
|
||||||
let markers: Marker[] = SAMPLE_MARKERS;
|
let markers: Marker[] = SAMPLE_MARKERS;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The buildings you can walk into, as pins on the city.
|
* The buildings you can walk into, as procedural glyphs on the city.
|
||||||
*
|
*
|
||||||
* This is the one thing that makes Tera and Spaces feel like one product rather
|
* This is the one thing that makes Tera and Spaces feel like one product rather
|
||||||
* than two views sharing a bundle. Both packs carry a real `site` — it is what
|
* than two views sharing a bundle. Both packs carry a real `site` — it is what
|
||||||
@@ -163,9 +172,10 @@ let markers: Marker[] = SAMPLE_MARKERS;
|
|||||||
* lazy chunk worth tens of kilobytes and the city wants these the instant the
|
* lazy chunk worth tens of kilobytes and the city wants these the instant the
|
||||||
* board appears, long before anybody opens a door. See `offices/sites.ts`.
|
* board appears, long before anybody opens a door. See `offices/sites.ts`.
|
||||||
*
|
*
|
||||||
* `colorKey` is opaque to the engine, as every `Pin.colorKey` is — `SAMPLE_PALETTE`
|
* `colorKey` is opaque to the engine, as every `Pin.colorKey` is — the palette
|
||||||
* resolves it, and giving these their own key is what lets a door look different
|
* resolves it, and giving these their own key is what lets a door look different
|
||||||
* from a company.
|
* from a company. `glyph` is equally literal: dimensions and a silhouette, with
|
||||||
|
* no office semantics in the renderer.
|
||||||
*/
|
*/
|
||||||
const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({
|
const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({
|
||||||
id: `office:${entry.id}`,
|
id: `office:${entry.id}`,
|
||||||
@@ -177,6 +187,7 @@ const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({
|
|||||||
// Hand-typed from the street grid, like every other coordinate here. Not a
|
// Hand-typed from the street grid, like every other coordinate here. Not a
|
||||||
// placeholder, so it is drawn as a real address.
|
// placeholder, so it is drawn as a real address.
|
||||||
located: true,
|
located: true,
|
||||||
|
...(entry.site.exterior ? { glyph: entry.site.exterior } : {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -677,8 +688,14 @@ async function mountCity(id: string) {
|
|||||||
// unconditionally and all of it is over San Francisco, so the SoCal board's
|
// unconditionally and all of it is over San Francisco, so the SoCal board's
|
||||||
// entire sky projected ~590 km off the world and rendered as nothing at all.
|
// entire sky projected ~590 km off the world and rendered as nothing at all.
|
||||||
const routes = sampleRoutesFor(entry.city);
|
const routes = sampleRoutesFor(entry.city);
|
||||||
|
// The public traffic API is region-oriented and intentionally capped around
|
||||||
|
// one metro. A state-wide request would either be rejected or become a data
|
||||||
|
// vacuum, so California keeps the honest deterministic sky while its two
|
||||||
|
// detailed boards continue to use live ADS-B when available.
|
||||||
const traffic =
|
const traffic =
|
||||||
access.can.liveEnvironment && access.feeds?.flights ? tera.flights(region, routes) : null;
|
id !== "california" && access.can.liveEnvironment && access.feeds?.flights
|
||||||
|
? tera.flights(region, routes)
|
||||||
|
: null;
|
||||||
cityFlights = traffic;
|
cityFlights = traffic;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -717,9 +734,42 @@ async function mountCity(id: string) {
|
|||||||
dial.setExtra(trafficDial?.extra() ?? 0);
|
dial.setExtra(trafficDial?.extra() ?? 0);
|
||||||
trafficDial = dial;
|
trafficDial = dial;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable destinations are handed to the scene at construction time, not a
|
||||||
|
* frame later. A building glyph needs that head start so `blocks.ts` can
|
||||||
|
* reserve its footprint before the anonymous one-draw-call skyline is
|
||||||
|
* emitted; otherwise both buildings occupy the same address and the useful
|
||||||
|
* one is usually hidden inside the random one.
|
||||||
|
*
|
||||||
|
* A door belongs to the board it stands on. This used to be gated on a
|
||||||
|
* hard-coded `id === "sf"`; using the board's own bounds is what keeps Mateo
|
||||||
|
* Court on the Southland board and off the Bay Area one. Sample companies
|
||||||
|
* stay SF-only because that sample feed is about one city and always was.
|
||||||
|
*/
|
||||||
|
const bounds = entry.city.bounds;
|
||||||
|
const doors = OFFICE_MARKERS.filter(
|
||||||
|
(m) =>
|
||||||
|
m.lat >= bounds.minLat &&
|
||||||
|
m.lat <= bounds.maxLat &&
|
||||||
|
m.lng >= bounds.minLng &&
|
||||||
|
m.lng <= bounds.maxLng,
|
||||||
|
);
|
||||||
|
const initialMarkers = id === "sf" ? [...markers, ...doors] : doors;
|
||||||
|
|
||||||
const handle = await createScene(stage, {
|
const handle = await createScene(stage, {
|
||||||
city: entry.city,
|
city: entry.city,
|
||||||
markerPalette: palette,
|
markerPalette: palette,
|
||||||
|
markers: initialMarkers,
|
||||||
|
...(id === "california"
|
||||||
|
? {
|
||||||
|
roadTraffic: {
|
||||||
|
pack: CALIFORNIA_TRANSPORT,
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
count: 14,
|
||||||
|
seed: 115,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
flights: dial.source,
|
flights: dial.source,
|
||||||
...(catalogue ? { satellites: catalogue } : {}),
|
...(catalogue ? { satellites: catalogue } : {}),
|
||||||
/**
|
/**
|
||||||
@@ -818,28 +868,6 @@ async function mountCity(id: string) {
|
|||||||
// not a decoration. LA gets its own weather, not San Francisco's fog.
|
// not a decoration. LA gets its own weather, not San Francisco's fog.
|
||||||
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
|
marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
|
||||||
});
|
});
|
||||||
/**
|
|
||||||
* A door belongs to the board it stands on.
|
|
||||||
*
|
|
||||||
* The gate here used to be `id === "sf"`, which was correct for exactly as
|
|
||||||
* long as every office was in the Bay Area. It stopped being correct the
|
|
||||||
* moment one was not: a hard-coded city id would have kept the Los Angeles
|
|
||||||
* building off the Los Angeles board and pinned it to San Francisco's.
|
|
||||||
*
|
|
||||||
* So the test is the board's own bounds, which is the same question asked
|
|
||||||
* honestly — a pin for a building outside the rectangle being drawn is a pin
|
|
||||||
* in the wrong place, whichever city that happens to be. The sample company
|
|
||||||
* markers stay SF-only; they are sample data about one city and always were.
|
|
||||||
*/
|
|
||||||
const bounds = entry.city.bounds;
|
|
||||||
const doors = OFFICE_MARKERS.filter(
|
|
||||||
(m) =>
|
|
||||||
m.lat >= bounds.minLat &&
|
|
||||||
m.lat <= bounds.maxLat &&
|
|
||||||
m.lng >= bounds.minLng &&
|
|
||||||
m.lng <= bounds.maxLng,
|
|
||||||
);
|
|
||||||
city.setMarkers(id === "sf" ? [...markers, ...doors] : doors);
|
|
||||||
city.onChapterChange(() => renderLegend());
|
city.onChapterChange(() => renderLegend());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -889,7 +917,7 @@ async function mountCity(id: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
showPlan();
|
showPlan();
|
||||||
minimap.setMarkers(id === "sf" ? [...markers, ...doors] : doors);
|
minimap.setMarkers(initialMarkers);
|
||||||
|
|
||||||
// The instruments, for the one visitor in a deployment who has them. The pose
|
// The instruments, for the one visitor in a deployment who has them. The pose
|
||||||
// editor holds a `World`, a camera and a controls, so it belongs to the board
|
// editor holds a `World`, a camera and a controls, so it belongs to the board
|
||||||
@@ -1304,6 +1332,7 @@ const shortcutsCard = document.querySelector<HTMLElement>("#shortcuts");
|
|||||||
const helpButton = document.querySelector<HTMLButtonElement>("#help");
|
const helpButton = document.querySelector<HTMLButtonElement>("#help");
|
||||||
const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
|
const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
|
||||||
const credits = document.querySelector<HTMLElement>("#credits");
|
const credits = document.querySelector<HTMLElement>("#credits");
|
||||||
|
const driveControls = document.querySelector<HTMLElement>("#drive-controls");
|
||||||
|
|
||||||
function showDetail(text: string | null) {
|
function showDetail(text: string | null) {
|
||||||
const card = document.querySelector<HTMLElement>("#detail");
|
const card = document.querySelector<HTMLElement>("#detail");
|
||||||
@@ -1316,7 +1345,7 @@ function showDetail(text: string | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The two-button strip above the legend: cities outside, buildings inside.
|
* The board strip above the legend: world scales outside, buildings inside.
|
||||||
*
|
*
|
||||||
* One control that answers "which of these am I in", pointed at whichever list
|
* One control that answers "which of these am I in", pointed at whichever list
|
||||||
* is currently the answer. A second, separate office strip was the obvious
|
* is currently the answer. A second, separate office strip was the obvious
|
||||||
@@ -1332,8 +1361,8 @@ function renderCityPicker() {
|
|||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const b = document.createElement("button");
|
const b = document.createElement("button");
|
||||||
// `aria-pressed` rather than a class, because that is what these are: two
|
// `aria-pressed` rather than a class, because these buttons choose exactly
|
||||||
// buttons of which exactly one is on. The stylesheet keys off the attribute
|
// one active board. The stylesheet keys off the attribute
|
||||||
// so the visual state and the announced state cannot drift apart.
|
// so the visual state and the announced state cannot drift apart.
|
||||||
b.className = "city";
|
b.className = "city";
|
||||||
b.type = "button";
|
b.type = "button";
|
||||||
@@ -1449,6 +1478,7 @@ function renderLegend() {
|
|||||||
minimap?.setChapters(city.chapters, city.current());
|
minimap?.setChapters(city.chapters, city.current());
|
||||||
officePlan?.setActiveView(office?.current() ?? null);
|
officePlan?.setActiveView(office?.current() ?? null);
|
||||||
renderOfficeBadge();
|
renderOfficeBadge();
|
||||||
|
if (driveControls) driveControls.hidden = !routeDriveIsActive();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1814,8 +1844,6 @@ panelToggle?.addEventListener("click", () => {
|
|||||||
applyPanel();
|
applyPanel();
|
||||||
});
|
});
|
||||||
|
|
||||||
planToggle?.addEventListener("click", () => togglePlan());
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scrim behind the phone's panel sheet. It is `display: none` above 600px,
|
* The scrim behind the phone's panel sheet. It is `display: none` above 600px,
|
||||||
* so this listener is only ever reachable where the sheet exists.
|
* so this listener is only ever reachable where the sheet exists.
|
||||||
@@ -1855,6 +1883,98 @@ shortcutsCard?.addEventListener("click", (event) => {
|
|||||||
if (event.target === shortcutsCard) closeShortcuts();
|
if (event.target === shortcutsCard) closeShortcuts();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Held keyboard state translated into the same snapshot a gamepad/touch UI uses. */
|
||||||
|
const heldDriveKeys = new Set<string>();
|
||||||
|
|
||||||
|
function routeDriveIsActive(): boolean {
|
||||||
|
const state = !inside ? city?.vehicleState() : null;
|
||||||
|
return state !== null && state !== undefined && city?.current() === state.routeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publishVehicleActions(
|
||||||
|
supplement: Partial<VehicleActionSnapshot> = {},
|
||||||
|
): boolean {
|
||||||
|
if (!routeDriveIsActive() || !city) return false;
|
||||||
|
const left = heldDriveKeys.has("a");
|
||||||
|
const right = heldDriveKeys.has("d");
|
||||||
|
city.setVehicleActions(
|
||||||
|
mergeVehicleActions(
|
||||||
|
{
|
||||||
|
throttle: heldDriveKeys.has("w") ? 1 : 0,
|
||||||
|
brake: heldDriveKeys.has("s") ? 1 : 0,
|
||||||
|
steering: (right ? 1 : 0) - (left ? 1 : 0),
|
||||||
|
handbrake: heldDriveKeys.has(" "),
|
||||||
|
},
|
||||||
|
supplement,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVehicleCamera(): boolean {
|
||||||
|
if (!routeDriveIsActive() || !city) return false;
|
||||||
|
city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-drive-key]") ?? []) {
|
||||||
|
const key = button.dataset.driveKey;
|
||||||
|
if (key === undefined) continue;
|
||||||
|
const release = (event: PointerEvent) => {
|
||||||
|
heldDriveKeys.delete(key);
|
||||||
|
button.setAttribute("aria-pressed", "false");
|
||||||
|
publishVehicleActions();
|
||||||
|
event.preventDefault();
|
||||||
|
};
|
||||||
|
button.addEventListener("pointerdown", (event) => {
|
||||||
|
button.setPointerCapture(event.pointerId);
|
||||||
|
heldDriveKeys.add(key);
|
||||||
|
button.setAttribute("aria-pressed", "true");
|
||||||
|
publishVehicleActions();
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
button.addEventListener("pointerup", release);
|
||||||
|
button.addEventListener("pointercancel", release);
|
||||||
|
button.addEventListener("lostpointercapture", release);
|
||||||
|
}
|
||||||
|
|
||||||
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
|
||||||
|
?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
|
||||||
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
|
||||||
|
?.addEventListener("click", () => publishVehicleActions({ reset: true }));
|
||||||
|
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
|
||||||
|
?.addEventListener("click", () => toggleVehicleCamera());
|
||||||
|
|
||||||
|
window.addEventListener("keyup", (event) => {
|
||||||
|
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
||||||
|
if (!heldDriveKeys.delete(key)) return;
|
||||||
|
if (publishVehicleActions()) event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("blur", () => {
|
||||||
|
heldDriveKeys.clear();
|
||||||
|
publishVehicleActions();
|
||||||
|
});
|
||||||
|
|
||||||
|
let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
|
||||||
|
function pollDriveGamepad() {
|
||||||
|
try {
|
||||||
|
const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
|
||||||
|
if (pad && routeDriveIsActive()) {
|
||||||
|
const sample = sampleStandardGamepad(pad, gamepadButtons);
|
||||||
|
gamepadButtons = sample.buttons;
|
||||||
|
publishVehicleActions(sample.actions);
|
||||||
|
} else {
|
||||||
|
gamepadButtons = { assist: false, reset: false };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Some privacy-hardened browsers expose the method but throw until a pad
|
||||||
|
// has produced a trusted event. Keyboard/touch remain fully functional.
|
||||||
|
}
|
||||||
|
requestAnimationFrame(pollDriveGamepad);
|
||||||
|
}
|
||||||
|
requestAnimationFrame(pollDriveGamepad);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keyboard access to everything the mouse can reach.
|
* Keyboard access to everything the mouse can reach.
|
||||||
*
|
*
|
||||||
@@ -1902,6 +2022,25 @@ window.addEventListener("keydown", (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const lower = event.key.toLowerCase();
|
const lower = event.key.toLowerCase();
|
||||||
|
if (lower === "w" || lower === "a" || lower === "s" || lower === "d" || event.key === " ") {
|
||||||
|
heldDriveKeys.add(event.key === " " ? " " : lower);
|
||||||
|
if (publishVehicleActions()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lower === "r" && publishVehicleActions({ reset: true })) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (lower === "c" && toggleVehicleCamera()) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (lower === "m") {
|
if (lower === "m") {
|
||||||
togglePlan();
|
togglePlan();
|
||||||
return;
|
return;
|
||||||
@@ -2369,7 +2508,9 @@ async function boot() {
|
|||||||
*/
|
*/
|
||||||
const wanted = new URLSearchParams(location.search).get("city");
|
const wanted = new URLSearchParams(location.search).get("city");
|
||||||
const first = CITIES.find((c) => c.id === wanted) ?? CITIES[0];
|
const first = CITIES.find((c) => c.id === wanted) ?? CITIES[0];
|
||||||
await building(`Building ${first?.label ?? "the city"}…`, () => mountCity(first?.id ?? "sf"));
|
await building(`Building ${first?.label ?? "the city"}…`, () =>
|
||||||
|
mountCity(first?.id ?? "california"),
|
||||||
|
);
|
||||||
|
|
||||||
// The instruments, after the first board, because the panel reads a live
|
// The instruments, after the first board, because the panel reads a live
|
||||||
// stage and there is not one before this line.
|
// stage and there is not one before this line.
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ export const LUMBRIDGE_HQ_SITE: OfficeSite = {
|
|||||||
elevation: 188,
|
elevation: 188,
|
||||||
heading: 205,
|
heading: 205,
|
||||||
label: "Transbay, San Francisco",
|
label: "Transbay, San Francisco",
|
||||||
|
exterior: {
|
||||||
|
kind: "building",
|
||||||
|
width: 48,
|
||||||
|
depth: 42,
|
||||||
|
height: 326,
|
||||||
|
storeys: 61,
|
||||||
|
heading: 205,
|
||||||
|
profile: "tower",
|
||||||
|
seed: 115,
|
||||||
|
bodyColor: 0x8799a8,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A hangar on the old naval air station. See `frontier-valley.ts`. */
|
/** A hangar on the old naval air station. See `frontier-valley.ts`. */
|
||||||
@@ -42,6 +53,17 @@ export const FRONTIER_VALLEY_SITE: OfficeSite = {
|
|||||||
elevation: 4,
|
elevation: 4,
|
||||||
heading: 0,
|
heading: 0,
|
||||||
label: "Alameda Point",
|
label: "Alameda Point",
|
||||||
|
exterior: {
|
||||||
|
kind: "building",
|
||||||
|
width: 54,
|
||||||
|
depth: 30,
|
||||||
|
height: 11,
|
||||||
|
storeys: 2,
|
||||||
|
heading: 0,
|
||||||
|
profile: "hangar",
|
||||||
|
seed: 2718,
|
||||||
|
bodyColor: 0x899397,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +78,17 @@ export const MATEO_COURT_SITE: OfficeSite = {
|
|||||||
elevation: 1.2,
|
elevation: 1.2,
|
||||||
heading: 36,
|
heading: 36,
|
||||||
label: "Arts District, Los Angeles",
|
label: "Arts District, Los Angeles",
|
||||||
|
exterior: {
|
||||||
|
kind: "building",
|
||||||
|
width: 36,
|
||||||
|
depth: 26,
|
||||||
|
height: 9,
|
||||||
|
storeys: 2,
|
||||||
|
heading: 36,
|
||||||
|
profile: "courtyard",
|
||||||
|
seed: 1781,
|
||||||
|
bodyColor: 0xa87960,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
CROW_METRICS,
|
||||||
|
DOG_METRICS,
|
||||||
|
HUMANOID_METRICS,
|
||||||
|
buildCrow,
|
||||||
|
buildDog,
|
||||||
|
buildHumanoid,
|
||||||
|
cloneCrow,
|
||||||
|
cloneDog,
|
||||||
|
cloneHumanoid,
|
||||||
|
disposeCrow,
|
||||||
|
disposeDog,
|
||||||
|
disposeHumanoid,
|
||||||
|
poseCrowFlight,
|
||||||
|
poseDogAttention,
|
||||||
|
poseDogWalk,
|
||||||
|
poseHumanoid,
|
||||||
|
} from "../assets/actors/index.ts";
|
||||||
|
|
||||||
|
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||||
|
const result: THREE.Mesh[] = [];
|
||||||
|
root.traverse((object) => {
|
||||||
|
if (object instanceof THREE.Mesh) result.push(object);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertSharedResources(source: THREE.Group, clone: THREE.Group): void {
|
||||||
|
const a = meshes(source);
|
||||||
|
const b = meshes(clone);
|
||||||
|
assert.equal(b.length, a.length);
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
assert.equal(b[i]!.geometry, a[i]!.geometry);
|
||||||
|
assert.equal(b[i]!.material, a[i]!.material);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("procedural actor assets", () => {
|
||||||
|
it("builds a metre-scale customizable humanoid with a front face surface", () => {
|
||||||
|
const rig = buildHumanoid({ bodyShape: "broad", outfitColor: 0x224466 });
|
||||||
|
const box = new THREE.Box3().setFromObject(rig.root);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
assert.ok(Math.abs(size.y - HUMANOID_METRICS.height) < 0.08, `height ${size.y}`);
|
||||||
|
assert.ok(box.min.y > -0.015, `floor ${box.min.y}`);
|
||||||
|
assert.equal(rig.root.userData.forwardAxis, "-Z");
|
||||||
|
assert.ok(rig.face.getWorldPosition(new THREE.Vector3()).z < 0);
|
||||||
|
|
||||||
|
const clone = cloneHumanoid(rig);
|
||||||
|
assertSharedResources(rig.root, clone.root);
|
||||||
|
poseHumanoid(clone, { walkPhase: Math.PI / 2, stride: 0.5, headYaw: 0.3 });
|
||||||
|
assert.notEqual(clone.joints.hipLeft.rotation.x, rig.joints.hipLeft.rotation.x);
|
||||||
|
assert.equal(clone.joints.head.rotation.y, 0.3);
|
||||||
|
assert.equal(clone.ownsMaterials, false);
|
||||||
|
disposeHumanoid(rig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds an anonymous office dog with independent attention and gait joints", () => {
|
||||||
|
const rig = buildDog();
|
||||||
|
const box = new THREE.Box3().setFromObject(rig.root);
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
assert.ok(size.y <= DOG_METRICS.height + 0.08, `height ${size.y}`);
|
||||||
|
assert.ok(size.z >= DOG_METRICS.length - 0.1, `length ${size.z}`);
|
||||||
|
assert.equal(rig.root.userData.actorType, "anonymous-dog");
|
||||||
|
|
||||||
|
const clone = cloneDog(rig);
|
||||||
|
assertSharedResources(rig.root, clone.root);
|
||||||
|
poseDogWalk(clone, Math.PI / 2);
|
||||||
|
poseDogAttention(clone, 0.4, Math.PI / 2);
|
||||||
|
assert.notEqual(clone.joints.legFrontLeft.rotation.x, rig.joints.legFrontLeft.rotation.x);
|
||||||
|
assert.equal(clone.joints.head.rotation.y, 0.4);
|
||||||
|
assert.notEqual(clone.joints.tail.rotation.z, 0);
|
||||||
|
disposeDog(rig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds a Tera crow with mirrored independent wing joints", () => {
|
||||||
|
const rig = buildCrow();
|
||||||
|
const box = new THREE.Box3().setFromObject(rig.root);
|
||||||
|
assert.ok(box.max.y <= CROW_METRICS.perchedHeight + 0.1, `height ${box.max.y}`);
|
||||||
|
assert.equal(rig.root.userData.actorType, "anonymous-crow");
|
||||||
|
const beak = rig.root.getObjectByName("crow.beak");
|
||||||
|
assert.ok(beak);
|
||||||
|
assert.ok(beak.getWorldPosition(new THREE.Vector3()).z < 0);
|
||||||
|
|
||||||
|
const clone = cloneCrow(rig);
|
||||||
|
assertSharedResources(rig.root, clone.root);
|
||||||
|
poseCrowFlight(clone, Math.PI / 2, 1);
|
||||||
|
assert.equal(clone.joints.wingLeft.rotation.z, -clone.joints.wingRight.rotation.z);
|
||||||
|
assert.notEqual(clone.joints.wingLeft.rotation.z, rig.joints.wingLeft.rotation.z);
|
||||||
|
disposeCrow(rig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never disposes caller-owned materials unless explicitly requested", () => {
|
||||||
|
const source = buildCrow();
|
||||||
|
const shared = meshes(source.root)[0]!.material as THREE.Material;
|
||||||
|
let disposals = 0;
|
||||||
|
shared.addEventListener("dispose", () => disposals++);
|
||||||
|
const external = {
|
||||||
|
feather: shared,
|
||||||
|
sheen: shared,
|
||||||
|
beak: shared,
|
||||||
|
eye: shared,
|
||||||
|
foot: shared,
|
||||||
|
};
|
||||||
|
const rig = buildCrow({ materials: external });
|
||||||
|
disposeCrow(rig);
|
||||||
|
assert.equal(disposals, 0);
|
||||||
|
disposeCrow(source);
|
||||||
|
assert.equal(disposals, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { layoutBuildingGlyph } from "../engine/buildingGlyph.ts";
|
||||||
|
import type { BuildingGlyph } from "../engine/types.ts";
|
||||||
|
|
||||||
|
function glyph(overrides: Partial<BuildingGlyph> = {}): BuildingGlyph {
|
||||||
|
return {
|
||||||
|
kind: "building",
|
||||||
|
width: 40,
|
||||||
|
depth: 30,
|
||||||
|
height: 100,
|
||||||
|
storeys: 24,
|
||||||
|
heading: 0,
|
||||||
|
profile: "block",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("map building glyph layouts", () => {
|
||||||
|
it("steps a tower inward without changing its declared height", () => {
|
||||||
|
const segments = layoutBuildingGlyph(glyph({ profile: "tower" }));
|
||||||
|
assert.equal(segments.length, 3);
|
||||||
|
assert.ok(segments[1]!.width < segments[0]!.width);
|
||||||
|
assert.ok(segments[2]!.width < segments[1]!.width);
|
||||||
|
assert.equal(Math.max(...segments.map((s) => s.base + s.height)), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves a real hole inside a courtyard ring", () => {
|
||||||
|
const segments = layoutBuildingGlyph(
|
||||||
|
glyph({ profile: "courtyard", width: 36, depth: 26, height: 9 }),
|
||||||
|
);
|
||||||
|
assert.equal(segments.length, 4);
|
||||||
|
|
||||||
|
const north = segments[0]!;
|
||||||
|
const west = segments[2]!;
|
||||||
|
const openWidth = 36 - west.width * 2;
|
||||||
|
const openDepth = 26 - north.depth * 2;
|
||||||
|
assert.ok(openWidth > 0, `courtyard closes across its width: ${openWidth}`);
|
||||||
|
assert.ok(openDepth > 0, `courtyard closes across its depth: ${openDepth}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reserves the top of a hangar for its pitched roof", () => {
|
||||||
|
const [body] = layoutBuildingGlyph(glyph({ profile: "hangar", height: 11 }));
|
||||||
|
assert.ok(body);
|
||||||
|
assert.equal(body.base, 0);
|
||||||
|
assert.equal(body.height, 11 * 0.72);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps undersized authored footprints renderable", () => {
|
||||||
|
const [segment] = layoutBuildingGlyph(
|
||||||
|
glyph({ width: 0, depth: -2, height: 0, profile: "block" }),
|
||||||
|
);
|
||||||
|
assert.ok(segment);
|
||||||
|
assert.deepEqual(
|
||||||
|
{ width: segment.width, depth: segment.depth, height: segment.height },
|
||||||
|
{ width: 4, depth: 4, height: 3 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** Contract checks for the state-scale rendering adapter. */
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import CALIFORNIA_CITY, { CALIFORNIA_I_5, CALIFORNIA_US_101 } from "../cities/california.ts";
|
||||||
|
|
||||||
|
describe("California corridor city", () => {
|
||||||
|
it("keeps the two transport routes on one coarse render board", () => {
|
||||||
|
assert.equal(CALIFORNIA_CITY.id, "california");
|
||||||
|
assert.equal(CALIFORNIA_CITY.roads.length, 2);
|
||||||
|
assert.ok(CALIFORNIA_US_101.length > 10);
|
||||||
|
assert.ok(CALIFORNIA_I_5.length > 10);
|
||||||
|
for (const path of [CALIFORNIA_US_101, CALIFORNIA_I_5]) {
|
||||||
|
for (const [lat, lng] of path) {
|
||||||
|
assert.ok(lat >= CALIFORNIA_CITY.bounds.minLat && lat <= CALIFORNIA_CITY.bounds.maxLat);
|
||||||
|
assert.ok(lng >= CALIFORNIA_CITY.bounds.minLng && lng <= CALIFORNIA_CITY.bounds.maxLng);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers route chapters plus doors into both detailed city boards", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
CALIFORNIA_CITY.chapters.map((chapter) => chapter.id),
|
||||||
|
["california-overview", "la-sf-us-101", "la-sf-i-5", "los-angeles", "san-francisco"],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses a state-scale field rather than city-scale cells", () => {
|
||||||
|
assert.ok(CALIFORNIA_CITY.cellLat >= 0.01);
|
||||||
|
assert.ok(CALIFORNIA_CITY.cellLng >= 0.01);
|
||||||
|
assert.equal(CALIFORNIA_CITY.districts.length, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
/** Contract tests for the authored California transport pack. */
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||||
|
import type { GeographicPoint, TransportRoute } from "../transport/types.ts";
|
||||||
|
|
||||||
|
function assertUnique(values: readonly string[], what: string): void {
|
||||||
|
assert.equal(new Set(values).size, values.length, `${what} must be unique`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPoint(point: GeographicPoint, what: string): void {
|
||||||
|
assert.ok(Number.isFinite(point.lat), `${what} latitude must be finite`);
|
||||||
|
assert.ok(Number.isFinite(point.lng), `${what} longitude must be finite`);
|
||||||
|
assert.ok(point.lat >= -90 && point.lat <= 90, `${what} latitude is outside the globe`);
|
||||||
|
assert.ok(point.lng >= -180 && point.lng <= 180, `${what} longitude is outside the globe`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertProvenance(value: string, what: string): void {
|
||||||
|
assert.ok(value.trim().length > 20, `${what} needs meaningful provenance`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function routeSegments(route: TransportRoute) {
|
||||||
|
const byId = new Map(CALIFORNIA_TRANSPORT.segments.map((segment) => [segment.id, segment]));
|
||||||
|
return route.segmentIds.map((id) => {
|
||||||
|
const segment = byId.get(id);
|
||||||
|
assert.ok(segment, `route ${route.id} references missing segment ${id}`);
|
||||||
|
return segment;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("California transport graph", () => {
|
||||||
|
it("has stable, unique identifiers and valid references", () => {
|
||||||
|
assertUnique(
|
||||||
|
CALIFORNIA_TRANSPORT.nodes.map((node) => node.id),
|
||||||
|
"node ids",
|
||||||
|
);
|
||||||
|
assertUnique(
|
||||||
|
CALIFORNIA_TRANSPORT.segments.map((segment) => segment.id),
|
||||||
|
"segment ids",
|
||||||
|
);
|
||||||
|
assertUnique(
|
||||||
|
CALIFORNIA_TRANSPORT.routes.map((route) => route.id),
|
||||||
|
"route ids",
|
||||||
|
);
|
||||||
|
assertUnique(
|
||||||
|
CALIFORNIA_TRANSPORT.anchors.map((anchor) => anchor.id),
|
||||||
|
"anchor ids",
|
||||||
|
);
|
||||||
|
|
||||||
|
const nodeIds = new Set(CALIFORNIA_TRANSPORT.nodes.map((node) => node.id));
|
||||||
|
for (const segment of CALIFORNIA_TRANSPORT.segments) {
|
||||||
|
assert.ok(nodeIds.has(segment.fromNodeId), `${segment.id} has no start node`);
|
||||||
|
assert.ok(nodeIds.has(segment.toNodeId), `${segment.id} has no end node`);
|
||||||
|
}
|
||||||
|
for (const anchor of CALIFORNIA_TRANSPORT.anchors) {
|
||||||
|
assert.ok(nodeIds.has(anchor.nodeId), `${anchor.id} has no corridor node`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps every route continuous from its declared start to its declared end", () => {
|
||||||
|
for (const route of CALIFORNIA_TRANSPORT.routes) {
|
||||||
|
const segments = routeSegments(route);
|
||||||
|
assert.ok(segments.length > 0, `${route.id} is empty`);
|
||||||
|
assert.equal(segments[0]?.fromNodeId, route.fromNodeId);
|
||||||
|
assert.equal(segments.at(-1)?.toNodeId, route.toNodeId);
|
||||||
|
|
||||||
|
for (let index = 1; index < segments.length; index += 1) {
|
||||||
|
assert.equal(
|
||||||
|
segments[index - 1]?.toNodeId,
|
||||||
|
segments[index]?.fromNodeId,
|
||||||
|
`${route.id} breaks between segments ${index - 1} and ${index}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names I-5's real Bay connectors instead of pretending I-5 reaches San Francisco", () => {
|
||||||
|
const route = CALIFORNIA_TRANSPORT.routes.find((candidate) => candidate.id === "la-sf-i-5");
|
||||||
|
assert.ok(route);
|
||||||
|
const roads = routeSegments(route).map((segment) => segment.roadName);
|
||||||
|
|
||||||
|
assert.deepEqual([...new Set(roads)], ["I-5", "I-580", "I-80 / Bay Bridge", "I-80"]);
|
||||||
|
assert.match(route.label, /I-580/);
|
||||||
|
assert.match(route.label, /I-80/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps US-101 on US-101 for the complete authored itinerary", () => {
|
||||||
|
const route = CALIFORNIA_TRANSPORT.routes.find((candidate) => candidate.id === "la-sf-us-101");
|
||||||
|
assert.ok(route);
|
||||||
|
assert.ok(routeSegments(route).every((segment) => segment.roadName === "US-101"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("contains finite California coordinates and usable simulation envelopes", () => {
|
||||||
|
for (const node of CALIFORNIA_TRANSPORT.nodes) {
|
||||||
|
assertPoint(node.position, `node ${node.id}`);
|
||||||
|
assert.ok(node.position.lat >= 32 && node.position.lat <= 42, `${node.id} is outside California`);
|
||||||
|
assert.ok(node.position.lng >= -125 && node.position.lng <= -114, `${node.id} is outside California`);
|
||||||
|
}
|
||||||
|
for (const anchor of CALIFORNIA_TRANSPORT.anchors) {
|
||||||
|
assertPoint(anchor.position, `anchor ${anchor.id}`);
|
||||||
|
}
|
||||||
|
for (const segment of CALIFORNIA_TRANSPORT.segments) {
|
||||||
|
assert.ok(segment.speedLimitMph > 0 && segment.speedLimitMph <= 70);
|
||||||
|
assert.ok(Number.isInteger(segment.lanesPerDirection));
|
||||||
|
assert.ok(segment.lanesPerDirection >= 1 && segment.lanesPerDirection <= 8);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records provenance on every authored item", () => {
|
||||||
|
for (const note of CALIFORNIA_TRANSPORT.provenance) assertProvenance(note, "pack");
|
||||||
|
for (const node of CALIFORNIA_TRANSPORT.nodes) assertProvenance(node.provenance, node.id);
|
||||||
|
for (const segment of CALIFORNIA_TRANSPORT.segments) {
|
||||||
|
assertProvenance(segment.provenance, segment.id);
|
||||||
|
}
|
||||||
|
for (const route of CALIFORNIA_TRANSPORT.routes) assertProvenance(route.provenance, route.id);
|
||||||
|
for (const anchor of CALIFORNIA_TRANSPORT.anchors) {
|
||||||
|
assertProvenance(anchor.provenance, anchor.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has scene anchors for both cities and all shipped offices", () => {
|
||||||
|
const cityIds = CALIFORNIA_TRANSPORT.anchors
|
||||||
|
.filter((anchor) => anchor.kind === "city")
|
||||||
|
.map((anchor) => anchor.cityId)
|
||||||
|
.sort();
|
||||||
|
const officeIds = CALIFORNIA_TRANSPORT.anchors
|
||||||
|
.filter((anchor) => anchor.kind === "office")
|
||||||
|
.map((anchor) => anchor.officeId)
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
assert.deepEqual(cityIds, ["sf", "socal"]);
|
||||||
|
assert.deepEqual(officeIds, ["frontier-valley", "lumbridge-hq", "mateo-court"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a JSON round trip without losing data", () => {
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(CALIFORNIA_TRANSPORT)), CALIFORNIA_TRANSPORT);
|
||||||
|
});
|
||||||
|
});
|
||||||
+28
-8
@@ -444,13 +444,14 @@ describe("the Frontier Valley pack", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Both shipped packs declare where they stand, and the two are deliberately
|
* All shipped packs declare where they stand, and the three are deliberately
|
||||||
* nothing alike — which is the entire argument for the field existing.
|
* nothing alike — which is the entire argument for the field existing.
|
||||||
*/
|
*/
|
||||||
describe("the sites", () => {
|
describe("the sites", () => {
|
||||||
it("are both declared", () => {
|
const packs = [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT];
|
||||||
assert.ok(LUMBRIDGE_HQ.site, "Lumbridge HQ has no site");
|
|
||||||
assert.ok(FRONTIER_VALLEY.site, "Frontier Valley has no site");
|
it("are all declared", () => {
|
||||||
|
for (const pack of packs) assert.ok(pack.site, `${pack.name} has no site`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("put one high in the air and one on the ground", () => {
|
it("put one high in the air and one on the ground", () => {
|
||||||
@@ -465,15 +466,15 @@ describe("the sites", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("carry headings inside the compass", () => {
|
it("carry headings inside the compass", () => {
|
||||||
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY, MATEO_COURT]) {
|
for (const pack of packs) {
|
||||||
const h = pack.site?.heading ?? 0;
|
const h = pack.site?.heading ?? 0;
|
||||||
assert.ok(h >= 0 && h < 360, `${pack.id} has a heading of ${h}`);
|
assert.ok(h >= 0 && h < 360, `${pack.id} has a heading of ${h}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("are both on the board the city view draws", () => {
|
it("puts the Bay Area offices on the board the city view draws", () => {
|
||||||
// Not a format requirement — an office may stand anywhere — but both of
|
// Not a format requirement — an office may stand anywhere — but these two
|
||||||
// these are meant to be places in *this* product's San Francisco, and a
|
// are meant to be places in *this* product's San Francisco, and a
|
||||||
// coordinate typo that put one in Nevada would otherwise render fine.
|
// coordinate typo that put one in Nevada would otherwise render fine.
|
||||||
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
|
for (const pack of [LUMBRIDGE_HQ, FRONTIER_VALLEY]) {
|
||||||
const site = pack.site;
|
const site = pack.site;
|
||||||
@@ -482,6 +483,25 @@ describe("the sites", () => {
|
|||||||
assert.ok(site.lng > -122.8 && site.lng < -121.8, `${pack.id} longitude ${site.lng}`);
|
assert.ok(site.lng > -122.8 && site.lng < -121.8, `${pack.id} longitude ${site.lng}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("gives every map destination a finite, aligned exterior glyph", () => {
|
||||||
|
for (const pack of packs) {
|
||||||
|
const site = pack.site;
|
||||||
|
assert.ok(site, `${pack.id} has no site`);
|
||||||
|
const exterior = site.exterior;
|
||||||
|
assert.ok(exterior, `${pack.id} has no exterior glyph`);
|
||||||
|
assert.equal(exterior.kind, "building");
|
||||||
|
assert.equal(exterior.heading, site.heading, `${pack.id} exterior faces away from its plan`);
|
||||||
|
for (const [field, value] of Object.entries({
|
||||||
|
width: exterior.width,
|
||||||
|
depth: exterior.depth,
|
||||||
|
height: exterior.height,
|
||||||
|
storeys: exterior.storeys,
|
||||||
|
})) {
|
||||||
|
assert.ok(Number.isFinite(value) && value > 0, `${pack.id} has invalid ${field}: ${value}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/** Render-layer contract tests that do not require a WebGL context. */
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||||
|
import { createRoadTrafficLayer } from "../engine/roadTraffic.ts";
|
||||||
|
import type { World } from "../engine/world.ts";
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
const world = {
|
||||||
|
project(lat: number, lng: number): [number, number] {
|
||||||
|
return [(lng + 121) * 20, -(lat - 36) * 20];
|
||||||
|
},
|
||||||
|
groundAt(): number {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
} as unknown as World;
|
||||||
|
const camera = new THREE.PerspectiveCamera(42, 16 / 9, 0.1, 2_000);
|
||||||
|
const controls = {
|
||||||
|
target: new THREE.Vector3(),
|
||||||
|
enabled: true,
|
||||||
|
} as unknown as OrbitControls;
|
||||||
|
const layer = createRoadTrafficLayer(world, camera, controls, {
|
||||||
|
pack: CALIFORNIA_TRANSPORT,
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
count: 8,
|
||||||
|
seed: 115,
|
||||||
|
});
|
||||||
|
return { layer, camera, controls };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("road traffic render layer", () => {
|
||||||
|
it("draws one articulated hero and instanced background parts", () => {
|
||||||
|
const { layer } = fixture();
|
||||||
|
const hero = layer.group.getObjectByName("model-x-hero");
|
||||||
|
assert.ok(hero);
|
||||||
|
assert.equal(hero.scale.x, 0.18);
|
||||||
|
assert.ok(layer.group.children.some((child) => child instanceof THREE.InstancedMesh));
|
||||||
|
assert.equal(layer.hero()?.routeId, "la-sf-us-101");
|
||||||
|
assert.ok(Math.abs(hero.position.y - 0.155) < 1e-9);
|
||||||
|
layer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("switches routes and owns the orbit-control handoff while following", () => {
|
||||||
|
const { layer, camera, controls } = fixture();
|
||||||
|
layer.setRoute("la-sf-i-5");
|
||||||
|
assert.equal(layer.routeId(), "la-sf-i-5");
|
||||||
|
assert.equal(layer.hero()?.routeId, "la-sf-i-5");
|
||||||
|
|
||||||
|
layer.setFollowing(true);
|
||||||
|
assert.equal(layer.following(), true);
|
||||||
|
assert.equal(controls.enabled, false);
|
||||||
|
layer.tick(0.1);
|
||||||
|
assert.ok(camera.position.toArray().every(Number.isFinite));
|
||||||
|
assert.ok(controls.target.toArray().every(Number.isFinite));
|
||||||
|
|
||||||
|
layer.setCameraMode("driver");
|
||||||
|
assert.equal(layer.cameraMode(), "driver");
|
||||||
|
layer.tick(1 / 60);
|
||||||
|
assert.ok(camera.position.y > layer.group.getObjectByName("model-x-hero")!.position.y);
|
||||||
|
|
||||||
|
layer.setFollowing(false);
|
||||||
|
assert.equal(controls.enabled, true);
|
||||||
|
layer.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hands manual input to the deterministic hero and resumes assistance", () => {
|
||||||
|
const { layer } = fixture();
|
||||||
|
for (let index = 0; index < 20; index += 1) {
|
||||||
|
layer.setVehicleActions({ throttle: 1, steering: 0.8 });
|
||||||
|
layer.tick(1 / 30);
|
||||||
|
}
|
||||||
|
assert.equal(layer.hero().mode, "manual");
|
||||||
|
assert.ok(layer.hero().lateralOffsetM > 0);
|
||||||
|
|
||||||
|
layer.setVehicleActions({ modeRequest: "assisted" });
|
||||||
|
layer.tick(1 / 30);
|
||||||
|
assert.equal(layer.hero().mode, "assisted");
|
||||||
|
layer.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import * as THREE from "three";
|
||||||
|
import {
|
||||||
|
MODEL_X_METRICS,
|
||||||
|
advanceModelXWheels,
|
||||||
|
buildModelX,
|
||||||
|
cloneModelX,
|
||||||
|
disposeModelX,
|
||||||
|
modelXInstanceParts,
|
||||||
|
setModelXSteering,
|
||||||
|
setModelXWheelRotation,
|
||||||
|
} from "../assets/vehicles/index.ts";
|
||||||
|
|
||||||
|
function meshes(root: THREE.Object3D): THREE.Mesh[] {
|
||||||
|
const found: THREE.Mesh[] = [];
|
||||||
|
root.traverse((object) => {
|
||||||
|
if (object instanceof THREE.Mesh) found.push(object);
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("procedural Model X vehicle asset", () => {
|
||||||
|
it("has a metre-scale crossover silhouette and faces -Z", () => {
|
||||||
|
const rig = buildModelX({ detail: "corridor" });
|
||||||
|
const size = new THREE.Box3().setFromObject(rig.root).getSize(new THREE.Vector3());
|
||||||
|
|
||||||
|
assert.ok(Math.abs(size.x - MODEL_X_METRICS.width) < 0.12, `width ${size.x}`);
|
||||||
|
assert.ok(Math.abs(size.y - MODEL_X_METRICS.height) < 0.08, `height ${size.y}`);
|
||||||
|
assert.ok(Math.abs(size.z - MODEL_X_METRICS.length) < 0.08, `length ${size.z}`);
|
||||||
|
assert.equal(rig.root.userData.forwardAxis, "-Z");
|
||||||
|
assert.ok(rig.wheels.frontLeft.steering.position.z < 0);
|
||||||
|
assert.ok(rig.wheels.rearLeft.steering.position.z > 0);
|
||||||
|
disposeModelX(rig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("exposes independent steering and rolling joints", () => {
|
||||||
|
const rig = buildModelX();
|
||||||
|
setModelXWheelRotation(rig, 1.25);
|
||||||
|
for (const wheel of Object.values(rig.wheels)) assert.equal(wheel.spin.rotation.x, 1.25);
|
||||||
|
|
||||||
|
setModelXSteering(rig, 99);
|
||||||
|
assert.equal(rig.wheels.frontLeft.steering.rotation.y, MODEL_X_METRICS.maxSteeringAngle);
|
||||||
|
assert.equal(rig.wheels.frontRight.steering.rotation.y, MODEL_X_METRICS.maxSteeringAngle);
|
||||||
|
assert.equal(rig.wheels.rearLeft.steering.rotation.y, 0);
|
||||||
|
|
||||||
|
advanceModelXWheels(rig, MODEL_X_METRICS.wheelRadius);
|
||||||
|
assert.equal(rig.wheels.frontLeft.spin.rotation.x, 0.25);
|
||||||
|
disposeModelX(rig);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clones cheaply while keeping its pose independent", () => {
|
||||||
|
const original = buildModelX();
|
||||||
|
const clone = cloneModelX(original);
|
||||||
|
const sourceMeshes = meshes(original.root);
|
||||||
|
const clonedMeshes = meshes(clone.root);
|
||||||
|
|
||||||
|
assert.equal(clonedMeshes.length, sourceMeshes.length);
|
||||||
|
for (let i = 0; i < sourceMeshes.length; i++) {
|
||||||
|
assert.equal(clonedMeshes[i]!.geometry, sourceMeshes[i]!.geometry);
|
||||||
|
assert.equal(clonedMeshes[i]!.material, sourceMeshes[i]!.material);
|
||||||
|
}
|
||||||
|
|
||||||
|
setModelXSteering(clone, -0.3);
|
||||||
|
assert.equal(clone.wheels.frontLeft.steering.rotation.y, -0.3);
|
||||||
|
assert.equal(original.wheels.frontLeft.steering.rotation.y, 0);
|
||||||
|
assert.equal(clone.ownsMaterials, false);
|
||||||
|
disposeModelX(original);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes stable neutral-pose pieces for instanced traffic", () => {
|
||||||
|
const rig = buildModelX({ detail: "corridor" });
|
||||||
|
const parts = modelXInstanceParts(rig);
|
||||||
|
assert.equal(parts.length, meshes(rig.root).length);
|
||||||
|
assert.ok(parts.some((part) => part.name === "frontLeft.tire"));
|
||||||
|
assert.ok(parts.some((part) => part.name.startsWith("model-x.body:")));
|
||||||
|
assert.ok(parts.every((part) => Number.isFinite(part.matrix.determinant())));
|
||||||
|
disposeModelX(rig);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||||
|
import {
|
||||||
|
VehicleController,
|
||||||
|
normalizeVehicleActions,
|
||||||
|
replayVehicleInputs,
|
||||||
|
} from "../transport/vehicleController.ts";
|
||||||
|
|
||||||
|
describe("vehicle controller", () => {
|
||||||
|
it("normalizes arbitrary adapter input into safe device-neutral actions", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeVehicleActions({
|
||||||
|
throttle: 4,
|
||||||
|
brake: Number.NaN,
|
||||||
|
steering: -3,
|
||||||
|
handbrake: true,
|
||||||
|
modeRequest: "manual",
|
||||||
|
reset: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
throttle: 1,
|
||||||
|
brake: 0,
|
||||||
|
steering: -1,
|
||||||
|
handbrake: true,
|
||||||
|
modeRequest: "manual",
|
||||||
|
reset: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(normalizeVehicleActions(undefined), {
|
||||||
|
throttle: 0,
|
||||||
|
brake: 0,
|
||||||
|
steering: 0,
|
||||||
|
handbrake: false,
|
||||||
|
modeRequest: "none",
|
||||||
|
reset: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("advances assisted driving on a deterministic fixed clock", () => {
|
||||||
|
const a = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
initialDistanceM: 1_000,
|
||||||
|
});
|
||||||
|
const b = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
initialDistanceM: 1_000,
|
||||||
|
});
|
||||||
|
for (let index = 0; index < 240; index += 1) {
|
||||||
|
a.stepFixed();
|
||||||
|
b.stepFixed();
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.snapshot(), b.snapshot());
|
||||||
|
assert.equal(a.state().mode, "assisted");
|
||||||
|
assert.ok(a.state().distanceM > 1_000);
|
||||||
|
assert.ok(a.state().speedMps > 0);
|
||||||
|
assert.ok(Number.isFinite(a.state().lat));
|
||||||
|
assert.ok(Number.isFinite(a.state().lng));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives manual input priority and cleanly resumes assistance", () => {
|
||||||
|
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-i-5",
|
||||||
|
initialSpeedMps: 25,
|
||||||
|
});
|
||||||
|
for (let index = 0; index < 90; index += 1) {
|
||||||
|
controller.stepFixed({ throttle: 0.7, steering: 0.8 });
|
||||||
|
}
|
||||||
|
assert.equal(controller.state().mode, "manual");
|
||||||
|
assert.ok(controller.state().lateralOffsetM > 0.5);
|
||||||
|
|
||||||
|
controller.stepFixed({ modeRequest: "assisted", steering: 0.9 });
|
||||||
|
assert.equal(controller.state().mode, "manual", "simultaneous human input must win");
|
||||||
|
const takeoverOffset = controller.state().lateralOffsetM;
|
||||||
|
|
||||||
|
controller.stepFixed({ modeRequest: "assisted" });
|
||||||
|
assert.equal(controller.state().mode, "assisted");
|
||||||
|
assert.ok(
|
||||||
|
Math.abs(controller.state().lateralOffsetM - takeoverOffset) < 0.25,
|
||||||
|
"assistance must not teleport the car to centre",
|
||||||
|
);
|
||||||
|
for (let index = 0; index < 300; index += 1) controller.stepFixed();
|
||||||
|
assert.ok(Math.abs(controller.state().lateralOffsetM) < Math.abs(takeoverOffset));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces speed and road-edge guardrails and exposes contact", () => {
|
||||||
|
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
mode: "manual",
|
||||||
|
initialSpeedMps: 200,
|
||||||
|
initialLateralOffsetM: 100,
|
||||||
|
maximumSpeedMps: 30,
|
||||||
|
guardrailOffsetM: 4,
|
||||||
|
});
|
||||||
|
assert.equal(controller.state().speedMps, 30);
|
||||||
|
assert.equal(controller.state().lateralOffsetM, 4);
|
||||||
|
for (let index = 0; index < 120; index += 1) {
|
||||||
|
controller.stepFixed({ throttle: 1, steering: 1 });
|
||||||
|
}
|
||||||
|
assert.ok(controller.state().lateralOffsetM <= 4);
|
||||||
|
assert.ok(controller.state().speedMps <= 30);
|
||||||
|
assert.equal(controller.state().guardrailContact, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets exactly to its configured spawn state", () => {
|
||||||
|
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-i-5",
|
||||||
|
mode: "manual",
|
||||||
|
initialDistanceM: 12_345,
|
||||||
|
initialLateralOffsetM: -1.25,
|
||||||
|
initialSpeedMps: 8,
|
||||||
|
});
|
||||||
|
const spawn = controller.snapshot();
|
||||||
|
for (let index = 0; index < 100; index += 1) {
|
||||||
|
controller.stepFixed({ throttle: 1, steering: 0.5 });
|
||||||
|
}
|
||||||
|
controller.stepFixed({ reset: true });
|
||||||
|
assert.deepEqual(controller.snapshot(), spawn);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps sleeping-tab catch-up and ignores invalid render deltas", () => {
|
||||||
|
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
});
|
||||||
|
assert.equal(controller.tick(Number.NaN), 0);
|
||||||
|
assert.equal(controller.tick(-1), 0);
|
||||||
|
const steps = controller.tick(600);
|
||||||
|
assert.ok(steps <= 15);
|
||||||
|
assert.equal(controller.state().elapsedSteps, steps);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("replays timed input frames bit-for-bit", () => {
|
||||||
|
const options = {
|
||||||
|
routeId: "la-sf-i-5",
|
||||||
|
initialSpeedMps: 15,
|
||||||
|
fixedStepSeconds: 1 / 30,
|
||||||
|
} as const;
|
||||||
|
const frames = [
|
||||||
|
{ steps: 40, actions: { throttle: 0.8, steering: -0.3 } },
|
||||||
|
{ steps: 1, actions: { modeRequest: "assisted" as const } },
|
||||||
|
{ steps: 80 },
|
||||||
|
{ steps: 20, actions: { brake: 0.7 } },
|
||||||
|
];
|
||||||
|
const first = replayVehicleInputs(CALIFORNIA_TRANSPORT, options, frames);
|
||||||
|
const second = replayVehicleInputs(CALIFORNIA_TRANSPORT, options, frames);
|
||||||
|
assert.deepEqual(first, second);
|
||||||
|
assert.equal(first.trajectory.length, 142);
|
||||||
|
assert.equal(first.final.elapsedSteps, 141);
|
||||||
|
assert.equal(first.final.mode, "manual");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can switch route variants while preserving normalized progress", () => {
|
||||||
|
const controller = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
initialDistanceM: 220_000,
|
||||||
|
});
|
||||||
|
const progress = controller.state().progress;
|
||||||
|
controller.setRoute("la-sf-i-5", true);
|
||||||
|
assert.equal(controller.routeId(), "la-sf-i-5");
|
||||||
|
assert.ok(Math.abs(controller.state().progress - progress) < 1e-12);
|
||||||
|
assert.equal(controller.state().elapsedSteps, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compresses corridor progress without changing vehicle dynamics", () => {
|
||||||
|
const normal = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
initialSpeedMps: 20,
|
||||||
|
mode: "manual",
|
||||||
|
});
|
||||||
|
const compressed = new VehicleController(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
initialSpeedMps: 20,
|
||||||
|
mode: "manual",
|
||||||
|
travelScale: 900,
|
||||||
|
});
|
||||||
|
normal.stepFixed();
|
||||||
|
compressed.stepFixed();
|
||||||
|
assert.equal(compressed.state().speedMps, normal.state().speedMps);
|
||||||
|
assert.equal(compressed.state().steering, normal.state().steering);
|
||||||
|
assert.ok(compressed.state().distanceM > normal.state().distanceM * 800);
|
||||||
|
assert.equal(compressed.state().wheelRadians, normal.state().wheelRadians);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { mergeVehicleActions, sampleStandardGamepad } from "../input/vehicle.ts";
|
||||||
|
|
||||||
|
function pad(over: { axes?: number[]; buttons?: Record<number, number> } = {}) {
|
||||||
|
const buttons = Array.from({ length: 8 }, (_, index) => ({
|
||||||
|
pressed: (over.buttons?.[index] ?? 0) > 0.5,
|
||||||
|
value: over.buttons?.[index] ?? 0,
|
||||||
|
}));
|
||||||
|
return { axes: over.axes ?? [0, 0, 0, 0], buttons };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("vehicle input adapters", () => {
|
||||||
|
it("maps a standard gamepad with a steering deadzone and analogue triggers", () => {
|
||||||
|
const sample = sampleStandardGamepad(pad({ axes: [0.5], buttons: { 6: 0.2, 7: 0.75, 1: 1 } }));
|
||||||
|
assert.ok(sample.actions.steering > 0 && sample.actions.steering < 0.5);
|
||||||
|
assert.equal(sample.actions.brake, 0.2);
|
||||||
|
assert.equal(sample.actions.throttle, 0.75);
|
||||||
|
assert.equal(sample.actions.handbrake, true);
|
||||||
|
assert.equal(sampleStandardGamepad(pad({ axes: [0.05] })).actions.steering, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes assisted/reset buttons only on their rising edge", () => {
|
||||||
|
const first = sampleStandardGamepad(pad({ buttons: { 2: 1, 3: 1 } }));
|
||||||
|
assert.equal(first.actions.modeRequest, "assisted");
|
||||||
|
assert.equal(first.actions.reset, true);
|
||||||
|
const held = sampleStandardGamepad(pad({ buttons: { 2: 1, 3: 1 } }), first.buttons);
|
||||||
|
assert.equal(held.actions.modeRequest, "none");
|
||||||
|
assert.equal(held.actions.reset, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges simultaneous adapters by strongest analogue and any safety input", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
mergeVehicleActions(
|
||||||
|
{ throttle: 1, steering: -0.4 },
|
||||||
|
{ brake: 0.7, steering: 0.8, handbrake: true },
|
||||||
|
),
|
||||||
|
{
|
||||||
|
throttle: 1,
|
||||||
|
brake: 0.7,
|
||||||
|
steering: 0.8,
|
||||||
|
handbrake: true,
|
||||||
|
modeRequest: "none",
|
||||||
|
reset: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
/** Determinism and lifecycle tests for statewide road traffic. */
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import CALIFORNIA_TRANSPORT from "../transport/california.ts";
|
||||||
|
import {
|
||||||
|
VehicleSimulation,
|
||||||
|
buildRoutePath,
|
||||||
|
distanceMetres,
|
||||||
|
sampleRoute,
|
||||||
|
} from "../transport/vehicleSim.ts";
|
||||||
|
|
||||||
|
describe("vehicle simulation", () => {
|
||||||
|
it("builds both complete routes and samples their declared endpoints", () => {
|
||||||
|
for (const id of ["la-sf-us-101", "la-sf-i-5"]) {
|
||||||
|
const path = buildRoutePath(CALIFORNIA_TRANSPORT, id);
|
||||||
|
assert.ok(path.lengthM > 500_000);
|
||||||
|
const start = sampleRoute(path, 0);
|
||||||
|
const finish = sampleRoute(path, path.lengthM - 0.01);
|
||||||
|
assert.ok(distanceMetres(start, { lat: 34.0522, lng: -118.2437 }) < 10);
|
||||||
|
assert.ok(distanceMetres(finish, { lat: 37.7749, lng: -122.4194 }) < 20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is deterministic for the same seed, route, and frame sequence", () => {
|
||||||
|
const a = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
count: 8,
|
||||||
|
seed: 42,
|
||||||
|
});
|
||||||
|
const b = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
count: 8,
|
||||||
|
seed: 42,
|
||||||
|
});
|
||||||
|
for (const dt of [0.016, 0.033, 0.2, 0.041, 0.08]) {
|
||||||
|
a.tick(dt);
|
||||||
|
b.tick(dt);
|
||||||
|
}
|
||||||
|
assert.deepEqual(a.poses(), b.poses());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps a background-tab delta and keeps every pose finite", () => {
|
||||||
|
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-i-5",
|
||||||
|
count: 16,
|
||||||
|
});
|
||||||
|
const path = buildRoutePath(CALIFORNIA_TRANSPORT, "la-sf-i-5");
|
||||||
|
const before = sim.poses()[0]?.distanceM ?? 0;
|
||||||
|
sim.tick(600);
|
||||||
|
const after = sim.poses()[0]?.distanceM ?? 0;
|
||||||
|
const direct = Math.abs(after - before);
|
||||||
|
const travelled = Math.min(direct, path.lengthM - direct);
|
||||||
|
assert.ok(travelled < 20_000, "a sleeping tab must not replay ten minutes");
|
||||||
|
for (const pose of sim.poses()) {
|
||||||
|
assert.ok(Number.isFinite(pose.lat));
|
||||||
|
assert.ok(Number.isFinite(pose.lng));
|
||||||
|
assert.ok(Number.isFinite(pose.headingDeg));
|
||||||
|
assert.ok(pose.progress >= 0 && pose.progress < 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes route as one deterministic state transition", () => {
|
||||||
|
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
count: 4,
|
||||||
|
seed: 7,
|
||||||
|
});
|
||||||
|
sim.setRoute("la-sf-i-5");
|
||||||
|
assert.equal(sim.routeId(), "la-sf-i-5");
|
||||||
|
assert.ok(sim.poses().every((pose) => pose.routeId === "la-sf-i-5"));
|
||||||
|
assert.equal(sim.poses()[0]?.id, "model-x-hero");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves reverse traffic south while keeping its heading finite", () => {
|
||||||
|
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
count: 4,
|
||||||
|
seed: 115,
|
||||||
|
timeScale: 1,
|
||||||
|
});
|
||||||
|
const southbound = sim.poses().find((pose) => pose.direction === -1);
|
||||||
|
assert.equal(southbound?.direction, -1);
|
||||||
|
const before = southbound?.distanceM ?? 0;
|
||||||
|
const beforeLat = southbound?.lat ?? 0;
|
||||||
|
sim.tick(0.1);
|
||||||
|
assert.ok((southbound?.distanceM ?? before) < before);
|
||||||
|
assert.ok((southbound?.lat ?? beforeLat) < beforeLat);
|
||||||
|
assert.ok(Number.isFinite(southbound?.headingDeg));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns one stable pose view for allocation-free render polling", () => {
|
||||||
|
const sim = new VehicleSimulation(CALIFORNIA_TRANSPORT, {
|
||||||
|
routeId: "la-sf-us-101",
|
||||||
|
count: 4,
|
||||||
|
});
|
||||||
|
const poses = sim.poses();
|
||||||
|
sim.tick(0.1);
|
||||||
|
assert.equal(sim.poses(), poses);
|
||||||
|
assert.equal(sim.poses()[0], poses[0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { Plan } from "../interiors/plan.ts";
|
||||||
|
import {
|
||||||
|
createWalker,
|
||||||
|
normalizeWalkerAction,
|
||||||
|
type WalkerOptions,
|
||||||
|
} from "../interiors/walker.ts";
|
||||||
|
import type { Level, Office, Room, Wall } from "../interiors/types.ts";
|
||||||
|
|
||||||
|
const ROOM: Room = {
|
||||||
|
id: "floor",
|
||||||
|
name: "Floor",
|
||||||
|
floor: "floor" as never,
|
||||||
|
outline: [
|
||||||
|
{ x: 0, z: 0 },
|
||||||
|
{ x: 10, z: 0 },
|
||||||
|
{ x: 10, z: 8 },
|
||||||
|
{ x: 0, z: 8 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function planWith(walls: Wall[] = []): Plan {
|
||||||
|
const level: Level = {
|
||||||
|
id: "ground",
|
||||||
|
name: "Ground",
|
||||||
|
elevation: 0,
|
||||||
|
wallHeight: 3,
|
||||||
|
wallThickness: 0.1,
|
||||||
|
floorplan: { rooms: [ROOM], walls },
|
||||||
|
};
|
||||||
|
const office: Office = { id: "walk-test", name: "Walk Test", levels: [level], viewpoints: [] };
|
||||||
|
return new Plan(office, { warn: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
function walker(plan: Plan, over: Partial<WalkerOptions> = {}) {
|
||||||
|
return createWalker(plan, {
|
||||||
|
levelId: "ground",
|
||||||
|
position: { x: 2, z: 2 },
|
||||||
|
speed: 1,
|
||||||
|
fixedStep: 0.1,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("walker input and clock", () => {
|
||||||
|
it("normalizes planar actions without amplifying smaller input", () => {
|
||||||
|
assert.deepEqual(normalizeWalkerAction({ x: 0.3, z: -0.4 }), { x: 0.3, z: -0.4 });
|
||||||
|
const diagonal = normalizeWalkerAction({ x: 1, z: 1 });
|
||||||
|
assert.ok(Math.abs(Math.hypot(diagonal.x, diagonal.z) - 1) < 1e-12);
|
||||||
|
assert.deepEqual(normalizeWalkerAction({ x: Number.NaN, z: 1 }), { x: 0, z: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves only in fixed steps and is deterministic across frame chunking", () => {
|
||||||
|
const plan = planWith();
|
||||||
|
const one = walker(plan);
|
||||||
|
const many = walker(plan);
|
||||||
|
one.tick(0.05, { x: 1, z: 0 });
|
||||||
|
assert.deepEqual(one.state().position, { x: 2, z: 2 });
|
||||||
|
one.tick(0.35, { x: 1, z: 0 });
|
||||||
|
for (let index = 0; index < 4; index += 1) many.tick(0.1, { x: 1, z: 0 });
|
||||||
|
assert.deepEqual(one.state(), many.state());
|
||||||
|
assert.ok(Math.abs(one.state().distance - 0.4) < 1e-12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns defensive state snapshots", () => {
|
||||||
|
const controller = walker(planWith());
|
||||||
|
const leaked = controller.state();
|
||||||
|
leaked.position.x = Number.NaN;
|
||||||
|
assert.deepEqual(controller.state().position, { x: 2, z: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("walker collision", () => {
|
||||||
|
it("sweeps its circular footprint and cannot tunnel through a wall", () => {
|
||||||
|
const plan = planWith([{ id: "divider", from: { x: 5, z: 0 }, to: { x: 5, z: 8 } }]);
|
||||||
|
const controller = walker(plan, { speed: 100, fixedStep: 0.1 });
|
||||||
|
const state = controller.tick(0.1, { x: 1, z: 0 });
|
||||||
|
assert.ok(state.position.x < 4.65 && state.position.x > 4.64, `${state.position.x}`);
|
||||||
|
assert.equal(plan.blocked("ground", state.position, state.position, 0.3), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("slides the unblocked component of diagonal movement along a wall", () => {
|
||||||
|
const plan = planWith([{ id: "divider", from: { x: 5, z: 0 }, to: { x: 5, z: 8 } }]);
|
||||||
|
const controller = walker(plan, { position: { x: 4.6, z: 2 }, speed: 2 });
|
||||||
|
for (let index = 0; index < 10; index += 1) controller.tick(0.1, { x: 1, z: 1 });
|
||||||
|
const state = controller.state();
|
||||||
|
assert.ok(state.position.x < 4.65, `${state.position.x}`);
|
||||||
|
assert.ok(state.position.z > 3, `${state.position.z}`);
|
||||||
|
assert.equal(plan.blocked("ground", state.position, state.position, 0.3), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("walks through a resolved door gap without a door-specific exception", () => {
|
||||||
|
const plan = planWith([
|
||||||
|
{
|
||||||
|
id: "divider",
|
||||||
|
from: { x: 5, z: 0 },
|
||||||
|
to: { x: 5, z: 8 },
|
||||||
|
openings: [{ kind: "door", start: 3.4, width: 1.2, sill: 0, head: 2.1 }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const controller = walker(plan, { position: { x: 4, z: 4 }, speed: 2 });
|
||||||
|
for (let index = 0; index < 10; index += 1) controller.tick(0.1, { x: 1, z: 0 });
|
||||||
|
assert.ok(controller.state().position.x > 5.5, `${controller.state().position.x}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("walker guardrails", () => {
|
||||||
|
it("stays within finite level bounds and ignores invalid time/input", () => {
|
||||||
|
const controller = walker(planWith(), { position: { x: 9.6, z: 4 }, speed: 10 });
|
||||||
|
controller.tick(Number.NaN, { x: 1, z: 0 });
|
||||||
|
controller.tick(1, { x: Number.POSITIVE_INFINITY, z: 0 });
|
||||||
|
assert.deepEqual(controller.state().position, { x: 9.6, z: 4 });
|
||||||
|
controller.tick(1, { x: 1, z: 0 });
|
||||||
|
const state = controller.state();
|
||||||
|
assert.equal(state.position.x, 9.7);
|
||||||
|
assert.ok(Number.isFinite(state.position.x) && Number.isFinite(state.distance));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets atomically and rejects invalid spawns/configuration", () => {
|
||||||
|
const plan = planWith();
|
||||||
|
const controller = walker(plan);
|
||||||
|
controller.tick(0.2, { x: 1, z: 0 });
|
||||||
|
assert.deepEqual(controller.reset({ levelId: "ground", position: { x: 7, z: 6 } }).position, { x: 7, z: 6 });
|
||||||
|
assert.equal(controller.state().distance, 0);
|
||||||
|
assert.throws(() => controller.reset({ levelId: "ground", position: { x: Number.NaN, z: 1 } }), RangeError);
|
||||||
|
assert.deepEqual(controller.state().position, { x: 7, z: 6 });
|
||||||
|
assert.throws(() => walker(plan, { radius: 0 }), RangeError);
|
||||||
|
assert.throws(() => walker(plan, { position: { x: 11, z: 2 } }), RangeError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
/**
|
||||||
|
* The first California transport corridor: two coarse LA-to-Bay itineraries.
|
||||||
|
*
|
||||||
|
* This is simulation geometry, not a navigation dataset. Coordinates were
|
||||||
|
* placed by hand at recognisable cities and junctions, with long road sections
|
||||||
|
* represented by one straight edge. In particular, I-5 does not enter San
|
||||||
|
* Francisco: that itinerary names its real Bay approach over I-580 and I-80.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
TransportAnchor,
|
||||||
|
TransportNode,
|
||||||
|
TransportPack,
|
||||||
|
TransportRoute,
|
||||||
|
TransportSegment,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
const AUTHORED_NODE =
|
||||||
|
"Original coarse waypoint authored by the Tera project from general California geography; approximate and not for navigation.";
|
||||||
|
const AUTHORED_ROAD =
|
||||||
|
"Original coarse simulation segment authored by the Tera project; road identity and representative speed/lanes are approximate, not live navigation data.";
|
||||||
|
const INTERNAL_OFFICE =
|
||||||
|
"Transition coordinate mirrors Tera's authored office site catalogue; it is project data, not copied map geometry.";
|
||||||
|
|
||||||
|
export const CALIFORNIA_TRANSPORT_NODES: readonly TransportNode[] = [
|
||||||
|
{
|
||||||
|
id: "los-angeles",
|
||||||
|
label: "Los Angeles",
|
||||||
|
kind: "terminus",
|
||||||
|
position: { lat: 34.0522, lng: -118.2437 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
|
||||||
|
// US-101: the coast and Salinas Valley approach.
|
||||||
|
{
|
||||||
|
id: "ventura",
|
||||||
|
label: "Ventura",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 34.2805, lng: -119.2945 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "santa-barbara",
|
||||||
|
label: "Santa Barbara",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 34.4208, lng: -119.6982 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "santa-maria",
|
||||||
|
label: "Santa Maria",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 34.953, lng: -120.4357 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "san-luis-obispo",
|
||||||
|
label: "San Luis Obispo",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 35.2828, lng: -120.6596 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "paso-robles",
|
||||||
|
label: "Paso Robles",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 35.626, lng: -120.691 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "king-city",
|
||||||
|
label: "King City",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 36.2127, lng: -121.126 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "salinas",
|
||||||
|
label: "Salinas",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 36.6777, lng: -121.6555 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "gilroy",
|
||||||
|
label: "Gilroy",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 37.0058, lng: -121.5683 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "san-jose",
|
||||||
|
label: "San Jose",
|
||||||
|
kind: "junction",
|
||||||
|
position: { lat: 37.3382, lng: -121.8863 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redwood-city",
|
||||||
|
label: "Redwood City",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 37.4852, lng: -122.2364 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
|
||||||
|
// I-5: Central Valley, followed by an explicitly named Bay connector.
|
||||||
|
{
|
||||||
|
id: "santa-clarita",
|
||||||
|
label: "Santa Clarita",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 34.3917, lng: -118.5426 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "grapevine",
|
||||||
|
label: "Grapevine",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 34.9416, lng: -118.929 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "lost-hills",
|
||||||
|
label: "Lost Hills",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 35.6166, lng: -119.6943 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "coalinga-interchange",
|
||||||
|
label: "Coalinga / SR-198",
|
||||||
|
kind: "junction",
|
||||||
|
position: { lat: 36.253, lng: -120.237 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "santa-nella",
|
||||||
|
label: "Santa Nella",
|
||||||
|
kind: "junction",
|
||||||
|
position: { lat: 37.102, lng: -121.016 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tracy",
|
||||||
|
label: "Tracy",
|
||||||
|
kind: "junction",
|
||||||
|
position: { lat: 37.7397, lng: -121.4252 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "altamont-pass",
|
||||||
|
label: "Altamont Pass",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 37.696, lng: -121.686 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "dublin",
|
||||||
|
label: "Dublin",
|
||||||
|
kind: "waypoint",
|
||||||
|
position: { lat: 37.7022, lng: -121.9358 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "oakland",
|
||||||
|
label: "Oakland",
|
||||||
|
kind: "junction",
|
||||||
|
position: { lat: 37.8044, lng: -122.2712 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "bay-bridge",
|
||||||
|
label: "San Francisco-Oakland Bay Bridge",
|
||||||
|
kind: "junction",
|
||||||
|
position: { lat: 37.7983, lng: -122.3778 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "san-francisco",
|
||||||
|
label: "San Francisco",
|
||||||
|
kind: "terminus",
|
||||||
|
position: { lat: 37.7749, lng: -122.4194 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CALIFORNIA_TRANSPORT_SEGMENTS: readonly TransportSegment[] = [
|
||||||
|
// US-101 route.
|
||||||
|
["us101-la-ventura", "los-angeles", "ventura", 65, 3],
|
||||||
|
["us101-ventura-santa-barbara", "ventura", "santa-barbara", 65, 2],
|
||||||
|
["us101-santa-barbara-santa-maria", "santa-barbara", "santa-maria", 65, 2],
|
||||||
|
["us101-santa-maria-san-luis-obispo", "santa-maria", "san-luis-obispo", 65, 2],
|
||||||
|
["us101-san-luis-obispo-paso-robles", "san-luis-obispo", "paso-robles", 65, 2],
|
||||||
|
["us101-paso-robles-king-city", "paso-robles", "king-city", 65, 2],
|
||||||
|
["us101-king-city-salinas", "king-city", "salinas", 65, 2],
|
||||||
|
["us101-salinas-gilroy", "salinas", "gilroy", 65, 2],
|
||||||
|
["us101-gilroy-san-jose", "gilroy", "san-jose", 65, 3],
|
||||||
|
["us101-san-jose-redwood-city", "san-jose", "redwood-city", 65, 4],
|
||||||
|
["us101-redwood-city-san-francisco", "redwood-city", "san-francisco", 65, 4],
|
||||||
|
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
|
||||||
|
id: id as string,
|
||||||
|
fromNodeId: fromNodeId as string,
|
||||||
|
toNodeId: toNodeId as string,
|
||||||
|
roadName: "US-101",
|
||||||
|
kind: "us-highway" as const,
|
||||||
|
speedLimitMph: speedLimitMph as number,
|
||||||
|
lanesPerDirection: lanesPerDirection as number,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const INTERSTATE_SEGMENTS: readonly TransportSegment[] = [
|
||||||
|
["i5-la-santa-clarita", "los-angeles", "santa-clarita", 65, 4],
|
||||||
|
["i5-santa-clarita-grapevine", "santa-clarita", "grapevine", 65, 3],
|
||||||
|
["i5-grapevine-lost-hills", "grapevine", "lost-hills", 70, 2],
|
||||||
|
["i5-lost-hills-coalinga", "lost-hills", "coalinga-interchange", 70, 2],
|
||||||
|
["i5-coalinga-santa-nella", "coalinga-interchange", "santa-nella", 70, 2],
|
||||||
|
["i5-santa-nella-tracy", "santa-nella", "tracy", 70, 3],
|
||||||
|
].map(([id, fromNodeId, toNodeId, speedLimitMph, lanesPerDirection]) => ({
|
||||||
|
id: id as string,
|
||||||
|
fromNodeId: fromNodeId as string,
|
||||||
|
toNodeId: toNodeId as string,
|
||||||
|
roadName: "I-5",
|
||||||
|
kind: "interstate" as const,
|
||||||
|
speedLimitMph: speedLimitMph as number,
|
||||||
|
lanesPerDirection: lanesPerDirection as number,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const BAY_CONNECTOR_SEGMENTS: readonly TransportSegment[] = [
|
||||||
|
{
|
||||||
|
id: "i580-tracy-altamont",
|
||||||
|
fromNodeId: "tracy",
|
||||||
|
toNodeId: "altamont-pass",
|
||||||
|
roadName: "I-580",
|
||||||
|
kind: "connector",
|
||||||
|
speedLimitMph: 65,
|
||||||
|
lanesPerDirection: 3,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i580-altamont-dublin",
|
||||||
|
fromNodeId: "altamont-pass",
|
||||||
|
toNodeId: "dublin",
|
||||||
|
roadName: "I-580",
|
||||||
|
kind: "connector",
|
||||||
|
speedLimitMph: 65,
|
||||||
|
lanesPerDirection: 4,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i580-dublin-oakland",
|
||||||
|
fromNodeId: "dublin",
|
||||||
|
toNodeId: "oakland",
|
||||||
|
roadName: "I-580",
|
||||||
|
kind: "connector",
|
||||||
|
speedLimitMph: 65,
|
||||||
|
lanesPerDirection: 4,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i80-oakland-bay-bridge",
|
||||||
|
fromNodeId: "oakland",
|
||||||
|
toNodeId: "bay-bridge",
|
||||||
|
roadName: "I-80 / Bay Bridge",
|
||||||
|
kind: "connector",
|
||||||
|
speedLimitMph: 50,
|
||||||
|
lanesPerDirection: 5,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "i80-bay-bridge-san-francisco",
|
||||||
|
fromNodeId: "bay-bridge",
|
||||||
|
toNodeId: "san-francisco",
|
||||||
|
roadName: "I-80",
|
||||||
|
kind: "connector",
|
||||||
|
speedLimitMph: 50,
|
||||||
|
lanesPerDirection: 5,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CALIFORNIA_I5_SEGMENTS: readonly TransportSegment[] = [
|
||||||
|
...INTERSTATE_SEGMENTS,
|
||||||
|
...BAY_CONNECTOR_SEGMENTS,
|
||||||
|
];
|
||||||
|
|
||||||
|
const US_101_SEGMENT_IDS = CALIFORNIA_TRANSPORT_SEGMENTS.map((segment) => segment.id);
|
||||||
|
const I_5_SEGMENT_IDS = CALIFORNIA_I5_SEGMENTS.map((segment) => segment.id);
|
||||||
|
|
||||||
|
export const CALIFORNIA_TRANSPORT_ROUTES: readonly TransportRoute[] = [
|
||||||
|
{
|
||||||
|
id: "la-sf-us-101",
|
||||||
|
label: "Los Angeles to San Francisco via US-101",
|
||||||
|
description: "The coastal and Salinas Valley route through Santa Barbara and San Jose.",
|
||||||
|
fromNodeId: "los-angeles",
|
||||||
|
toNodeId: "san-francisco",
|
||||||
|
segmentIds: US_101_SEGMENT_IDS,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "la-sf-i-5",
|
||||||
|
label: "Los Angeles to San Francisco via I-5, I-580, and I-80",
|
||||||
|
description:
|
||||||
|
"The Central Valley route, leaving I-5 at Tracy for I-580 and I-80 across the Bay Bridge.",
|
||||||
|
fromNodeId: "los-angeles",
|
||||||
|
toNodeId: "san-francisco",
|
||||||
|
segmentIds: I_5_SEGMENT_IDS,
|
||||||
|
provenance: AUTHORED_ROAD,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CALIFORNIA_TRANSPORT_ANCHORS: readonly TransportAnchor[] = [
|
||||||
|
{
|
||||||
|
id: "city-socal",
|
||||||
|
kind: "city",
|
||||||
|
cityId: "socal",
|
||||||
|
label: "Southern California",
|
||||||
|
nodeId: "los-angeles",
|
||||||
|
position: { lat: 34.0522, lng: -118.2437 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "city-sf",
|
||||||
|
kind: "city",
|
||||||
|
cityId: "sf",
|
||||||
|
label: "San Francisco",
|
||||||
|
nodeId: "san-francisco",
|
||||||
|
position: { lat: 37.7749, lng: -122.4194 },
|
||||||
|
provenance: AUTHORED_NODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "office-mateo-court",
|
||||||
|
kind: "office",
|
||||||
|
cityId: "socal",
|
||||||
|
officeId: "mateo-court",
|
||||||
|
label: "Mateo Court",
|
||||||
|
nodeId: "los-angeles",
|
||||||
|
position: { lat: 34.0395, lng: -118.2288 },
|
||||||
|
provenance: INTERNAL_OFFICE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "office-lumbridge-hq",
|
||||||
|
kind: "office",
|
||||||
|
cityId: "sf",
|
||||||
|
officeId: "lumbridge-hq",
|
||||||
|
label: "Lumbridge HQ",
|
||||||
|
nodeId: "san-francisco",
|
||||||
|
position: { lat: 37.7897, lng: -122.3972 },
|
||||||
|
provenance: INTERNAL_OFFICE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "office-frontier-valley",
|
||||||
|
kind: "office",
|
||||||
|
cityId: "sf",
|
||||||
|
officeId: "frontier-valley",
|
||||||
|
label: "Frontier Valley",
|
||||||
|
nodeId: "oakland",
|
||||||
|
position: { lat: 37.7756, lng: -122.3186 },
|
||||||
|
provenance: INTERNAL_OFFICE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** All corridor data in one JSON-safe object. */
|
||||||
|
export const CALIFORNIA_TRANSPORT: TransportPack = {
|
||||||
|
id: "california-la-bay",
|
||||||
|
name: "California: Los Angeles to the Bay",
|
||||||
|
schemaVersion: 1,
|
||||||
|
description:
|
||||||
|
"A coarse statewide simulation corridor connecting Tera's Southern California and San Francisco scenes.",
|
||||||
|
nodes: CALIFORNIA_TRANSPORT_NODES,
|
||||||
|
segments: [...CALIFORNIA_TRANSPORT_SEGMENTS, ...CALIFORNIA_I5_SEGMENTS],
|
||||||
|
routes: CALIFORNIA_TRANSPORT_ROUTES,
|
||||||
|
anchors: CALIFORNIA_TRANSPORT_ANCHORS,
|
||||||
|
provenance: [
|
||||||
|
"Original manually authored Tera project data; no third-party map geometry is embedded.",
|
||||||
|
"Coordinates, lane counts, and speed envelopes are coarse simulation inputs and must not be used for navigation.",
|
||||||
|
"Office transition coordinates mirror the repository's own src/offices/sites.ts catalogue.",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CALIFORNIA_TRANSPORT;
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Serializable contracts for transport packs.
|
||||||
|
*
|
||||||
|
* These types deliberately contain data only: no classes, dates, maps, or
|
||||||
|
* three.js values. A pack can therefore cross an HTTP boundary, live in a
|
||||||
|
* Worker, or be recorded for a deterministic replay without translation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** A WGS84-like geographic position in decimal degrees. */
|
||||||
|
export interface GeographicPoint {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The road system responsible for a segment. */
|
||||||
|
export type RoadKind = "interstate" | "us-highway" | "connector";
|
||||||
|
|
||||||
|
/** Why a node exists in the deliberately sparse corridor graph. */
|
||||||
|
export type TransportNodeKind = "terminus" | "waypoint" | "junction";
|
||||||
|
|
||||||
|
export interface TransportNode {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
kind: TransportNodeKind;
|
||||||
|
position: GeographicPoint;
|
||||||
|
/** Human-readable origin and accuracy note for this authored coordinate. */
|
||||||
|
provenance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A directed driveable edge. Reverse itineraries traverse the same edge in
|
||||||
|
* reverse; geometry is intentionally not duplicated for each direction.
|
||||||
|
*/
|
||||||
|
export interface TransportSegment {
|
||||||
|
id: string;
|
||||||
|
fromNodeId: string;
|
||||||
|
toNodeId: string;
|
||||||
|
/** The name a route badge or itinerary should show. */
|
||||||
|
roadName: string;
|
||||||
|
kind: RoadKind;
|
||||||
|
/** Coarse simulation envelope, not live traffic or navigation advice. */
|
||||||
|
speedLimitMph: number;
|
||||||
|
/** Number of through lanes in one direction at the representative section. */
|
||||||
|
lanesPerDirection: number;
|
||||||
|
provenance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A named, ordered itinerary through the segment graph. */
|
||||||
|
export interface TransportRoute {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
fromNodeId: string;
|
||||||
|
toNodeId: string;
|
||||||
|
segmentIds: readonly string[];
|
||||||
|
provenance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnchorBase {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
/** Nearest corridor node used to enter or leave the large-scale simulation. */
|
||||||
|
nodeId: string;
|
||||||
|
/** Exact transition marker; it need not lie on the corridor centreline. */
|
||||||
|
position: GeographicPoint;
|
||||||
|
provenance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CityTransportAnchor extends AnchorBase {
|
||||||
|
kind: "city";
|
||||||
|
cityId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OfficeTransportAnchor extends AnchorBase {
|
||||||
|
kind: "office";
|
||||||
|
cityId: string;
|
||||||
|
officeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransportAnchor = CityTransportAnchor | OfficeTransportAnchor;
|
||||||
|
|
||||||
|
/** A complete coarse world graph and its links to finer Tera scenes. */
|
||||||
|
export interface TransportPack {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
schemaVersion: 1;
|
||||||
|
description: string;
|
||||||
|
nodes: readonly TransportNode[];
|
||||||
|
segments: readonly TransportSegment[];
|
||||||
|
routes: readonly TransportRoute[];
|
||||||
|
anchors: readonly TransportAnchor[];
|
||||||
|
/** Pack-wide authorship and fitness-for-purpose notices. */
|
||||||
|
provenance: readonly string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
/**
|
||||||
|
* Renderer-independent solo vehicle controls for a route-relative simulation.
|
||||||
|
*
|
||||||
|
* Inputs are normalized action snapshots rather than DOM events, so keyboards,
|
||||||
|
* gamepads, touch controls, remote clients, and recorded replays all drive the
|
||||||
|
* same deterministic fixed-step state machine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { GeographicPoint, TransportPack } from "./types.ts";
|
||||||
|
import {
|
||||||
|
buildRoutePath,
|
||||||
|
sampleRoute,
|
||||||
|
type RoutePath,
|
||||||
|
type RouteSample,
|
||||||
|
} from "./vehicleSim.ts";
|
||||||
|
|
||||||
|
const MPH_TO_MPS = 0.44704;
|
||||||
|
const EARTH_RADIUS_M = 6_371_000;
|
||||||
|
const TWO_PI = Math.PI * 2;
|
||||||
|
|
||||||
|
export type VehicleControlMode = "assisted" | "manual";
|
||||||
|
export type VehicleModeRequest = "none" | VehicleControlMode;
|
||||||
|
|
||||||
|
/** Device-neutral actions sampled for one rendered or fixed simulation frame. */
|
||||||
|
export interface VehicleActionSnapshot {
|
||||||
|
/** Accelerator position in the inclusive range [0, 1]. */
|
||||||
|
throttle: number;
|
||||||
|
/** Service brake position in the inclusive range [0, 1]. */
|
||||||
|
brake: number;
|
||||||
|
/** Steering input where -1 is full left and 1 is full right. */
|
||||||
|
steering: number;
|
||||||
|
handbrake: boolean;
|
||||||
|
/** One-shot mode request. Meaningful even when all analogue axes are neutral. */
|
||||||
|
modeRequest: VehicleModeRequest;
|
||||||
|
/** One-shot request to restore the configured initial state. */
|
||||||
|
reset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NEUTRAL_VEHICLE_ACTIONS: Readonly<VehicleActionSnapshot> = Object.freeze({
|
||||||
|
throttle: 0,
|
||||||
|
brake: 0,
|
||||||
|
steering: 0,
|
||||||
|
handbrake: false,
|
||||||
|
modeRequest: "none",
|
||||||
|
reset: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
export interface VehicleControllerOptions {
|
||||||
|
routeId: string;
|
||||||
|
mode?: VehicleControlMode;
|
||||||
|
direction?: 1 | -1;
|
||||||
|
initialDistanceM?: number;
|
||||||
|
initialLateralOffsetM?: number;
|
||||||
|
initialSpeedMps?: number;
|
||||||
|
/** Defaults to 60 Hz and is clamped to a safe simulation range. */
|
||||||
|
fixedStepSeconds?: number;
|
||||||
|
/** Caps catch-up after a sleeping tab. Defaults to 0.25 seconds. */
|
||||||
|
maxFrameDeltaSeconds?: number;
|
||||||
|
maximumSpeedMps?: number;
|
||||||
|
assistedCruiseRatio?: number;
|
||||||
|
guardrailOffsetM?: number;
|
||||||
|
wheelRadiusM?: number;
|
||||||
|
/**
|
||||||
|
* Multiplies longitudinal route progress without changing acceleration or
|
||||||
|
* steering response. State-scale boards use compression; metre-scale roads
|
||||||
|
* leave this at 1. Defaults to 1.
|
||||||
|
*/
|
||||||
|
travelScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleControllerState extends GeographicPoint {
|
||||||
|
routeId: string;
|
||||||
|
mode: VehicleControlMode;
|
||||||
|
direction: 1 | -1;
|
||||||
|
/** Distance from the route's declared start, wrapped to its total length. */
|
||||||
|
distanceM: number;
|
||||||
|
progress: number;
|
||||||
|
/** Signed offset from route centre; positive is to the driver's right. */
|
||||||
|
lateralOffsetM: number;
|
||||||
|
speedMps: number;
|
||||||
|
/** Smoothed normalized steering position, independent of input device. */
|
||||||
|
steering: number;
|
||||||
|
routeHeadingDeg: number;
|
||||||
|
headingDeg: number;
|
||||||
|
segmentId: string;
|
||||||
|
roadName: string;
|
||||||
|
speedLimitMph: number;
|
||||||
|
wheelRadians: number;
|
||||||
|
guardrailContact: boolean;
|
||||||
|
elapsedSteps: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleControllerSnapshot extends VehicleControllerState {}
|
||||||
|
|
||||||
|
/** A held input snapshot and its exact duration in fixed simulation steps. */
|
||||||
|
export interface TimedVehicleInputFrame {
|
||||||
|
steps: number;
|
||||||
|
actions?: Partial<VehicleActionSnapshot>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleReplayResult {
|
||||||
|
/** Initial state followed by one snapshot after every simulated step. */
|
||||||
|
trajectory: readonly VehicleControllerSnapshot[];
|
||||||
|
final: VehicleControllerSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolvedOptions {
|
||||||
|
routeId: string;
|
||||||
|
mode: VehicleControlMode;
|
||||||
|
direction: 1 | -1;
|
||||||
|
initialDistanceM: number;
|
||||||
|
initialLateralOffsetM: number;
|
||||||
|
initialSpeedMps: number;
|
||||||
|
fixedStepSeconds: number;
|
||||||
|
maxFrameDeltaSeconds: number;
|
||||||
|
maximumSpeedMps: number;
|
||||||
|
assistedCruiseRatio: number;
|
||||||
|
guardrailOffsetM: number;
|
||||||
|
wheelRadiusM: number;
|
||||||
|
travelScale: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteOr(value: number | undefined, fallback: number): number {
|
||||||
|
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(value: number, modulus: number): number {
|
||||||
|
return ((value % modulus) + modulus) % modulus;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToward(value: number, target: number, maximumDelta: number): number {
|
||||||
|
if (value < target) return Math.min(value + maximumDelta, target);
|
||||||
|
if (value > target) return Math.max(value - maximumDelta, target);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp and sanitize input from any adapter before it reaches simulation. */
|
||||||
|
export function normalizeVehicleActions(
|
||||||
|
actions: Partial<VehicleActionSnapshot> | undefined,
|
||||||
|
): VehicleActionSnapshot {
|
||||||
|
const modeRequest = actions?.modeRequest;
|
||||||
|
return {
|
||||||
|
throttle: clamp(finiteOr(actions?.throttle, 0), 0, 1),
|
||||||
|
brake: clamp(finiteOr(actions?.brake, 0), 0, 1),
|
||||||
|
steering: clamp(finiteOr(actions?.steering, 0), -1, 1),
|
||||||
|
handbrake: actions?.handbrake === true,
|
||||||
|
modeRequest: modeRequest === "manual" || modeRequest === "assisted" ? modeRequest : "none",
|
||||||
|
reset: actions?.reset === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveOptions(options: VehicleControllerOptions): ResolvedOptions {
|
||||||
|
return {
|
||||||
|
routeId: options.routeId,
|
||||||
|
mode: options.mode === "manual" ? "manual" : "assisted",
|
||||||
|
direction: options.direction === -1 ? -1 : 1,
|
||||||
|
initialDistanceM: finiteOr(options.initialDistanceM, 0),
|
||||||
|
initialLateralOffsetM: finiteOr(options.initialLateralOffsetM, 0),
|
||||||
|
initialSpeedMps: Math.max(0, finiteOr(options.initialSpeedMps, 0)),
|
||||||
|
fixedStepSeconds: clamp(finiteOr(options.fixedStepSeconds, 1 / 60), 1 / 240, 0.1),
|
||||||
|
maxFrameDeltaSeconds: clamp(finiteOr(options.maxFrameDeltaSeconds, 0.25), 0.05, 1),
|
||||||
|
maximumSpeedMps: clamp(finiteOr(options.maximumSpeedMps, 58), 5, 100),
|
||||||
|
assistedCruiseRatio: clamp(finiteOr(options.assistedCruiseRatio, 0.92), 0.25, 1.1),
|
||||||
|
guardrailOffsetM: clamp(finiteOr(options.guardrailOffsetM, 5.4), 1, 20),
|
||||||
|
wheelRadiusM: clamp(finiteOr(options.wheelRadiusM, 0.36), 0.1, 1),
|
||||||
|
travelScale: clamp(finiteOr(options.travelScale, 1), 1, 10_000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasManualIntent(actions: VehicleActionSnapshot): boolean {
|
||||||
|
return (
|
||||||
|
actions.modeRequest === "manual" ||
|
||||||
|
actions.handbrake ||
|
||||||
|
actions.throttle > 0.02 ||
|
||||||
|
actions.brake > 0.02 ||
|
||||||
|
Math.abs(actions.steering) > 0.08
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function offsetPoint(sample: RouteSample, lateralOffsetM: number): GeographicPoint {
|
||||||
|
const heading = (sample.headingDeg * Math.PI) / 180;
|
||||||
|
// Right-hand normal to a compass bearing: south for eastbound, east for northbound.
|
||||||
|
const northM = -Math.sin(heading) * lateralOffsetM;
|
||||||
|
const eastM = Math.cos(heading) * lateralOffsetM;
|
||||||
|
const latitudeRadians = (sample.lat * Math.PI) / 180;
|
||||||
|
return {
|
||||||
|
lat: sample.lat + (northM / EARTH_RADIUS_M) * (180 / Math.PI),
|
||||||
|
lng:
|
||||||
|
sample.lng +
|
||||||
|
(eastM / (EARTH_RADIUS_M * Math.max(0.01, Math.cos(latitudeRadians)))) * (180 / Math.PI),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic route-relative driving state machine.
|
||||||
|
*
|
||||||
|
* `tick` adapts render time to a fixed clock. `stepFixed` is the authoritative
|
||||||
|
* primitive for tests, networking, and replay and always advances exactly once.
|
||||||
|
*/
|
||||||
|
export class VehicleController {
|
||||||
|
private readonly pack: TransportPack;
|
||||||
|
private readonly options: ResolvedOptions;
|
||||||
|
private path: RoutePath;
|
||||||
|
private accumulator = 0;
|
||||||
|
private readonly current: VehicleControllerState;
|
||||||
|
|
||||||
|
constructor(pack: TransportPack, options: VehicleControllerOptions) {
|
||||||
|
this.pack = pack;
|
||||||
|
this.options = resolveOptions(options);
|
||||||
|
this.path = buildRoutePath(pack, this.options.routeId);
|
||||||
|
const sample = sampleRoute(this.path, this.options.initialDistanceM, this.options.direction);
|
||||||
|
const point = offsetPoint(sample, 0);
|
||||||
|
this.current = {
|
||||||
|
...point,
|
||||||
|
routeId: this.path.route.id,
|
||||||
|
mode: this.options.mode,
|
||||||
|
direction: this.options.direction,
|
||||||
|
distanceM: 0,
|
||||||
|
progress: 0,
|
||||||
|
lateralOffsetM: 0,
|
||||||
|
speedMps: 0,
|
||||||
|
steering: 0,
|
||||||
|
routeHeadingDeg: sample.headingDeg,
|
||||||
|
headingDeg: sample.headingDeg,
|
||||||
|
segmentId: sample.segmentId,
|
||||||
|
roadName: sample.roadName,
|
||||||
|
speedLimitMph: sample.speedLimitMph,
|
||||||
|
wheelRadians: 0,
|
||||||
|
guardrailContact: false,
|
||||||
|
elapsedSteps: 0,
|
||||||
|
};
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
fixedStepSeconds(): number {
|
||||||
|
return this.options.fixedStepSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
routeId(): string {
|
||||||
|
return this.path.route.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable state object for allocation-free polling. Treat it as read-only. */
|
||||||
|
state(): Readonly<VehicleControllerState> {
|
||||||
|
return this.current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detached state suitable for logs, network frames, and equality assertions. */
|
||||||
|
snapshot(): VehicleControllerSnapshot {
|
||||||
|
return { ...this.current };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Restore the configured spawn state and clear pending fractional time. */
|
||||||
|
reset(): void {
|
||||||
|
this.accumulator = 0;
|
||||||
|
const distanceM = wrap(this.options.initialDistanceM, this.path.lengthM);
|
||||||
|
const lateralOffsetM = clamp(
|
||||||
|
this.options.initialLateralOffsetM,
|
||||||
|
-this.options.guardrailOffsetM,
|
||||||
|
this.options.guardrailOffsetM,
|
||||||
|
);
|
||||||
|
const speedMps = clamp(this.options.initialSpeedMps, 0, this.options.maximumSpeedMps);
|
||||||
|
const sample = sampleRoute(this.path, distanceM, this.options.direction);
|
||||||
|
const point = offsetPoint(sample, lateralOffsetM);
|
||||||
|
Object.assign(this.current, point, {
|
||||||
|
routeId: this.path.route.id,
|
||||||
|
mode: this.options.mode,
|
||||||
|
direction: this.options.direction,
|
||||||
|
distanceM,
|
||||||
|
progress: distanceM / this.path.lengthM,
|
||||||
|
lateralOffsetM,
|
||||||
|
speedMps,
|
||||||
|
steering: 0,
|
||||||
|
routeHeadingDeg: sample.headingDeg,
|
||||||
|
headingDeg: sample.headingDeg,
|
||||||
|
segmentId: sample.segmentId,
|
||||||
|
roadName: sample.roadName,
|
||||||
|
speedLimitMph: sample.speedLimitMph,
|
||||||
|
wheelRadians: 0,
|
||||||
|
guardrailContact: false,
|
||||||
|
elapsedSteps: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change corridor as an explicit reset. Progress may optionally be preserved,
|
||||||
|
* which is useful for switching route variants without retaining stale metres.
|
||||||
|
*/
|
||||||
|
setRoute(routeId: string, preserveProgress = false): void {
|
||||||
|
if (routeId === this.path.route.id) return;
|
||||||
|
const previousProgress = this.current.progress;
|
||||||
|
this.path = buildRoutePath(this.pack, routeId);
|
||||||
|
this.options.routeId = routeId;
|
||||||
|
this.options.initialDistanceM = preserveProgress ? previousProgress * this.path.lengthM : 0;
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance rendered seconds and return the number of fixed steps executed. */
|
||||||
|
tick(
|
||||||
|
deltaSeconds: number,
|
||||||
|
actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS,
|
||||||
|
): number {
|
||||||
|
if (!Number.isFinite(deltaSeconds) || deltaSeconds <= 0) return 0;
|
||||||
|
const normalized = normalizeVehicleActions(actions);
|
||||||
|
if (normalized.reset) {
|
||||||
|
this.reset();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
this.accumulator += Math.min(deltaSeconds, this.options.maxFrameDeltaSeconds);
|
||||||
|
let steps = 0;
|
||||||
|
while (this.accumulator + Number.EPSILON >= this.options.fixedStepSeconds) {
|
||||||
|
this.stepNormalized(normalized);
|
||||||
|
this.accumulator -= this.options.fixedStepSeconds;
|
||||||
|
steps += 1;
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance exactly one authoritative simulation step. */
|
||||||
|
stepFixed(actions: Partial<VehicleActionSnapshot> = NEUTRAL_VEHICLE_ACTIONS): void {
|
||||||
|
const normalized = normalizeVehicleActions(actions);
|
||||||
|
if (normalized.reset) {
|
||||||
|
this.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.stepNormalized(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stepNormalized(actions: VehicleActionSnapshot): void {
|
||||||
|
const dt = this.options.fixedStepSeconds;
|
||||||
|
const manualIntent = hasManualIntent(actions);
|
||||||
|
// Direct human input always wins, including over a simultaneous request to
|
||||||
|
// resume assistance. A neutral assisted request can re-engage on the next step.
|
||||||
|
if (manualIntent) this.current.mode = "manual";
|
||||||
|
else if (actions.modeRequest === "assisted") this.current.mode = "assisted";
|
||||||
|
|
||||||
|
let throttle = actions.throttle;
|
||||||
|
let brake = actions.brake;
|
||||||
|
let steeringTarget = actions.steering;
|
||||||
|
|
||||||
|
if (this.current.mode === "assisted") {
|
||||||
|
const roadTarget = this.current.speedLimitMph * MPH_TO_MPS * this.options.assistedCruiseRatio;
|
||||||
|
const targetSpeed = Math.min(roadTarget, this.options.maximumSpeedMps);
|
||||||
|
const speedError = targetSpeed - this.current.speedMps;
|
||||||
|
throttle = clamp(speedError / 5, 0, 1);
|
||||||
|
brake = clamp(-speedError / 7, 0, 1);
|
||||||
|
steeringTarget = clamp(-this.current.lateralOffsetM / 2.4, -1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.current.steering = moveToward(this.current.steering, steeringTarget, 3.8 * dt);
|
||||||
|
|
||||||
|
const aeroDrag = this.current.speedMps * this.current.speedMps * 0.0018;
|
||||||
|
const rollingDrag = this.current.speedMps > 0 ? 0.12 : 0;
|
||||||
|
const engineFade = 1 - 0.55 * (this.current.speedMps / this.options.maximumSpeedMps);
|
||||||
|
const acceleration =
|
||||||
|
throttle * 5.4 * Math.max(0.2, engineFade) -
|
||||||
|
brake * 9.5 -
|
||||||
|
(actions.handbrake ? 13 : 0) -
|
||||||
|
aeroDrag -
|
||||||
|
rollingDrag;
|
||||||
|
this.current.speedMps = clamp(
|
||||||
|
this.current.speedMps + acceleration * dt,
|
||||||
|
0,
|
||||||
|
this.options.maximumSpeedMps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const previousDistance = this.current.distanceM;
|
||||||
|
const physicalTravelled = this.current.speedMps * dt;
|
||||||
|
const routeTravelled = physicalTravelled * this.options.travelScale;
|
||||||
|
this.current.distanceM = wrap(
|
||||||
|
previousDistance + routeTravelled * this.current.direction,
|
||||||
|
this.path.lengthM,
|
||||||
|
);
|
||||||
|
|
||||||
|
let proposedLateral =
|
||||||
|
this.current.lateralOffsetM + this.current.steering * this.current.speedMps * 0.2 * dt;
|
||||||
|
if (this.current.mode === "assisted") {
|
||||||
|
// Assistance damps the final few centimetres without an abrupt lane snap.
|
||||||
|
proposedLateral *= Math.exp(-0.35 * dt);
|
||||||
|
}
|
||||||
|
this.current.guardrailContact = Math.abs(proposedLateral) > this.options.guardrailOffsetM;
|
||||||
|
if (this.current.guardrailContact) {
|
||||||
|
proposedLateral = clamp(
|
||||||
|
proposedLateral,
|
||||||
|
-this.options.guardrailOffsetM,
|
||||||
|
this.options.guardrailOffsetM,
|
||||||
|
);
|
||||||
|
this.current.speedMps = Math.min(this.current.speedMps * 0.78, 12);
|
||||||
|
this.current.steering *= 0.35;
|
||||||
|
}
|
||||||
|
this.current.lateralOffsetM = proposedLateral;
|
||||||
|
|
||||||
|
const sample = sampleRoute(this.path, this.current.distanceM, this.current.direction);
|
||||||
|
const point = offsetPoint(sample, this.current.lateralOffsetM);
|
||||||
|
const wheelDelta = physicalTravelled / this.options.wheelRadiusM;
|
||||||
|
Object.assign(this.current, point, {
|
||||||
|
progress: this.current.distanceM / this.path.lengthM,
|
||||||
|
routeHeadingDeg: sample.headingDeg,
|
||||||
|
headingDeg: sample.headingDeg + this.current.steering * 9,
|
||||||
|
segmentId: sample.segmentId,
|
||||||
|
roadName: sample.roadName,
|
||||||
|
speedLimitMph: sample.speedLimitMph,
|
||||||
|
wheelRadians: wrap(this.current.wheelRadians + wheelDelta, TWO_PI),
|
||||||
|
elapsedSteps: this.current.elapsedSteps + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute an exact, renderer-independent input recording. */
|
||||||
|
export function replayVehicleInputs(
|
||||||
|
pack: TransportPack,
|
||||||
|
options: VehicleControllerOptions,
|
||||||
|
frames: readonly TimedVehicleInputFrame[],
|
||||||
|
): VehicleReplayResult {
|
||||||
|
const controller = new VehicleController(pack, options);
|
||||||
|
const trajectory: VehicleControllerSnapshot[] = [controller.snapshot()];
|
||||||
|
for (const frame of frames) {
|
||||||
|
const steps = Math.max(0, Math.floor(finiteOr(frame.steps, 0)));
|
||||||
|
const actions = normalizeVehicleActions(frame.actions);
|
||||||
|
for (let index = 0; index < steps; index += 1) {
|
||||||
|
// Reset and mode requests are edge-triggered at the start of a timed frame.
|
||||||
|
controller.stepFixed(
|
||||||
|
index === 0
|
||||||
|
? actions
|
||||||
|
: { ...actions, modeRequest: "none", reset: false },
|
||||||
|
);
|
||||||
|
trajectory.push(controller.snapshot());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const final = trajectory.at(-1) ?? controller.snapshot();
|
||||||
|
return { trajectory, final };
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
/**
|
||||||
|
* Deterministic road traffic over a serializable `TransportPack`.
|
||||||
|
*
|
||||||
|
* The simulation owns route progress, never render objects. It advances on a
|
||||||
|
* fixed clock and exposes plain geographic poses, so a city scene can project
|
||||||
|
* them through `World` while a server or replay runner can use the same code
|
||||||
|
* without three.js. Long background-tab deltas are capped rather than replayed
|
||||||
|
* as a burst; traffic resumes smoothly instead of teleporting through a route.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
GeographicPoint,
|
||||||
|
TransportPack,
|
||||||
|
TransportRoute,
|
||||||
|
TransportSegment,
|
||||||
|
} from "./types.ts";
|
||||||
|
|
||||||
|
const EARTH_RADIUS_M = 6_371_000;
|
||||||
|
const MPH_TO_MPS = 0.44704;
|
||||||
|
const FIXED_STEP = 1 / 20;
|
||||||
|
const MAX_FRAME_DELTA = 0.25;
|
||||||
|
|
||||||
|
export interface RouteLeg {
|
||||||
|
segment: TransportSegment;
|
||||||
|
from: GeographicPoint;
|
||||||
|
to: GeographicPoint;
|
||||||
|
lengthM: number;
|
||||||
|
startM: number;
|
||||||
|
endM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoutePath {
|
||||||
|
route: TransportRoute;
|
||||||
|
legs: readonly RouteLeg[];
|
||||||
|
lengthM: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouteSample extends GeographicPoint {
|
||||||
|
/** Compass bearing in degrees clockwise from true north. */
|
||||||
|
headingDeg: number;
|
||||||
|
segmentId: string;
|
||||||
|
roadName: string;
|
||||||
|
speedLimitMph: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehiclePose extends RouteSample {
|
||||||
|
id: string;
|
||||||
|
routeId: string;
|
||||||
|
/** 0 is the median-side lane; positive values move toward the shoulder. */
|
||||||
|
lane: number;
|
||||||
|
direction: 1 | -1;
|
||||||
|
speedMps: number;
|
||||||
|
distanceM: number;
|
||||||
|
progress: number;
|
||||||
|
wheelRadians: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleSimulationOptions {
|
||||||
|
routeId: string;
|
||||||
|
count?: number;
|
||||||
|
seed?: number;
|
||||||
|
/** Time compression for the statewide board. Defaults to 900x. */
|
||||||
|
timeScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VehicleState {
|
||||||
|
pose: VehiclePose;
|
||||||
|
cruise: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function radians(degrees: number): number {
|
||||||
|
return (degrees * Math.PI) / 180;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Equirectangular distance; sub-metre agreement is unnecessary at this scale. */
|
||||||
|
export function distanceMetres(a: GeographicPoint, b: GeographicPoint): number {
|
||||||
|
const meanLat = radians((a.lat + b.lat) / 2);
|
||||||
|
const dy = radians(b.lat - a.lat);
|
||||||
|
const dx = radians(b.lng - a.lng) * Math.cos(meanLat);
|
||||||
|
return Math.hypot(dx, dy) * EARTH_RADIUS_M;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bearing(a: GeographicPoint, b: GeographicPoint): number {
|
||||||
|
const meanLat = radians((a.lat + b.lat) / 2);
|
||||||
|
const north = b.lat - a.lat;
|
||||||
|
const east = (b.lng - a.lng) * Math.cos(meanLat);
|
||||||
|
return (Math.atan2(east, north) * 180) / Math.PI;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoutePath(pack: TransportPack, routeId: string): RoutePath {
|
||||||
|
const route = pack.routes.find((candidate) => candidate.id === routeId);
|
||||||
|
if (!route) throw new Error(`transport: unknown route "${routeId}"`);
|
||||||
|
const nodes = new Map(pack.nodes.map((node) => [node.id, node]));
|
||||||
|
const segments = new Map(pack.segments.map((segment) => [segment.id, segment]));
|
||||||
|
const legs: RouteLeg[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
for (const id of route.segmentIds) {
|
||||||
|
const segment = segments.get(id);
|
||||||
|
if (!segment) throw new Error(`transport: route "${routeId}" references missing segment "${id}"`);
|
||||||
|
const from = nodes.get(segment.fromNodeId)?.position;
|
||||||
|
const to = nodes.get(segment.toNodeId)?.position;
|
||||||
|
if (!from || !to) throw new Error(`transport: segment "${id}" references a missing node`);
|
||||||
|
const lengthM = distanceMetres(from, to);
|
||||||
|
if (!(lengthM > 0)) throw new Error(`transport: segment "${id}" has no length`);
|
||||||
|
legs.push({ segment, from, to, lengthM, startM: cursor, endM: cursor + lengthM });
|
||||||
|
cursor += lengthM;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (legs.length === 0) throw new Error(`transport: route "${routeId}" is empty`);
|
||||||
|
return { route, legs, lengthM: cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrap(value: number, modulus: number): number {
|
||||||
|
return ((value % modulus) + modulus) % modulus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sampleRoute(path: RoutePath, distanceM: number, direction: 1 | -1 = 1): RouteSample {
|
||||||
|
const travelled = wrap(distanceM, path.lengthM);
|
||||||
|
const leg = path.legs.find((candidate) => travelled <= candidate.endM) ?? path.legs.at(-1);
|
||||||
|
if (!leg) throw new Error(`transport: route "${path.route.id}" has no legs`);
|
||||||
|
const t = Math.max(0, Math.min(1, (travelled - leg.startM) / leg.lengthM));
|
||||||
|
const from = direction === 1 ? leg.from : leg.to;
|
||||||
|
const to = direction === 1 ? leg.to : leg.from;
|
||||||
|
// `distanceM` is always measured from the route's declared start. Reverse
|
||||||
|
// traffic advances that scalar downward, so its geographic interpolation is
|
||||||
|
// still `t`; only its bearing is reversed. Mirroring `t` here makes a
|
||||||
|
// southbound car move north while visually facing south.
|
||||||
|
const u = t;
|
||||||
|
return {
|
||||||
|
lat: leg.from.lat + (leg.to.lat - leg.from.lat) * u,
|
||||||
|
lng: leg.from.lng + (leg.to.lng - leg.from.lng) * u,
|
||||||
|
headingDeg: bearing(from, to),
|
||||||
|
segmentId: leg.segment.id,
|
||||||
|
roadName: leg.segment.roadName,
|
||||||
|
speedLimitMph: leg.segment.speedLimitMph,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Small reproducible generator; simulation results never depend on `Math.random()`. */
|
||||||
|
function seeded(seed: number): () => number {
|
||||||
|
let state = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
state += 0x6d2b79f5;
|
||||||
|
let t = state;
|
||||||
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||||
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||||
|
return ((t ^ (t >>> 14)) >>> 0) / 4_294_967_296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class VehicleSimulation {
|
||||||
|
private readonly pack: TransportPack;
|
||||||
|
private path: RoutePath;
|
||||||
|
private readonly count: number;
|
||||||
|
private readonly seed: number;
|
||||||
|
private readonly timeScale: number;
|
||||||
|
private accumulator = 0;
|
||||||
|
private states: VehicleState[] = [];
|
||||||
|
private poseView: VehiclePose[] = [];
|
||||||
|
|
||||||
|
constructor(pack: TransportPack, options: VehicleSimulationOptions) {
|
||||||
|
this.pack = pack;
|
||||||
|
this.path = buildRoutePath(pack, options.routeId);
|
||||||
|
this.count = Math.max(1, Math.min(64, Math.floor(options.count ?? 12)));
|
||||||
|
this.seed = options.seed ?? 115;
|
||||||
|
this.timeScale = Math.max(1, options.timeScale ?? 900);
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
routeId(): string {
|
||||||
|
return this.path.route.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRoute(routeId: string): void {
|
||||||
|
if (routeId === this.path.route.id) return;
|
||||||
|
this.path = buildRoutePath(this.pack, routeId);
|
||||||
|
this.accumulator = 0;
|
||||||
|
this.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
private reset(): void {
|
||||||
|
const rand = seeded(this.seed ^ hash(this.path.route.id));
|
||||||
|
this.states = Array.from({ length: this.count }, (_, index) => {
|
||||||
|
// The first vehicle is the northbound follow-camera hero. Every third
|
||||||
|
// background vehicle after it runs south so both carriageways stay alive.
|
||||||
|
const direction: 1 | -1 = index > 0 && index % 3 === 0 ? -1 : 1;
|
||||||
|
const distanceM = ((index + rand() * 0.6) / this.count) * this.path.lengthM;
|
||||||
|
const sample = sampleRoute(this.path, distanceM, direction);
|
||||||
|
const cruise = 0.86 + rand() * 0.12;
|
||||||
|
const speedMps = sample.speedLimitMph * MPH_TO_MPS * cruise;
|
||||||
|
return {
|
||||||
|
cruise,
|
||||||
|
pose: {
|
||||||
|
...sample,
|
||||||
|
id: index === 0 ? "model-x-hero" : `model-x-${String(index + 1).padStart(2, "0")}`,
|
||||||
|
routeId: this.path.route.id,
|
||||||
|
lane: index % 2,
|
||||||
|
direction,
|
||||||
|
speedMps,
|
||||||
|
distanceM,
|
||||||
|
progress: distanceM / this.path.lengthM,
|
||||||
|
wheelRadians: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// Keep one stable array for render consumers. The pose objects within it
|
||||||
|
// are already mutated in place by `step`, so a 60 fps scene should not pay
|
||||||
|
// for a fresh wrapper array on every frame.
|
||||||
|
this.poseView = this.states.map((state) => state.pose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Advance by rendered seconds; internally every state change is a 20 Hz step. */
|
||||||
|
tick(dt: number): void {
|
||||||
|
if (!Number.isFinite(dt) || dt <= 0) return;
|
||||||
|
this.accumulator += Math.min(dt, MAX_FRAME_DELTA);
|
||||||
|
while (this.accumulator >= FIXED_STEP) {
|
||||||
|
this.step(FIXED_STEP);
|
||||||
|
this.accumulator -= FIXED_STEP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private step(dt: number): void {
|
||||||
|
for (const state of this.states) {
|
||||||
|
const previous = state.pose;
|
||||||
|
const signed = previous.speedMps * this.timeScale * dt * previous.direction;
|
||||||
|
const distanceM = wrap(previous.distanceM + signed, this.path.lengthM);
|
||||||
|
const sample = sampleRoute(this.path, distanceM, previous.direction);
|
||||||
|
const speedMps = sample.speedLimitMph * MPH_TO_MPS * state.cruise;
|
||||||
|
Object.assign(previous, sample, {
|
||||||
|
speedMps,
|
||||||
|
distanceM,
|
||||||
|
progress: distanceM / this.path.lengthM,
|
||||||
|
// 0.36 m is a representative Model X tyre radius.
|
||||||
|
wheelRadians: wrap(previous.wheelRadians + (Math.abs(signed) / 0.36), Math.PI * 2),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stable objects, mutated in place; render layers may retain references. */
|
||||||
|
poses(): readonly VehiclePose[] {
|
||||||
|
return this.poseView;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hash(value: string): number {
|
||||||
|
let out = 2_166_136_261;
|
||||||
|
for (let index = 0; index < value.length; index += 1) {
|
||||||
|
out ^= value.charCodeAt(index);
|
||||||
|
out = Math.imul(out, 16_777_619);
|
||||||
|
}
|
||||||
|
return out >>> 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user